feat:更换企业授权
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m41s

This commit is contained in:
luo
2026-06-22 10:36:33 +08:00
parent 866a2587b3
commit d3b5f55c64
34 changed files with 2478 additions and 3335 deletions

View File

@@ -206,9 +206,9 @@
import { useRouter } from 'vue-router'
import { RoutesAlias } from '@/router/routesAlias'
import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElSwitch, ElCascader, ElTreeSelect } from 'element-plus'
import { ElMessage, ElSwitch, ElCascader } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { EnterpriseItem, ShopResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
@@ -236,8 +236,6 @@
const shopLoading = ref(false)
const tableRef = ref()
const currentEnterpriseId = ref<number>(0)
const shopTreeData = ref<ShopResponse[]>([])
// 店铺级联选择相关
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
@@ -499,22 +497,6 @@
})
}
if (hasAuth('enterprise_customer:card_authorization')) {
actions.push({
label: '卡授权',
handler: () => manageCards(row),
type: 'primary'
})
}
if (hasAuth('enterprise_customer:device_authorization')) {
actions.push({
label: '设备授权',
handler: () => manageDevices(row),
type: 'primary'
})
}
if (hasAuth('enterprise_customer:edit')) {
actions.push({
label: '编辑',
@@ -582,33 +564,6 @@
}
}
// 构建树形数据
const buildTreeData = (items: ShopResponse[]) => {
const map = new Map<number, ShopResponse & { children?: ShopResponse[] }>()
const tree: ShopResponse[] = []
// 先将所有项放入 map
items.forEach((item) => {
map.set(item.id, { ...item, children: [] })
})
// 构建树形结构
items.forEach((item) => {
const node = map.get(item.id)!
if (item.parent_id && map.has(item.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(item.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 获取企业客户列表
const getTableData = async () => {
loading.value = true
@@ -855,21 +810,6 @@
})
}
// 卡管理
const manageCards = (row: EnterpriseItem) => {
router.push({
path: RoutesAlias.EnterpriseCards,
query: { id: row.id }
})
}
const manageDevices = (row: EnterpriseItem) => {
router.push({
path: RoutesAlias.EnterpriseDevices,
query: { id: row.id }
})
}
// 企业客户操作菜单项配置
</script>

View File

@@ -297,8 +297,8 @@
v-model="usedDataForm.data_usage"
:min="0"
:max="usedDataMaxValue"
:precision="usedDataForm.unit === 'MB' ? 0 : 3"
:step="usedDataForm.unit === 'MB' ? 1 : 0.1"
:precision="3"
:step="0.1"
class="used-data-input__number"
placeholder="请输入新的套餐真实已用量"
/>
@@ -447,8 +447,8 @@
return
}
if (dataUsageMb === undefined || dataUsageMb < 0 || !Number.isInteger(dataUsageMb)) {
callback(new Error('请输入可换算为非负数 MB 的已用量'))
if (dataUsageMb === undefined || dataUsageMb < 0) {
callback(new Error('请输入可换算为非负数 MB 的已用量'))
return
}

View File

@@ -44,7 +44,6 @@
/>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
<ElButton @click="handleReset()">重置</ElButton>
<ElTag type="info" size="small"> {{ total }} </ElTag>
</div>
</div>
</template>
@@ -66,6 +65,14 @@
<div v-else v-loading="loading" class="wallet-table-wrapper">
<ElTable v-if="hasTransactions" :data="transactionList" class="wallet-table" border>
<ElTableColumn prop="reference_no" label="业务编号">
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
</span>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn label="交易类型" width="100" align="center">
<template #default="scope">
<ElTag :type="getTransactionTypeTag(scope.row.transaction_type)" size="small">
@@ -103,14 +110,6 @@
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="reference_no" label="业务编号" min-width="180" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.reference_no" class="reference-no-link">
{{ scope.row.reference_no }}
</span>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="150" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || '-' }}

View File

@@ -44,17 +44,17 @@
</ElOption>
</ElSelect>
<template v-if="noSeriesId">
<div style=" margin-top: 4px; font-size: 12px;color: var(--el-text-color-warning)">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-warning)">
该设备未关联套餐系列无法购买套餐
</div>
</template>
<template v-else-if="packagesEmptyWithSeriesId">
<div style=" margin-top: 4px; font-size: 12px;color: var(--el-text-color-info)">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-text-color-info)">
该系列暂无可用套餐
</div>
</template>
<template v-else-if="selectedPackageIsGift">
<div style=" margin-top: 4px; font-size: 12px;color: var(--el-color-warning)">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-color-warning)">
提示: 赠送套餐只能使用线下支付方式且必须上传支付凭证
</div>
</template>
@@ -83,23 +83,13 @@
label="支付凭证"
prop="payment_voucher_key"
>
<ElUpload
<VoucherUpload
ref="uploadRef"
class="payment-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
:limit="1"
>
<el-icon class="payment-voucher-upload__icon"><UploadFilled /></el-icon>
<div class="payment-voucher-upload__text"> 将支付凭证拖到此处<em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip">支持 jpgpng 格式文件大小不超过 5MB</div>
</template>
</ElUpload>
v-model="form.payment_voucher_key"
voucher-name="支付凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('payment_voucher_key')"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -111,7 +101,7 @@
:loading="loading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '图片上传中...' : '提交' }}
{{ voucherUploading ? '凭证上传中...' : '提交' }}
</ElButton>
</div>
</template>
@@ -119,24 +109,22 @@
</template>
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElMessage,
ElOption,
ElSelect,
ElTag,
ElUpload
ElTag
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { OrderService, PackageManageService, StorageService } from '@/api/modules'
import type { FormInstance, FormRules } from 'element-plus'
import { OrderService, PackageManageService } from '@/api/modules'
import type { CreateOrderRequest, PackageResponse } from '@/types/api'
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
interface Props {
modelValue: boolean
@@ -156,13 +144,12 @@
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const loading = ref(false)
const voucherUploading = ref(false)
const packageSearchLoading = ref(false)
const packageOptions = ref<PackageResponse[]>([])
const packagesEmptyWithSeriesId = ref(false)
let activeVoucherUploadId = 0
const form = reactive({
package_id: undefined as number | undefined,
@@ -311,80 +298,6 @@
}
}
const invalidateVoucherUploadState = (clearFileKey = false) => {
activeVoucherUploadId += 1
voucherUploading.value = false
if (clearFileKey) {
form.payment_voucher_key = undefined
}
}
const handleVoucherFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
uploadRef.value?.clearFiles()
return
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
uploadRef.value?.clearFiles()
return
}
const requestId = ++activeVoucherUploadId
voucherUploading.value = true
form.payment_voucher_key = undefined
try {
ElMessage.info('正在上传支付凭证...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
if (requestId !== activeVoucherUploadId) return
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
uploadRef.value?.clearFiles()
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, file.type)
if (requestId !== activeVoucherUploadId) return
form.payment_voucher_key = file_key
ElMessage.success('上传成功')
nextTick(() => {
formRef.value?.validateField('payment_voucher_key')
})
} catch (error: any) {
if (requestId !== activeVoucherUploadId) return
console.error('上传支付凭证失败:', error)
ElMessage.error(error?.message || '上传失败,请重试')
form.payment_voucher_key = undefined
uploadRef.value?.clearFiles()
} finally {
if (requestId === activeVoucherUploadId) {
voucherUploading.value = false
}
}
}
const handleRemoveVoucher = () => {
invalidateVoucherUploadState(true)
}
const handleCancel = () => {
visible.value = false
}
@@ -447,10 +360,11 @@
formRef.value?.resetFields()
form.package_id = undefined
form.payment_method = 'wallet'
invalidateVoucherUploadState(true)
form.payment_voucher_key = undefined
voucherUploading.value = false
packageOptions.value = []
packagesEmptyWithSeriesId.value = false
uploadRef.value?.clearFiles()
uploadRef.value?.clearFiles(false)
}
</script>
@@ -460,34 +374,4 @@
gap: 12px;
justify-content: flex-end;
}
.payment-voucher-upload {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -5,7 +5,6 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="120"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -41,6 +40,22 @@
>
批量设置套餐系列
</ElButton>
<ElButton
type="primary"
:disabled="!selectedDevices.length"
@click="showEnterpriseDeviceAuthorizeDialog"
v-permission="'devices:authorize_enterprise'"
>
授权设备给企业
</ElButton>
<ElButton
type="warning"
:disabled="!selectedDevices.length"
@click="showEnterpriseDeviceRecallDialog"
v-permission="'devices:recall_enterprise_authorization'"
>
撤销设备授权
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'devices:export'">
导出
</ElButton>
@@ -677,6 +692,250 @@
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
/>
<ElDialog
v-model="enterpriseDeviceAuthorizeDialogVisible"
title="授权设备给企业"
width="620px"
@close="handleEnterpriseDeviceAuthorizeDialogClose"
>
<ElForm
ref="enterpriseDeviceAuthorizeFormRef"
:model="enterpriseDeviceAuthorizeForm"
:rules="enterpriseDeviceAuthorizeRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseDeviceAuthorizeForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceAuthorizeForm.selection_type">
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseDeviceAuthorizeForm.selection_type === DeviceSelectionType.LIST"
label="设备号列表"
>
<div>已选择 {{ selectedDevices.length }} 台设备</div>
</ElFormItem>
<template
v-if="enterpriseDeviceAuthorizeForm.selection_type === DeviceSelectionType.FILTER"
>
<ElFormItem label="设备号">
<ElInput
v-model="enterpriseDeviceAuthorizeForm.virtual_no"
placeholder="请输入设备号"
clearable
/>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseDeviceAuthorizeForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in searchBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="店铺名称">
<ElCascader
v-model="enterpriseDeviceAuthorizeForm.shop_id_path"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择店铺名称"
clearable
filterable
style="width: 100%"
/>
</ElFormItem>
</template>
<ElFormItem label="备注">
<ElInput
v-model="enterpriseDeviceAuthorizeForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseDeviceAuthorizeDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseDeviceAuthorize"
:loading="enterpriseDeviceAuthorizeLoading"
>
确认授权
</ElButton>
</div>
</template>
</ElDialog>
<ElDialog
v-model="enterpriseDeviceRecallDialogVisible"
title="撤销设备授权"
width="620px"
@close="handleEnterpriseDeviceRecallDialogClose"
>
<ElForm
ref="enterpriseDeviceRecallFormRef"
:model="enterpriseDeviceRecallForm"
:rules="enterpriseDeviceRecallRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseDeviceRecallForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选设备方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseDeviceRecallForm.selection_type">
<ElRadio label="list">设备号列表</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseDeviceRecallForm.selection_type === DeviceSelectionType.LIST"
label="设备号列表"
>
<div>已选择 {{ selectedDevices.length }} 台设备</div>
</ElFormItem>
<template
v-if="enterpriseDeviceRecallForm.selection_type === DeviceSelectionType.FILTER"
>
<ElFormItem label="设备号">
<ElInput
v-model="enterpriseDeviceRecallForm.virtual_no"
placeholder="请输入设备号"
clearable
/>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseDeviceRecallForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in searchBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
</template>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseDeviceRecallDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseDeviceRecall"
:loading="enterpriseDeviceRecallLoading"
>
确认撤销
</ElButton>
</div>
</template>
</ElDialog>
<ElDialog
v-model="enterpriseDeviceResultDialogVisible"
:title="enterpriseDeviceResultTitle"
width="700px"
>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ enterpriseDeviceOperationResult.success_count }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<ElTag type="danger">{{ enterpriseDeviceOperationResult.fail_count }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<div
v-if="
enterpriseDeviceOperationResult.failed_items &&
enterpriseDeviceOperationResult.failed_items.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="enterpriseDeviceOperationResult.failed_items" border max-height="300">
<ElTableColumn prop="virtual_no" label="设备号" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<div
v-if="
enterpriseDeviceOperationResult.authorized_devices &&
enterpriseDeviceOperationResult.authorized_devices.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">已授权设备</ElDivider>
<ElTable
:data="enterpriseDeviceOperationResult.authorized_devices"
border
max-height="220"
>
<ElTableColumn prop="virtual_no" label="设备号" width="180" />
<ElTableColumn prop="card_count" label="绑定卡数" />
</ElTable>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="enterpriseDeviceResultDialogVisible = false"
>确定</ElButton
>
</div>
</template>
</ElDialog>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="device"
@@ -699,7 +958,7 @@
CardService,
PackageSeriesService,
AssetService,
CarrierService
EnterpriseService
} from '@/api/modules'
import { DeviceSelectionType } from '@/types/api'
import { ElMessage, ElMessageBox, ElTag, ElIcon, ElRadioGroup, ElRadio } from 'element-plus'
@@ -720,6 +979,11 @@
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type {
EnterpriseAllocateDevicesResponse,
EnterpriseRecallDevicesResponse
} from '@/types/api/enterpriseDevice'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
@@ -781,6 +1045,20 @@
label: 'label',
children: 'children'
}
const loadTopLevelShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载顶级店铺失败:', error)
}
}
const seriesBindingShopCascadeOptions = ref<any[]>([])
const seriesBindingShopCascadeProps = {
lazy: true,
@@ -815,6 +1093,27 @@
}
const allocateResult = ref<AllocateDevicesResponse | null>(null)
const recallResult = ref<RecallDevicesResponse | null>(null)
const enterpriseDeviceAuthorizeDialogVisible = ref(false)
const enterpriseDeviceRecallDialogVisible = ref(false)
const enterpriseDeviceResultDialogVisible = ref(false)
const enterpriseDeviceAuthorizeLoading = ref(false)
const enterpriseDeviceRecallLoading = ref(false)
const enterpriseDeviceResultTitle = ref('')
const enterpriseDeviceAuthorizeFormRef = ref<FormInstance>()
const enterpriseDeviceRecallFormRef = ref<FormInstance>()
const enterpriseOptions = ref<EnterpriseItem[]>([])
const enterpriseLoading = ref(false)
const enterpriseDeviceOperationResult = ref<
Partial<EnterpriseAllocateDevicesResponse & EnterpriseRecallDevicesResponse> & {
success_count: number
fail_count: number
}
>({
success_count: 0,
fail_count: 0,
failed_items: null,
authorized_devices: null
})
// 套餐系列绑定相关
const seriesBindingDialogVisible = ref(false)
@@ -840,6 +1139,22 @@
created_at_end: ''
})
const seriesBindingForm = reactive(createInitialSeriesBindingState())
const createInitialEnterpriseDeviceAuthorizeState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: DeviceSelectionType.LIST,
virtual_no: '',
batch_no: '',
shop_id_path: undefined as number[] | undefined,
remark: ''
})
const createInitialEnterpriseDeviceRecallState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: DeviceSelectionType.LIST,
virtual_no: '',
batch_no: ''
})
const enterpriseDeviceAuthorizeForm = reactive(createInitialEnterpriseDeviceAuthorizeState())
const enterpriseDeviceRecallForm = reactive(createInitialEnterpriseDeviceRecallState())
const parseNumericStringToBigInt = (value: string): bigint | null => {
const normalizedValue = value.trim()
if (!normalizedValue || !/^\d+$/.test(normalizedValue)) {
@@ -931,6 +1246,13 @@
}
]
})
const createEnterpriseDeviceRules = () =>
reactive<FormRules>({
enterprise_id: [{ required: true, message: '请选择企业', trigger: 'change' }],
selection_type: [{ required: true, message: '请选择选设备方式', trigger: 'change' }]
})
const enterpriseDeviceAuthorizeRules = createEnterpriseDeviceRules()
const enterpriseDeviceRecallRules = createEnterpriseDeviceRules()
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 搜索表单店铺/系列选项
@@ -999,13 +1321,15 @@
shop_id: undefined as number | undefined,
series_id: undefined as number | undefined,
has_active_package: undefined as boolean | undefined,
authorized_enterprise_id: undefined as number | undefined,
is_authorized_to_enterprise: undefined as boolean | undefined,
dateRange: [] as string[],
created_at_start: '',
created_at_end: ''
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
const searchForm = reactive<Record<string, any>>({ ...initialSearchState })
// 搜索批次号(用于搜索表单)
const loadSearchBatchNoOptions = async (batchNo?: string) => {
@@ -1174,7 +1498,7 @@
}))
},
{
label: '起始至结束',
label: '起止时间',
prop: 'dateRange',
type: 'date',
config: {
@@ -1189,7 +1513,7 @@
// 分页
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '是否有生效套餐',
label: '效套餐',
prop: 'has_active_package',
type: 'select',
config: {
@@ -1201,6 +1525,37 @@
{ label: '否', value: false }
]
})
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '授权企业',
prop: 'authorized_enterprise_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: (query: string) => handleSearchEnterprise(query),
loading: enterpriseLoading.value,
placeholder: '请选择或搜索企业'
},
options: () =>
enterpriseOptions.value.map((enterprise) => ({
label: enterprise.enterprise_name,
value: enterprise.id
}))
})
searchFormItems.splice(searchFormItems.length - 1, 0, {
label: '授权状态',
prop: 'is_authorized_to_enterprise',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已授权', value: true },
{ label: '未授权', value: false }
]
})
const pagination = reactive({
page: 1,
@@ -1704,6 +2059,26 @@
}
}
const handleSearchEnterprise = async (query?: string) => {
try {
enterpriseLoading.value = true
const params = {
page: 1,
page_size: 20,
enterprise_name: query || undefined,
status: 1
}
const res = await EnterpriseService.getEnterprises(params)
if (res.code === 0) {
enterpriseOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索企业失败:', error)
} finally {
enterpriseLoading.value = false
}
}
let isFirstActivation = true
onMounted(() => {
getTableData()
@@ -1735,6 +2110,8 @@
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
authorized_enterprise_id: searchForm.authorized_enterprise_id || undefined,
is_authorized_to_enterprise: searchForm.is_authorized_to_enterprise ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
@@ -1763,6 +2140,8 @@
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
authorized_enterprise_id: searchForm.authorized_enterprise_id || undefined,
is_authorized_to_enterprise: searchForm.is_authorized_to_enterprise ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
@@ -1993,6 +2372,160 @@
recallForm.remark = ''
}
const getInferredDeviceEnterpriseId = () => {
const filterEnterpriseId = searchForm.authorized_enterprise_id
if (filterEnterpriseId) return filterEnterpriseId
const selectedEnterpriseIds = Array.from(
new Set(
selectedDevices.value
.map((device) => device.authorized_enterprise_id)
.filter((id): id is number => typeof id === 'number')
)
)
return selectedEnterpriseIds.length === 1 ? selectedEnterpriseIds[0] : undefined
}
const resetEnterpriseDeviceAuthorizeForm = () => {
Object.assign(enterpriseDeviceAuthorizeForm, createInitialEnterpriseDeviceAuthorizeState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER,
virtual_no: searchForm.virtual_no || '',
batch_no: searchForm.batch_no || '',
shop_id_path: undefined
})
}
const resetEnterpriseDeviceRecallForm = () => {
Object.assign(enterpriseDeviceRecallForm, createInitialEnterpriseDeviceRecallState(), {
enterprise_id: getInferredDeviceEnterpriseId(),
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER,
virtual_no: searchForm.virtual_no || '',
batch_no: searchForm.batch_no || ''
})
}
const showEnterpriseDeviceAuthorizeDialog = async () => {
resetEnterpriseDeviceAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadSearchBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseDeviceAuthorizeDialogVisible.value = true
enterpriseDeviceAuthorizeFormRef.value?.clearValidate()
}
const showEnterpriseDeviceRecallDialog = async () => {
resetEnterpriseDeviceRecallForm()
await Promise.all([handleSearchEnterprise(), loadSearchBatchNoOptions()])
enterpriseDeviceRecallDialogVisible.value = true
enterpriseDeviceRecallFormRef.value?.clearValidate()
}
const handleEnterpriseDeviceAuthorizeDialogClose = () => {
Object.assign(enterpriseDeviceAuthorizeForm, createInitialEnterpriseDeviceAuthorizeState())
enterpriseDeviceAuthorizeFormRef.value?.resetFields()
}
const handleEnterpriseDeviceRecallDialogClose = () => {
Object.assign(enterpriseDeviceRecallForm, createInitialEnterpriseDeviceRecallState())
enterpriseDeviceRecallFormRef.value?.resetFields()
}
const buildEnterpriseDeviceParams = (form: any) => {
const params: Record<string, unknown> = {
selection_type: form.selection_type,
remark: form.remark || undefined
}
if (form.selection_type === DeviceSelectionType.LIST) {
const deviceNos = selectedDevices.value.map((device) => device.virtual_no).filter(Boolean)
if (!deviceNos.length) {
ElMessage.warning('列表模式下请先选择设备')
return null
}
params.device_nos = deviceNos
} else {
if (form.batch_no) params.batch_no = form.batch_no
if (Array.isArray(form.shop_id_path) && form.shop_id_path.length > 0) {
params.shop_id = form.shop_id_path[form.shop_id_path.length - 1]
}
if (form.virtual_no) params.virtual_no = form.virtual_no
}
Object.keys(params).forEach((key) => {
if (params[key] === undefined || params[key] === '') delete params[key]
})
return params
}
const applyEnterpriseDeviceOperationSuccess = async (
title: string,
result: EnterpriseAllocateDevicesResponse | EnterpriseRecallDevicesResponse
) => {
enterpriseDeviceOperationResult.value = result
enterpriseDeviceResultTitle.value = title
enterpriseDeviceResultDialogVisible.value = true
selectedDevices.value = []
await getTableData()
}
const handleEnterpriseDeviceAuthorize = async () => {
if (!enterpriseDeviceAuthorizeFormRef.value) return
await enterpriseDeviceAuthorizeFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseDeviceParams(enterpriseDeviceAuthorizeForm)
if (!params) return
enterpriseDeviceAuthorizeLoading.value = true
try {
const res = await EnterpriseService.allocateDevices(
enterpriseDeviceAuthorizeForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseDeviceAuthorizeDialogVisible.value = false
await applyEnterpriseDeviceOperationSuccess('授权设备给企业结果', res.data)
}
} catch (error) {
console.error('授权设备给企业失败:', error)
} finally {
enterpriseDeviceAuthorizeLoading.value = false
}
})
}
const handleEnterpriseDeviceRecall = async () => {
if (!enterpriseDeviceRecallFormRef.value) return
await enterpriseDeviceRecallFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseDeviceParams(enterpriseDeviceRecallForm)
if (!params) return
enterpriseDeviceRecallLoading.value = true
try {
const res = await EnterpriseService.recallDevices(
enterpriseDeviceRecallForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseDeviceRecallDialogVisible.value = false
await applyEnterpriseDeviceOperationSuccess('撤销设备授权结果', res.data)
}
} catch (error) {
console.error('撤销设备授权失败:', error)
} finally {
enterpriseDeviceRecallLoading.value = false
}
})
}
// 批量设置套餐系列
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
<ArtSearchBar
v-model:filter="formFilters"
:items="formItems"
label-width="120"
:label-width="90"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -42,6 +42,22 @@
>
批量设置套餐系列
</ElButton>
<ElButton
type="primary"
:disabled="selectedCards.length === 0"
@click="showEnterpriseCardAuthorizeDialog"
v-permission="'iot_card:authorize_enterprise'"
>
授权卡给企业
</ElButton>
<ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showEnterpriseCardRecallDialog"
v-permission="'iot_card:recall_enterprise_authorization'"
>
回收卡授权
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'iot_card:export'">
导出
</ElButton>
@@ -357,6 +373,350 @@
</template>
</ElDialog>
<!-- 企业卡授权对话框 -->
<ElDialog
v-model="enterpriseCardAuthorizeDialogVisible"
title="授权卡给企业"
width="640px"
@close="handleEnterpriseCardAuthorizeDialogClose"
>
<ElForm
ref="enterpriseCardAuthorizeFormRef"
:model="enterpriseCardAuthorizeForm"
:rules="enterpriseCardAuthorizeRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseCardAuthorizeForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardAuthorizeForm.selection_type">
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardAuthorizeForm.selection_type === CardSelectionType.LIST"
label="ICCID列表"
>
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardAuthorizeForm.selection_type === CardSelectionType.RANGE"
label="起始ICCID"
prop="iccid_start"
>
<ElInput
v-model="enterpriseCardAuthorizeForm.iccid_start"
placeholder="请输入起始ICCID"
/>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardAuthorizeForm.selection_type === CardSelectionType.RANGE"
label="结束ICCID"
prop="iccid_end"
>
<ElInput
v-model="enterpriseCardAuthorizeForm.iccid_end"
placeholder="请输入结束ICCID"
/>
</ElFormItem>
<div
v-if="
enterpriseCardAuthorizeForm.selection_type === CardSelectionType.RANGE &&
enterpriseCardAuthorizeRangeCountText
"
class="range-allocation-hint range-allocation-hint--card-form"
>
共授权{{ enterpriseCardAuthorizeRangeCountText }}张卡
</div>
<template
v-if="enterpriseCardAuthorizeForm.selection_type === CardSelectionType.FILTER"
>
<ElFormItem label="运营商">
<ElSelect
v-model="enterpriseCardAuthorizeForm.carrier_id"
placeholder="请选择或搜索运营商"
clearable
filterable
remote
:remote-method="handleSearchAllocateCarrier"
:loading="allocateCarrierLoading"
style="width: 100%"
>
<ElOption
v-for="item in allocateCarrierOptions"
:key="item.id"
:label="item.carrier_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="店铺">
<ElCascader
v-model="enterpriseCardAuthorizeForm.shop_id_path"
:options="shopCascadeOptions"
:props="shopCascadeProps"
placeholder="请选择店铺"
style="width: 100%"
clearable
filterable
/>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseCardAuthorizeForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchAllocateBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in allocateBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="ICCID">
<ElInput v-model="enterpriseCardAuthorizeForm.iccid" placeholder="请输入ICCID" />
</ElFormItem>
</template>
<ElFormItem label="备注">
<ElInput
v-model="enterpriseCardAuthorizeForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseCardAuthorizeDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseCardAuthorize"
:loading="enterpriseCardAuthorizeLoading"
>
确认授权
</ElButton>
</div>
</template>
</ElDialog>
<!-- 企业卡授权回收对话框 -->
<ElDialog
v-model="enterpriseCardRecallDialogVisible"
title="回收卡授权"
width="640px"
@close="handleEnterpriseCardRecallDialogClose"
>
<ElForm
ref="enterpriseCardRecallFormRef"
:model="enterpriseCardRecallForm"
:rules="enterpriseCardRecallRules"
label-width="120px"
>
<ElFormItem label="授权企业" prop="enterprise_id">
<ElSelect
v-model="enterpriseCardRecallForm.enterprise_id"
placeholder="请选择或搜索企业"
clearable
filterable
remote
reserve-keyword
:remote-method="handleSearchEnterprise"
:loading="enterpriseLoading"
style="width: 100%"
>
<ElOption
v-for="item in enterpriseOptions"
:key="item.id"
:label="item.enterprise_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="选卡方式" prop="selection_type">
<ElRadioGroup v-model="enterpriseCardRecallForm.selection_type">
<ElRadio label="list">ICCID列表</ElRadio>
<ElRadio label="range">号段范围</ElRadio>
<ElRadio label="filter">筛选条件</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardRecallForm.selection_type === CardSelectionType.LIST"
label="ICCID列表"
>
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardRecallForm.selection_type === CardSelectionType.RANGE"
label="起始ICCID"
prop="iccid_start"
>
<ElInput
v-model="enterpriseCardRecallForm.iccid_start"
placeholder="请输入起始ICCID"
/>
</ElFormItem>
<ElFormItem
v-if="enterpriseCardRecallForm.selection_type === CardSelectionType.RANGE"
label="结束ICCID"
prop="iccid_end"
>
<ElInput v-model="enterpriseCardRecallForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem>
<div
v-if="
enterpriseCardRecallForm.selection_type === CardSelectionType.RANGE &&
enterpriseCardRecallRangeCountText
"
class="range-allocation-hint range-allocation-hint--card-form"
>
共回收{{ enterpriseCardRecallRangeCountText }}张卡
</div>
<template v-if="enterpriseCardRecallForm.selection_type === CardSelectionType.FILTER">
<ElFormItem label="运营商">
<ElSelect
v-model="enterpriseCardRecallForm.carrier_id"
placeholder="请选择或搜索运营商"
clearable
filterable
remote
:remote-method="handleSearchAllocateCarrier"
:loading="allocateCarrierLoading"
style="width: 100%"
>
<ElOption
v-for="item in allocateCarrierOptions"
:key="item.id"
:label="item.carrier_name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="批次号">
<ElSelect
v-model="enterpriseCardRecallForm.batch_no"
placeholder="请选择或搜索批次号"
clearable
filterable
remote
:remote-method="handleSearchAllocateBatchNo"
style="width: 100%"
>
<ElOption
v-for="item in allocateBatchNoOptions"
:key="item.id"
:label="item.batch_no"
:value="item.batch_no"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="ICCID">
<ElInput v-model="enterpriseCardRecallForm.iccid" placeholder="请输入ICCID" />
</ElFormItem>
</template>
<ElFormItem label="备注">
<ElInput
v-model="enterpriseCardRecallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="enterpriseCardRecallDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleEnterpriseCardRecall"
:loading="enterpriseCardRecallLoading"
>
确认回收
</ElButton>
</div>
</template>
</ElDialog>
<!-- 企业卡授权操作结果 -->
<ElDialog
v-model="enterpriseCardResultDialogVisible"
:title="enterpriseCardResultTitle"
width="700px"
>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ enterpriseCardOperationResult.success_count }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<ElTag type="danger">{{ enterpriseCardOperationResult.fail_count }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<div
v-if="
enterpriseCardOperationResult.failed_items &&
enterpriseCardOperationResult.failed_items.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="enterpriseCardOperationResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="200" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<div
v-if="
enterpriseCardOperationResult.recalled_devices &&
enterpriseCardOperationResult.recalled_devices.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">连带回收设备</ElDivider>
<ElTable :data="enterpriseCardOperationResult.recalled_devices" border max-height="220">
<ElTableColumn prop="virtual_no" label="设备虚拟号" width="180" />
<ElTableColumn prop="card_count" label="关联卡数" width="100" />
<ElTableColumn label="ICCID列表">
<template #default="{ row }">
{{ row.iccids?.join(', ') || '-' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="enterpriseCardResultDialogVisible = false"
>确定</ElButton
>
</div>
</template>
</ElDialog>
<!-- 批量设置套餐系列绑定对话框 -->
<ElDialog
v-model="seriesBindingDialogVisible"
@@ -652,7 +1012,8 @@
ShopService,
PackageSeriesService,
AssetService,
CarrierService
CarrierService,
EnterpriseService
} from '@/api/modules'
import { ElMessage, ElTag, ElIcon, ElMessageBox } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
@@ -677,6 +1038,8 @@
} from '@/types/api/card'
import { CardSelectionType } from '@/types/api/card'
import type { PackageSeriesResponse } from '@/types/api'
import type { EnterpriseItem } from '@/types/api/enterprise'
import type { AllocateCardsResponse, RecallCardsResponse } from '@/types/api/enterpriseCard'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
defineOptions({ name: 'StandaloneCardList' })
@@ -704,6 +1067,27 @@
fail_count: 0,
failed_items: null
})
const enterpriseCardAuthorizeDialogVisible = ref(false)
const enterpriseCardRecallDialogVisible = ref(false)
const enterpriseCardResultDialogVisible = ref(false)
const enterpriseCardAuthorizeLoading = ref(false)
const enterpriseCardRecallLoading = ref(false)
const enterpriseCardResultTitle = ref('')
const enterpriseCardAuthorizeFormRef = ref<FormInstance>()
const enterpriseCardRecallFormRef = ref<FormInstance>()
const enterpriseOptions = ref<EnterpriseItem[]>([])
const enterpriseLoading = ref(false)
const enterpriseCardOperationResult = ref<
Partial<AllocateCardsResponse & RecallCardsResponse> & {
success_count: number
fail_count: number
}
>({
success_count: 0,
fail_count: 0,
failed_items: null,
recalled_devices: null
})
// 操作审计日志弹窗
const operationLogsDialogVisible = ref(false)
@@ -727,6 +1111,29 @@
batch_no: ''
})
const seriesBindingForm = reactive(createInitialSeriesBindingState())
const createInitialEnterpriseCardAuthorizeState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: CardSelectionType.LIST,
iccid_start: '',
iccid_end: '',
carrier_id: undefined as number | undefined,
shop_id_path: undefined as number[] | undefined,
batch_no: '',
iccid: '',
remark: ''
})
const createInitialEnterpriseCardRecallState = () => ({
enterprise_id: undefined as number | undefined,
selection_type: CardSelectionType.LIST,
iccid_start: '',
iccid_end: '',
carrier_id: undefined as number | undefined,
batch_no: '',
iccid: '',
remark: ''
})
const enterpriseCardAuthorizeForm = reactive(createInitialEnterpriseCardAuthorizeState())
const enterpriseCardRecallForm = reactive(createInitialEnterpriseCardRecallState())
const parseNumericStringToBigInt = (value: string): bigint | null => {
const normalizedValue = value.trim()
if (!normalizedValue || !/^\d+$/.test(normalizedValue)) {
@@ -761,9 +1168,54 @@
const recallRangeCountText = computed(() =>
getFormattedRangeCountText(recallForm.iccid_start, recallForm.iccid_end)
)
const enterpriseCardAuthorizeRangeCountText = computed(() =>
getFormattedRangeCountText(
enterpriseCardAuthorizeForm.iccid_start,
enterpriseCardAuthorizeForm.iccid_end
)
)
const enterpriseCardRecallRangeCountText = computed(() =>
getFormattedRangeCountText(
enterpriseCardRecallForm.iccid_start,
enterpriseCardRecallForm.iccid_end
)
)
const seriesBindingRangeCountText = computed(() =>
getFormattedRangeCountText(seriesBindingForm.iccid_start, seriesBindingForm.iccid_end)
)
const createEnterpriseCardRules = (form: { selection_type: CardSelectionType }) =>
reactive<FormRules>({
enterprise_id: [{ required: true, message: '请选择企业', trigger: 'change' }],
selection_type: [{ required: true, message: '请选择选卡方式', trigger: 'change' }],
iccid_start: [
{
required: true,
validator: (_rule, value, callback) => {
if (form.selection_type === CardSelectionType.RANGE && !value) {
callback(new Error('请输入起始ICCID'))
} else {
callback()
}
},
trigger: 'blur'
}
],
iccid_end: [
{
required: true,
validator: (_rule, value, callback) => {
if (form.selection_type === CardSelectionType.RANGE && !value) {
callback(new Error('请输入结束ICCID'))
} else {
callback()
}
},
trigger: 'blur'
}
]
})
const enterpriseCardAuthorizeRules = createEnterpriseCardRules(enterpriseCardAuthorizeForm)
const enterpriseCardRecallRules = createEnterpriseCardRules(enterpriseCardRecallForm)
const seriesBindingRules = reactive<FormRules>({
selection_type: [{ required: true, message: '请选择选卡方式', trigger: 'change' }],
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
@@ -921,6 +1373,26 @@
}
}
const handleSearchEnterprise = async (query?: string) => {
try {
enterpriseLoading.value = true
const params = {
page: 1,
page_size: 20,
enterprise_name: query || undefined,
status: 1
}
const res = await EnterpriseService.getEnterprises(params)
if (res.code === 0) {
enterpriseOptions.value = res.data.items || []
}
} catch (error) {
console.error('搜索企业失败:', error)
} finally {
enterpriseLoading.value = false
}
}
// 搜索套餐系列(用于搜索栏)
const handleSearchSeries = async (query: string) => {
try {
@@ -1043,10 +1515,25 @@
label: 'label',
children: 'children'
}
const loadTopLevelShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({ parent_id: undefined })
if (res.code === 0) {
shopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载顶级店铺失败:', error)
}
}
// 搜索表单初始值
const initialSearchState: {
status: undefined | number
network_status: undefined | number
iccid: string
carrier_name: string
msisdn: string
@@ -1057,9 +1544,12 @@
has_active_package: undefined | boolean
is_distributed: undefined | number
is_standalone: undefined | boolean
authorized_enterprise_id: undefined | number
is_authorized_to_enterprise: undefined | boolean
[key: string]: any
} = {
status: undefined,
network_status: undefined,
iccid: '',
carrier_name: '',
msisdn: '',
@@ -1070,6 +1560,8 @@
has_active_package: undefined,
is_distributed: undefined,
is_standalone: undefined,
authorized_enterprise_id: undefined,
is_authorized_to_enterprise: undefined,
is_replaced: undefined,
iccid_start: '',
iccid_end: ''
@@ -1184,7 +1676,7 @@
}
},
{
label: '运营商名称',
label: '运营商',
prop: 'carrier_name',
type: 'select',
config: {
@@ -1216,6 +1708,19 @@
{ label: '已停用', value: 4 }
]
},
{
label: '网络状态',
prop: 'network_status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '停机', value: 0 },
{ label: '正常', value: 1 }
]
},
{
label: '卡虚拟号',
prop: 'virtual_no',
@@ -1235,7 +1740,7 @@
}
},
{
label: '绑定设备虚拟号',
label: '设备虚拟号',
prop: 'device_virtual_no',
type: 'input',
config: {
@@ -1279,7 +1784,7 @@
}))
},
{
label: '是否已分销',
label: '分销状态',
prop: 'is_distributed',
type: 'select',
config: {
@@ -1292,7 +1797,7 @@
]
},
{
label: '是否绑设备',
label: '是否绑设备',
prop: 'is_standalone',
type: 'select',
config: {
@@ -1305,7 +1810,7 @@
]
},
{
label: '是否有换卡记录',
label: '有无换卡',
prop: 'is_replaced',
type: 'select',
config: {
@@ -1318,7 +1823,7 @@
]
},
{
label: 'ICCID起始号',
label: '起始号',
prop: 'iccid_start',
type: 'input',
config: {
@@ -1327,7 +1832,7 @@
}
},
{
label: 'ICCID结束号',
label: '结束号',
prop: 'iccid_end',
type: 'input',
config: {
@@ -1339,7 +1844,7 @@
// 搜索表单配置
baseFormItems.splice(8, 0, {
label: '是否有生效套餐',
label: '效套餐',
prop: 'has_active_package',
type: 'select',
config: {
@@ -1351,6 +1856,37 @@
{ label: '否', value: false }
]
})
baseFormItems.splice(9, 0, {
label: '授权企业',
prop: 'authorized_enterprise_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: handleSearchEnterprise,
loading: enterpriseLoading.value,
placeholder: '请选择或搜索企业'
},
options: () =>
enterpriseOptions.value.map((enterprise) => ({
label: enterprise.enterprise_name,
value: enterprise.id
}))
})
baseFormItems.splice(10, 0, {
label: '授权状态',
prop: 'is_authorized_to_enterprise',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '已授权', value: true },
{ label: '未授权', value: false }
]
})
const formItems: SearchFormItem[] = baseFormItems
@@ -1999,6 +2535,175 @@
})
}
const getInferredCardEnterpriseId = () => {
const filterEnterpriseId = formFilters.authorized_enterprise_id
if (filterEnterpriseId) return filterEnterpriseId
const selectedEnterpriseIds = Array.from(
new Set(
selectedCards.value
.map((card) => card.authorized_enterprise_id)
.filter((id): id is number => typeof id === 'number')
)
)
return selectedEnterpriseIds.length === 1 ? selectedEnterpriseIds[0] : undefined
}
const resetEnterpriseCardAuthorizeForm = () => {
Object.assign(enterpriseCardAuthorizeForm, createInitialEnterpriseCardAuthorizeState(), {
selection_type:
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER,
carrier_id: undefined,
shop_id_path: undefined,
batch_no: formFilters.batch_no || '',
iccid: formFilters.iccid || ''
})
}
const resetEnterpriseCardRecallForm = () => {
Object.assign(enterpriseCardRecallForm, createInitialEnterpriseCardRecallState(), {
enterprise_id: getInferredCardEnterpriseId(),
selection_type:
selectedCards.value.length > 0 ? CardSelectionType.LIST : CardSelectionType.FILTER,
carrier_id: undefined,
batch_no: formFilters.batch_no || '',
iccid: formFilters.iccid || ''
})
}
const showEnterpriseCardAuthorizeDialog = async () => {
resetEnterpriseCardAuthorizeForm()
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions(),
loadTopLevelShopCascadeOptions()
])
enterpriseCardAuthorizeDialogVisible.value = true
enterpriseCardAuthorizeFormRef.value?.clearValidate()
}
const showEnterpriseCardRecallDialog = async () => {
resetEnterpriseCardRecallForm()
await Promise.all([
handleSearchEnterprise(),
loadAllocateCarrierOptions(),
loadAllocateBatchNoOptions()
])
enterpriseCardRecallDialogVisible.value = true
enterpriseCardRecallFormRef.value?.clearValidate()
}
const handleEnterpriseCardAuthorizeDialogClose = () => {
Object.assign(enterpriseCardAuthorizeForm, createInitialEnterpriseCardAuthorizeState())
enterpriseCardAuthorizeFormRef.value?.resetFields()
}
const handleEnterpriseCardRecallDialogClose = () => {
Object.assign(enterpriseCardRecallForm, createInitialEnterpriseCardRecallState())
enterpriseCardRecallFormRef.value?.resetFields()
}
const appendEnterpriseCardFilterParams = (params: Record<string, unknown>, form: any) => {
if (form.carrier_id) params.carrier_id = form.carrier_id
if (Array.isArray(form.shop_id_path) && form.shop_id_path.length > 0) {
params.shop_id = form.shop_id_path[form.shop_id_path.length - 1]
}
if (form.batch_no) params.batch_no = form.batch_no
if (form.iccid) params.iccid = form.iccid
}
const buildEnterpriseCardParams = (form: any) => {
const params: Record<string, unknown> = {
selection_type: form.selection_type,
remark: form.remark || undefined
}
if (form.selection_type === CardSelectionType.LIST) {
const iccids = selectedCards.value.map((card) => card.iccid)
if (!iccids.length) {
ElMessage.warning('列表模式下请先选择卡')
return null
}
params.iccids = iccids
} else if (form.selection_type === CardSelectionType.RANGE) {
params.iccid_start = form.iccid_start
params.iccid_end = form.iccid_end
} else {
appendEnterpriseCardFilterParams(params, form)
}
Object.keys(params).forEach((key) => {
if (params[key] === undefined || params[key] === '') delete params[key]
})
return params
}
const applyEnterpriseCardOperationSuccess = async (
title: string,
result: AllocateCardsResponse | RecallCardsResponse
) => {
enterpriseCardOperationResult.value = result
enterpriseCardResultTitle.value = title
enterpriseCardResultDialogVisible.value = true
selectedCards.value = []
await getTableData()
}
const handleEnterpriseCardAuthorize = async () => {
if (!enterpriseCardAuthorizeFormRef.value) return
await enterpriseCardAuthorizeFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseCardParams(enterpriseCardAuthorizeForm)
if (!params) return
enterpriseCardAuthorizeLoading.value = true
try {
const res = await EnterpriseService.allocateCards(
enterpriseCardAuthorizeForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseCardAuthorizeDialogVisible.value = false
await applyEnterpriseCardOperationSuccess('授权卡给企业结果', res.data)
}
} catch (error) {
console.error('授权卡给企业失败:', error)
} finally {
enterpriseCardAuthorizeLoading.value = false
}
})
}
const handleEnterpriseCardRecall = async () => {
if (!enterpriseCardRecallFormRef.value) return
await enterpriseCardRecallFormRef.value.validate(async (valid) => {
if (!valid) return
const params = buildEnterpriseCardParams(enterpriseCardRecallForm)
if (!params) return
enterpriseCardRecallLoading.value = true
try {
const res = await EnterpriseService.recallCards(
enterpriseCardRecallForm.enterprise_id!,
params as any
)
if (res.code === 0) {
enterpriseCardRecallDialogVisible.value = false
await applyEnterpriseCardOperationSuccess('回收卡授权结果', res.data)
}
} catch (error) {
console.error('回收卡授权失败:', error)
} finally {
enterpriseCardRecallLoading.value = false
}
})
}
// 显示套餐系列绑定对话框
const showSeriesBindingDialog = async () => {
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
@@ -2118,47 +2823,6 @@
})
}
// 清除卡套餐系列绑定
const handleClearCardSeries = async () => {
if (selectedCards.value.length === 0) {
ElMessage.warning('请先选择要清除关联的卡')
return
}
ElMessageBox.confirm(
`确定要清除所选 ${selectedCards.value.length} 张卡的套餐系列关联吗?`,
'确认清除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
try {
const res = await CardService.batchSetCardSeriesBinding({
selection_type: CardSelectionType.LIST,
iccids: selectedCards.value.map((card) => card.iccid),
series_id: 0
})
if (res.code === 0) {
if (res.data.fail_count === 0) {
ElMessage.success('清除关联成功')
} else {
ElMessage.warning(`部分失败:${res.data.failed_items?.[0]?.reason || '未知原因'}`)
}
selectedCards.value = []
await getTableData()
}
} catch (error) {
console.error('清除关联失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
// IoT卡操作处理函数
const handleCardOperation = (command: string, iccid: string, row?: any) => {
switch (command) {
@@ -2258,33 +2922,6 @@
// 用户取消
})
}
// 手动停用 IoT 卡(资产层面的停用)
const handleManualDeactivate = (iccid: string) => {
ElMessageBox.confirm(
`确定要手动停用卡片 ${iccid} 吗?此操作将在资产管理层面停用该卡。`,
'确认手动停用',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
.then(async () => {
try {
const res = await AssetService.deactivateAsset(iccid)
if (res.code === 0) {
ElMessage.success('手动停用成功')
await getTableData()
}
} catch (error: any) {
console.error('手动停用失败:', error)
}
})
.catch(() => {
// 用户取消
})
}
</script>
<style lang="scss" scoped>

View File

@@ -63,8 +63,8 @@
:currentPage="commissionPagination.page"
:pageSize="commissionPagination.pageSize"
:total="commissionPagination.total"
:marginTop="10"
:height="500"
:marginTop="0"
:height="450"
@size-change="handleCommissionSizeChange"
@current-change="handleCommissionCurrentChange"
>
@@ -231,6 +231,45 @@
<!-- 预充值钱包流水 Tab -->
<ElTabPane label="预充值钱包流水" name="mainWallet">
<ElForm :model="mainWalletSearchForm" class="main-wallet-filter" label-position="top">
<div class="main-wallet-filter__grid">
<ElFormItem label="交易类型">
<ElSelect
v-model="mainWalletSearchForm.transaction_type"
clearable
placeholder="全部类型"
>
<ElOption label="充值入账" :value="MainWalletTransactionType.RECHARGE" />
<ElOption label="套餐扣款" :value="MainWalletTransactionType.DEDUCT" />
<ElOption label="退款" :value="MainWalletTransactionType.REFUND" />
</ElSelect>
</ElFormItem>
<ElFormItem label="交易日期">
<ElDatePicker
v-model="mainWalletSearchForm.date_range"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
unlink-panels
/>
</ElFormItem>
<ElFormItem label="资产标识">
<ElInput
v-model="mainWalletSearchForm.asset_identifier"
clearable
maxlength="100"
placeholder="请输入ICCID或设备虚拟号"
@keyup.enter="handleMainWalletSearch"
/>
</ElFormItem>
<ElFormItem class="main-wallet-filter__actions">
<ElButton type="primary" @click="handleMainWalletSearch">搜索</ElButton>
<ElButton @click="handleMainWalletReset">重置</ElButton>
</ElFormItem>
</div>
</ElForm>
<ArtTable
ref="mainWalletTableRef"
row-key="id"
@@ -384,12 +423,14 @@
import { CommissionService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { MainWalletTransactionType } from '@/types/api/commission'
import type {
ShopFundSummaryItem,
ShopCommissionRecordItem,
WithdrawalRequestItem,
CommissionResolveAction,
MainWalletTransactionItem
MainWalletTransactionItem,
MainWalletTransactionQueryParams
} from '@/types/api/commission'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -491,6 +532,15 @@
pageSize: 20,
total: 0
})
const mainWalletSearchForm = reactive<{
transaction_type: MainWalletTransactionType | undefined
date_range: [string, string] | [] | null
asset_identifier: string
}>({
transaction_type: undefined,
date_range: [],
asset_identifier: ''
})
// 列配置
const columnOptions = [
@@ -713,6 +763,9 @@
commissionPagination.page = 1
withdrawalPagination.page = 1
mainWalletPagination.page = 1
mainWalletPagination.total = 0
mainWalletRecords.value = []
resetMainWalletSearchForm()
// 加载佣金明细
loadCommissionRecords()
@@ -841,9 +894,14 @@
mainWalletLoading.value = true
try {
const params = {
const [startDate, endDate] = mainWalletSearchForm.date_range || []
const params: MainWalletTransactionQueryParams = {
page: mainWalletPagination.page,
page_size: mainWalletPagination.pageSize
page_size: mainWalletPagination.pageSize,
transaction_type: mainWalletSearchForm.transaction_type,
start_date: startDate,
end_date: endDate,
asset_identifier: mainWalletSearchForm.asset_identifier.trim() || undefined
}
const res = await CommissionService.getMainWalletTransactions(
currentShop.value.shop_id,
@@ -872,6 +930,23 @@
loadMainWalletRecords()
}
const resetMainWalletSearchForm = () => {
mainWalletSearchForm.transaction_type = undefined
mainWalletSearchForm.date_range = []
mainWalletSearchForm.asset_identifier = ''
}
const handleMainWalletSearch = () => {
mainWalletPagination.page = 1
loadMainWalletRecords()
}
const handleMainWalletReset = () => {
resetMainWalletSearchForm()
mainWalletPagination.page = 1
loadMainWalletRecords()
}
// 处理佣金修正
const handleResolveCommission = (
row: ShopCommissionRecordItem,
@@ -955,6 +1030,58 @@
}
}
.main-wallet-filter {
padding: 16px 16px 4px;
margin-bottom: 12px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-select),
:deep(.el-date-editor),
:deep(.el-input) {
width: 100%;
}
}
.main-wallet-filter__grid {
display: grid;
grid-template-columns: minmax(140px, 0.8fr) minmax(260px, 1.4fr) minmax(220px, 1fr) auto;
gap: 12px 16px;
align-items: end;
}
.main-wallet-filter__actions {
:deep(.el-form-item__content) {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: nowrap;
}
}
@media (max-width: 1200px) {
.main-wallet-filter__grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.main-wallet-filter__actions {
:deep(.el-form-item__content) {
justify-content: flex-start;
}
}
}
@media (max-width: 768px) {
.main-wallet-filter__grid {
grid-template-columns: 1fr;
}
}
.asset-identifier-link {
color: var(--el-color-primary);
text-decoration: underline;

View File

@@ -21,6 +21,8 @@
<span>加载中...</span>
</div>
</ElCard>
<PaymentVoucherDialog :file-key="paymentVoucherFileKey" @close="paymentVoucherFileKey = ''" />
</div>
</template>
@@ -34,7 +36,7 @@
import { AgentRechargeService } from '@/api/modules'
import type { AgentRecharge, AgentRechargeStatus, AgentRechargePaymentMethod } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
defineOptions({ name: 'AgentRechargeDetail' })
@@ -43,6 +45,7 @@
const loading = ref(false)
const detailData = ref<AgentRecharge | null>(null)
const paymentVoucherFileKey = ref('')
const pageTitle = computed(() => `充值订单详情`)
@@ -124,6 +127,24 @@
prop: 'payment_transaction_id',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '支付凭证',
fullWidth: true,
render: (data) =>
data.payment_voucher_key
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
paymentVoucherFileKey.value = data.payment_voucher_key
}
},
() => '查看支付凭证'
)
: h('span', '-')
}
]
},

View File

@@ -96,32 +96,25 @@
label="支付凭证"
prop="payment_voucher_key"
>
<ElUpload
<VoucherUpload
ref="uploadRef"
class="payment-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
:limit="1"
>
<el-icon class="payment-voucher-upload__icon"><UploadFilled /></el-icon>
<div class="payment-voucher-upload__text">
将支付凭证拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">支持 jpgpng 格式文件大小不超过 5MB</div>
</template>
</ElUpload>
v-model="createForm.payment_voucher_key"
voucher-name="支付凭证"
@uploading-change="voucherUploading = $event"
@change="createFormRef?.validateField('payment_voucher_key')"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="createDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleCreateRecharge" :loading="createLoading">
确认创建
<ElButton
type="primary"
@click="handleCreateRecharge"
:loading="createLoading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '凭证上传中...' : '确认创建' }}
</ElButton>
</div>
</template>
@@ -178,19 +171,16 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { AgentRechargeService, ShopService, StorageService } from '@/api/modules'
import { AgentRechargeService, ShopService } from '@/api/modules'
import {
ElMessage,
ElTag,
ElTreeSelect,
ElButton,
ElCascader,
ElInputNumber,
ElSelect,
ElOption,
ElUpload
ElOption
} from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
AgentRecharge,
@@ -198,25 +188,23 @@
AgentRechargeStatus,
AgentRechargePaymentMethod,
CreateAgentRechargeRequest,
ConfirmOfflinePaymentRequest,
ShopResponse
ConfirmOfflinePaymentRequest
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import { useAuth } from '@/composables/useAuth'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
defineOptions({ name: 'AgentRechargeList' })
const router = useRouter()
const userStore = useUserStore()
const { hasAuth } = useAuth()
const loading = ref(false)
const createLoading = ref(false)
const voucherUploading = ref(false)
const confirmPayLoading = ref(false)
const tableRef = ref()
const createDialogVisible = ref(false)
@@ -238,8 +226,6 @@
// 店铺选项
const shopOptions = ref<any[]>([])
const shopTreeData = ref<ShopResponse[]>([])
// 店铺级联选择相关
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
@@ -340,7 +326,7 @@
const createFormRef = ref<FormInstance>()
const confirmPayFormRef = ref<FormInstance>()
const uploadRef = ref()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const MIN_RECHARGE_AMOUNT = 0.01
const createRules = computed<FormRules>(() => {
@@ -523,33 +509,6 @@
isFirstActivation = false
})
// 构建树形数据
const buildTreeData = (items: ShopResponse[]) => {
const map = new Map<number, ShopResponse & { children?: ShopResponse[] }>()
const tree: ShopResponse[] = []
// 先将所有项放入 map
items.forEach((item) => {
map.set(item.id, { ...item, children: [] })
})
// 构建树形结构
items.forEach((item) => {
const node = map.get(item.id)!
if (item.parent_id && map.has(item.parent_id)) {
// 有父节点,添加到父节点的 children 中
const parent = map.get(item.parent_id)!
if (!parent.children) parent.children = []
parent.children.push(node)
} else {
// 没有父节点或父节点不存在,作为根节点
tree.push(node)
}
})
return tree
}
// 加载店铺列表 - 改用级联查询
const loadShops = async () => {
try {
@@ -671,60 +630,18 @@
createForm.payment_method = ''
createForm.shop_id = null
createForm.payment_voucher_key = undefined
uploadRef.value?.clearFiles()
}
// 支付凭证文件变化
const handleVoucherFileChange = async (file: any) => {
const isImage = file.raw.type.startsWith('image/')
const isLt5M = file.raw.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件')
uploadRef.value?.clearFiles()
return
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
uploadRef.value?.clearFiles()
return
}
try {
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.raw.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
uploadRef.value?.clearFiles()
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file.raw, file.raw.type)
createForm.payment_voucher_key = file_key
ElMessage.success('上传成功')
} catch (error: any) {
console.error('上传失败:', error)
createForm.payment_voucher_key = undefined
uploadRef.value?.clearFiles()
}
}
// 删除支付凭证
const handleRemoveVoucher = () => {
createForm.payment_voucher_key = undefined
voucherUploading.value = false
uploadRef.value?.clearFiles(false)
}
// 创建充值订单
const handleCreateRecharge = async () => {
const formRef = createFormRef.value
if (!formRef) return
if (voucherUploading.value) {
ElMessage.warning('支付凭证上传中,请稍候')
return
}
await formRef.validate(async (valid) => {
if (valid) {
@@ -854,34 +771,4 @@
.agent-recharge-page {
height: 100%;
}
.payment-voucher-upload {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -22,13 +22,7 @@
</div>
</ElCard>
<ElImageViewer
v-if="voucherPreviewVisible"
:url-list="refundVoucherUrls"
:initial-index="voucherPreviewIndex"
hide-on-click-modal
@close="voucherPreviewVisible = false"
/>
<PaymentVoucherDialog :file-key="refundVoucherFileKey" @close="refundVoucherFileKey = ''" />
<!-- 审批通过对话框 -->
<ElDialog
@@ -172,11 +166,11 @@
<script setup lang="ts">
import { ref, onMounted, computed, h, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElImageViewer, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ElButton, ElCard, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { RefundService, StorageService } from '@/api/modules'
import { RefundService } from '@/api/modules'
import type {
Refund,
RefundStatus,
@@ -185,6 +179,7 @@
ResubmitRefundRequest
} from '@/types/api'
import { formatDateTime, yuanToFen } from '@/utils/business/format'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
defineOptions({ name: 'RefundDetail' })
@@ -193,9 +188,7 @@
const loading = ref(false)
const refund = ref<Refund | null>(null)
const refundVoucherUrls = ref<string[]>([])
const voucherPreviewVisible = ref(false)
const voucherPreviewIndex = ref(0)
const refundVoucherFileKey = ref('')
const pageTitle = computed(() => `退款详情`)
@@ -262,14 +255,6 @@
return statusMap[status] || '-'
}
const getVoucherKeys = (data: Refund): string[] => {
const voucherKey = data.refund_voucher_key || ''
return voucherKey
.split(',')
.map((key) => key.trim())
.filter(Boolean)
}
const detailSections = computed((): DetailSection[] => [
{
title: '退款信息',
@@ -309,18 +294,18 @@
{
label: '退款凭证',
fullWidth: true,
render: () =>
refundVoucherUrls.value.length
render: (data) =>
data.refund_voucher_key
? h(
'div',
{ class: 'refund-voucher-list' },
refundVoucherUrls.value.map((url, index) =>
h('img', {
class: 'refund-voucher-image',
src: url,
onClick: () => handlePreviewRefundVoucher(index)
})
)
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
refundVoucherFileKey.value = data.refund_voucher_key
}
},
() => '查看退款凭证'
)
: h('span', '-')
},
@@ -391,7 +376,6 @@
const res = await RefundService.getRefundById(id)
if (res.code === 0) {
refund.value = res.data
await loadRefundVoucherUrls(res.data)
}
} catch (error) {
console.error(error)
@@ -405,26 +389,6 @@
router.back()
}
const loadRefundVoucherUrls = async (data: Refund) => {
const fileKeys = getVoucherKeys(data)
refundVoucherUrls.value = []
if (!fileKeys.length) return
try {
const res = await StorageService.batchDownloadUrls({ file_keys: fileKeys })
if (res.code === 0) {
refundVoucherUrls.value = fileKeys.map((key) => res.data.urls[key]).filter(Boolean)
}
} catch (error) {
console.error('获取退款凭证失败:', error)
}
}
const handlePreviewRefundVoucher = (index: number) => {
voucherPreviewIndex.value = index
voucherPreviewVisible.value = true
}
onMounted(() => {
const id = route.params.id as string
if (id) {
@@ -556,21 +520,5 @@
font-size: 32px;
}
}
:deep(.refund-voucher-list) {
display: flex;
flex-wrap: wrap;
gap: 12px;
line-height: 1;
}
:deep(.refund-voucher-image) {
width: 96px;
height: 96px;
cursor: zoom-in;
object-fit: cover;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
}
</style>

View File

@@ -5,7 +5,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
show-expand
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -279,7 +279,8 @@
const initialSearchState: RefundQueryParams = {
status: undefined,
order_id: undefined,
shop_id: undefined
shop_id: undefined,
asset_identifier: ''
}
// 搜索表单
@@ -325,6 +326,15 @@
remoteMethod: (query: string) => searchOrdersForFilter(query)
}
},
{
label: '资产标识',
prop: 'asset_identifier',
type: 'input',
placeholder: '请输入ICCID或设备虚拟号',
config: {
clearable: true
}
},
{
label: '状态',
prop: 'status',
@@ -622,12 +632,14 @@
const getTableData = async () => {
loading.value = true
try {
const assetIdentifier = searchForm.asset_identifier?.trim()
const params: RefundQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
status: searchForm.status,
order_id: searchForm.order_id,
shop_id: searchForm.shop_id
shop_id: searchForm.shop_id,
asset_identifier: assetIdentifier || undefined
}
const res = await RefundService.getRefunds(params)
if (res.code === 0) {

View File

@@ -18,9 +18,7 @@
<!-- 订单列表 -->
<div v-if="detailData && detailData.items && detailData.items.length > 0">
<ElCard style="margin-top: 20px" shadow="never">
<div
class="order-items-section"
>
<div class="order-items-section">
<h3 class="section-title">订单列表</h3>
<ElTable :data="detailData.items" border>
<ElTableColumn prop="package_name" label="套餐名称" />
@@ -57,35 +55,29 @@
<span>加载中...</span>
</div>
</ElCard>
<PaymentVoucherDialog :file-key="paymentVoucherFileKey" @close="paymentVoucherFileKey = ''" />
</div>
</template>
<script setup lang="ts">
import { h, onMounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElButton,
ElCard,
ElIcon,
ElImage,
ElMessage,
ElTable,
ElTableColumn
} from 'element-plus'
import { ElButton, ElCard, ElIcon, ElMessage, ElTable, ElTableColumn } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { OrderService, StorageService } from '@/api/modules'
import { OrderService } from '@/api/modules'
import type {
Order,
OrderCommissionResult,
OrderCommissionStatus,
OrderOperatorType,
OrderPaymentMethod
OrderOperatorType
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
defineOptions({ name: 'OrderDetail' })
@@ -96,7 +88,7 @@
const loading = ref(false)
const detailData = ref<Order | null>(null)
const paymentVoucherUrl = ref('')
const paymentVoucherFileKey = ref('')
// 格式化货币 - 将分转换为元
const formatCurrency = (amount: number): string => {
@@ -113,17 +105,6 @@
return type === 'personal' ? '个人客户' : '代理商'
}
// 获取支付方式文本
const getPaymentMethodText = (method: OrderPaymentMethod): string => {
const methodMap: Record<OrderPaymentMethod, string> = {
wallet: '钱包支付',
wechat: '微信支付',
alipay: '支付宝支付',
offline: '线下支付'
}
return methodMap[method] || method
}
// 获取操作者类型文本
const getOperatorTypeText = (type?: OrderOperatorType): string => {
if (!type) return '-'
@@ -174,17 +155,6 @@
return roleMap[role] || role
}
const loadPaymentVoucherUrl = async (fileKey?: string) => {
paymentVoucherUrl.value = ''
if (!fileKey || !hasAuth('orders:view_payment_voucher')) return
try {
paymentVoucherUrl.value = await StorageService.getDownloadUrl(fileKey)
} catch (error) {
console.error('Get payment voucher failed:', error)
}
}
// 检查当前用户是否可以查看实付金额(代理账号和企业账号不能看到)
const canViewActualPaidAmount = computed(() => {
const userType = userStore.info.user_type
@@ -229,21 +199,17 @@
return h('span', '-')
}
if (!paymentVoucherUrl.value) {
return h('span', '加载中...')
}
return h(ElImage, {
src: paymentVoucherUrl.value,
previewSrcList: [paymentVoucherUrl.value],
fit: 'cover',
style: {
width: '120px',
height: '120px',
borderRadius: '8px',
border: '1px solid var(--el-border-color-light)'
}
})
return h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
paymentVoucherFileKey.value = data.payment_voucher_key
}
},
() => '查看支付凭证'
)
}
},
{
@@ -377,7 +343,6 @@
const res = await OrderService.getOrderById(id)
if (res.code === 0) {
detailData.value = res.data
await loadPaymentVoucherUrl(res.data.payment_voucher_key)
}
} catch (error) {
console.error(error)

View File

@@ -199,25 +199,13 @@
label="支付凭证"
prop="payment_voucher_key"
>
<ElUpload
<VoucherUpload
ref="uploadRef"
class="payment-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
:limit="1"
>
<el-icon class="payment-voucher-upload__icon"><UploadFilled /></el-icon>
<div class="payment-voucher-upload__text">
将支付凭证拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">支持所有图片格式</div>
</template>
</ElUpload>
v-model="createForm.payment_voucher_key"
voucher-name="支付凭证"
@uploading-change="voucherUploading = $event"
@change="createFormRef?.validateField('payment_voucher_key')"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -229,7 +217,7 @@
:loading="createLoading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '图片上传中...' : '提交' }}
{{ voucherUploading ? '凭证上传中...' : '提交' }}
</ElButton>
</div>
</template>
@@ -255,19 +243,17 @@
</template>
<script setup lang="ts">
import { computed, h, nextTick } from 'vue'
import { computed, h } from 'vue'
import { useRouter } from 'vue-router'
import {
OrderService,
CardService,
DeviceService,
PackageManageService,
StorageService,
ShopService
} from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadInstance, UploadFile } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Order,
OrderQueryParams,
@@ -290,6 +276,7 @@
import { RoutesAlias } from '@/router/routesAlias'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
defineOptions({ name: 'OrderList' })
@@ -308,7 +295,6 @@
const createDialogVisible = ref(false)
const exportDialogVisible = ref(false)
const paymentVoucherFileKey = ref('')
let activeVoucherUploadId = 0
// 搜索表单初始值
const initialSearchState: OrderSearchFormState = {
@@ -536,82 +522,7 @@
})
const createFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const invalidateVoucherUploadState = (clearFileKey = false) => {
activeVoucherUploadId += 1
voucherUploading.value = false
if (clearFileKey) {
createForm.payment_voucher_key = undefined
}
}
// 处理支付凭证文件选择
const handleVoucherFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
// 验证文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
uploadRef.value?.clearFiles()
return
}
const requestId = ++activeVoucherUploadId
voucherUploading.value = true
createForm.payment_voucher_key = undefined
// 开始上传
try {
ElMessage.info('正在上传支付凭证...')
// 1. 获取上传 URL
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
uploadRef.value?.clearFiles()
return
}
const { upload_url, file_key } = uploadUrlRes.data
// 2. 上传文件到对象存储
await StorageService.uploadFile(upload_url, file, file.type)
if (requestId !== activeVoucherUploadId) return
// 3. 保存 file_key
createForm.payment_voucher_key = file_key
ElMessage.success('上传成功')
// 触发表单验证
await nextTick(() => {
createFormRef.value?.validateField('payment_voucher_key')
})
} catch (error: any) {
if (requestId !== activeVoucherUploadId) return
console.error('上传失败:', error)
ElMessage.error(error.message || '上传失败,请重试')
createForm.payment_voucher_key = undefined
uploadRef.value?.clearFiles()
} finally {
if (requestId === activeVoucherUploadId) {
voucherUploading.value = false
}
}
}
// 删除支付凭证
const handleRemoveVoucher = () => {
invalidateVoucherUploadState(true)
}
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createRules = computed<FormRules>(() => {
const baseRules: FormRules = {
@@ -1231,10 +1142,11 @@
createForm.iot_card_id = null
createForm.device_id = null
createForm.payment_method = 'wallet'
invalidateVoucherUploadState(true)
createForm.payment_voucher_key = undefined
voucherUploading.value = false
// 清空上传文件列表
uploadRef.value?.clearFiles()
uploadRef.value?.clearFiles(false)
// 清空套餐、IoT卡和设备搜索结果
packageOptions.value = []
@@ -1418,36 +1330,6 @@
height: 100%;
}
.payment-voucher-upload {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}