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

@@ -27,10 +27,10 @@ import type {
import type {
EnterpriseDeviceListParams,
EnterpriseDevicePageResult,
AllocateDevicesRequest,
AllocateDevicesResponse,
RecallDevicesRequest,
RecallDevicesResponse
EnterpriseAllocateDevicesRequest,
EnterpriseAllocateDevicesResponse,
EnterpriseRecallDevicesRequest,
EnterpriseRecallDevicesResponse
} from '@/types/api/enterpriseDevice'
export class EnterpriseService extends BaseService {
@@ -171,9 +171,9 @@ export class EnterpriseService extends BaseService {
*/
static allocateDevices(
enterpriseId: number,
data: AllocateDevicesRequest
): Promise<BaseResponse<AllocateDevicesResponse>> {
return this.post<BaseResponse<AllocateDevicesResponse>>(
data: EnterpriseAllocateDevicesRequest
): Promise<BaseResponse<EnterpriseAllocateDevicesResponse>> {
return this.post<BaseResponse<EnterpriseAllocateDevicesResponse>>(
`/api/admin/enterprises/${enterpriseId}/allocate-devices`,
data
)
@@ -201,9 +201,9 @@ export class EnterpriseService extends BaseService {
*/
static recallDevices(
enterpriseId: number,
data: RecallDevicesRequest
): Promise<BaseResponse<RecallDevicesResponse>> {
return this.post<BaseResponse<RecallDevicesResponse>>(
data: EnterpriseRecallDevicesRequest
): Promise<BaseResponse<EnterpriseRecallDevicesResponse>> {
return this.post<BaseResponse<EnterpriseRecallDevicesResponse>>(
`/api/admin/enterprises/${enterpriseId}/recall-devices`,
data
)

View File

@@ -47,23 +47,13 @@
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<ElUpload
<VoucherUpload
ref="uploadRef"
class="refund-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
multiple
>
<ElIcon class="refund-voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="refund-voucher-upload__text">将退款凭证拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持所有图片格式可上传多张</div>
</template>
</ElUpload>
v-model="formData.refund_voucher_key"
voucher-name="退款凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('refund_voucher_key')"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -75,7 +65,7 @@
:loading="submitLoading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '图片上传中...' : '确认创建' }}
{{ voucherUploading ? '凭证上传中...' : '确认创建' }}
</ElButton>
</div>
</template>
@@ -83,13 +73,13 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { RefundService, OrderService, StorageService } from '@/api/modules'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules'
import type { CreateRefundRequest, Order } from '@/types/api'
import { formatMoney, yuanToFen } from '@/utils/business/format'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
interface Props {
modelValue: boolean
@@ -109,13 +99,11 @@
})
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const submitLoading = ref(false)
const voucherUploading = ref(false)
const voucherUploadingCount = ref(0)
const orderSearchLoading = ref(false)
const orderSearchOptions = ref<Order[]>([])
let activeVoucherUploadBatch = 0
const formData = reactive<{
order_id: number | null
@@ -207,96 +195,14 @@
const handleDialogClosed = () => {
formRef.value?.resetFields()
activeVoucherUploadBatch += 1
voucherUploadingCount.value = 0
voucherUploading.value = false
formData.order_id = null
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
formData.refund_voucher_key = undefined
voucherFileKeyMap.clear()
removedVoucherUploadUids.clear()
orderSearchOptions.value = []
uploadRef.value?.clearFiles()
}
const voucherFileKeyMap = new Map<number, string>()
const removedVoucherUploadUids = new Set<number>()
const refreshPaymentVoucherKey = () => {
const keys = Array.from(voucherFileKeyMap.values())
formData.refund_voucher_key = keys.length ? keys.join(',') : undefined
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleVoucherFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!file.type.startsWith('image/')) {
ElMessage.error('只能上传图片文件!')
removeUploadFile(uploadFile)
return
}
const uploadBatch = activeVoucherUploadBatch
removedVoucherUploadUids.delete(uploadFile.uid)
voucherUploadingCount.value += 1
voucherUploading.value = voucherUploadingCount.value > 0
try {
ElMessage.info('正在上传退款凭证...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, file.type)
if (
uploadBatch !== activeVoucherUploadBatch ||
removedVoucherUploadUids.has(uploadFile.uid)
) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
refreshPaymentVoucherKey()
ElMessage.success('上传成功')
await nextTick(() => {
formRef.value?.validateField('refund_voucher_key')
})
} catch (error: any) {
if (uploadBatch !== activeVoucherUploadBatch) return
console.error('上传退款凭证失败:', error)
ElMessage.error(error.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeVoucherUploadBatch) {
voucherUploadingCount.value = Math.max(0, voucherUploadingCount.value - 1)
voucherUploading.value = voucherUploadingCount.value > 0
}
}
}
const handleRemoveVoucher = (uploadFile: UploadFile) => {
removedVoucherUploadUids.add(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
uploadRef.value?.clearFiles(false)
}
const handleSubmit = async () => {
@@ -343,35 +249,3 @@
}
})
</script>
<style scoped lang="scss">
.refund-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

@@ -1,22 +1,47 @@
<template>
<ElImageViewer
v-if="visible"
:url-list="urls"
:initial-index="0"
hide-on-click-modal
@close="handleClose"
/>
<ElDialog v-model="visible" title="凭证查看" width="560px" @closed="handleClose">
<div v-if="loading" class="voucher-preview__loading">加载中...</div>
<div v-else-if="files.length" class="voucher-preview__list">
<div v-for="file in files" :key="file.key" class="voucher-preview__item">
<ElImage
v-if="file.isImage"
class="voucher-preview__image"
:src="file.url"
:preview-src-list="imageUrls"
:initial-index="getImageIndex(file.url)"
fit="cover"
preview-teleported
/>
<div v-else class="voucher-preview__file">
<ElIcon><Document /></ElIcon>
</div>
<div class="voucher-preview__meta">
<div class="voucher-preview__name" :title="file.name">{{ file.name }}</div>
<ElButton link type="primary" @click="openFile(file)">查看/下载</ElButton>
</div>
</div>
</div>
<ElEmpty v-else description="暂无凭证" />
</ElDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElImageViewer } from 'element-plus'
import { computed, ref, watch } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { ElButton, ElDialog, ElEmpty, ElIcon, ElImage } from 'element-plus'
import { StorageService } from '@/api/modules'
interface Props {
fileKey?: string
}
interface VoucherFile {
key: string
name: string
url: string
isImage: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
@@ -24,32 +49,74 @@
}>()
const visible = ref(false)
const urls = ref<string[]>([])
const loading = ref(false)
const files = ref<VoucherFile[]>([])
const imageUrls = computed(() =>
files.value.filter((file) => file.isImage).map((file) => file.url)
)
const getVoucherKeys = (value: string) =>
value
.split(',')
.map((key) => key.trim())
.filter(Boolean)
const getFileName = (fileKey: string) => fileKey.split('/').pop() || fileKey
const isImageFile = (fileKey: string, url: string) => {
const target = `${fileKey} ${url}`.toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/.test(target)
}
const getImageIndex = (url: string) => Math.max(0, imageUrls.value.indexOf(url))
const openFile = (file: VoucherFile) => {
window.open(file.url, '_blank', 'noopener,noreferrer')
}
watch(
() => props.fileKey,
async (val) => {
if (val) {
urls.value = []
if (!val) {
visible.value = false
const fileKeys = val
.split(',')
.map((key) => key.trim())
.filter(Boolean)
try {
const res = await StorageService.batchDownloadUrls({
file_keys: fileKeys
})
if (res.code === 0) {
urls.value = fileKeys.map((key) => res.data.urls[key]).filter(Boolean)
visible.value = urls.value.length > 0
}
} catch (error) {
console.error('获取支付凭证失败:', error)
files.value = []
return
}
const fileKeys = getVoucherKeys(val)
if (!fileKeys.length) {
visible.value = false
files.value = []
return
}
visible.value = true
loading.value = true
files.value = []
try {
const res = await StorageService.batchDownloadUrls({
file_keys: fileKeys
})
if (res.code === 0) {
files.value = fileKeys
.map((key) => {
const url = res.data.urls[key]
if (!url) return null
return {
key,
name: getFileName(key),
url,
isImage: isImageFile(key, url)
}
})
.filter((file): file is VoucherFile => !!file)
}
} else {
visible.value = false
urls.value = []
} catch (error) {
console.error('获取凭证失败:', error)
} finally {
loading.value = false
}
},
{ immediate: true }
@@ -57,7 +124,62 @@
const handleClose = () => {
visible.value = false
urls.value = []
loading.value = false
files.value = []
emit('close')
}
</script>
<style scoped lang="scss">
.voucher-preview {
&__loading {
padding: 32px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
&__item {
min-width: 0;
}
&__image,
&__file {
width: 120px;
height: 120px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
}
&__image {
cursor: zoom-in;
}
&__file {
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
&__meta {
width: 120px;
margin-top: 8px;
}
&__name {
overflow: hidden;
font-size: 12px;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,404 @@
<template>
<div
ref="rootRef"
class="voucher-upload"
tabindex="0"
@click="focusUploadArea"
@paste="handlePaste"
>
<ElUpload
ref="uploadRef"
class="voucher-upload__control"
drag
multiple
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
>
<ElIcon class="voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件点击上传区域后可粘贴文件
</div>
</template>
<template #file="{ file }">
<div class="voucher-upload__file-item">
<img
v-if="isUploadImage(file)"
class="voucher-upload__file-cover"
:src="getUploadFileUrl(file)"
alt=""
/>
<div v-else class="voucher-upload__file-cover voucher-upload__file-cover--default">
<ElIcon><Document /></ElIcon>
</div>
<span class="voucher-upload__file-name" :title="file.name">{{ file.name }}</span>
<button
class="voucher-upload__file-remove"
type="button"
aria-label="移除文件"
@click.stop="removeUploadFile(file)"
>
<ElIcon><Close /></ElIcon>
</button>
</div>
</template>
</ElUpload>
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { genFileId } from 'element-plus'
import { Close, Document, UploadFilled } from '@element-plus/icons-vue'
import type { UploadFile, UploadInstance, UploadRawFile } from 'element-plus'
import { StorageService } from '@/api/modules'
interface Props {
modelValue?: string
voucherName?: string
maxCount?: number
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
})
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
'uploading-change': [value: boolean]
change: [value: string | undefined]
}>()
const rootRef = ref<HTMLElement>()
const uploadRef = ref<UploadInstance>()
const uploadingCount = ref(0)
const voucherFileKeyMap = new Map<number, string>()
const removedUploadUids = new Set<number>()
const fileObjectUrlMap = new Map<number, string>()
const selectedUploadUids = new Set<number>()
let activeUploadBatch = 0
const getMaxCount = () => Math.max(1, props.maxCount)
const showMaxCountWarning = () => {
ElMessage.warning(`最多只能上传 ${getMaxCount()} 个文件`)
}
const emitVoucherKeys = () => {
const keys = Array.from(voucherFileKeyMap.values())
const value = keys.length ? keys.join(',') : undefined
emit('update:modelValue', value)
emit('change', value)
}
const setUploadingCount = (count: number) => {
uploadingCount.value = Math.max(0, count)
emit('uploading-change', uploadingCount.value > 0)
}
const clearFiles = (emitValue = true) => {
activeUploadBatch += 1
setUploadingCount(0)
voucherFileKeyMap.clear()
removedUploadUids.clear()
selectedUploadUids.clear()
revokeAllFileObjectUrls()
uploadRef.value?.clearFiles()
if (emitValue) {
emitVoucherKeys()
}
}
const revokeFileObjectUrl = (uid: number) => {
const url = fileObjectUrlMap.get(uid)
if (!url) return
URL.revokeObjectURL(url)
fileObjectUrlMap.delete(uid)
}
const revokeAllFileObjectUrls = () => {
fileObjectUrlMap.forEach((url) => URL.revokeObjectURL(url))
fileObjectUrlMap.clear()
}
const isUploadImage = (file: UploadFile) => {
if (file.raw?.type?.startsWith('image/')) return true
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(file.name)
}
const getUploadFileUrl = (file: UploadFile) => {
if (file.url) return file.url
if (!file.raw) return ''
const cachedUrl = fileObjectUrlMap.get(file.uid)
if (cachedUrl) return cachedUrl
const url = URL.createObjectURL(file.raw)
fileObjectUrlMap.set(file.uid, url)
return url
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()
removeUploadFile(uploadFile)
return
}
selectedUploadUids.add(uploadFile.uid)
}
const uploadBatch = activeUploadBatch
removedUploadUids.delete(uploadFile.uid)
setUploadingCount(uploadingCount.value + 1)
try {
ElMessage.info(`正在上传${props.voucherName}...`)
const contentType = file.type || 'application/octet-stream'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, contentType)
if (uploadBatch !== activeUploadBatch || removedUploadUids.has(uploadFile.uid)) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
emitVoucherKeys()
ElMessage.success('上传成功')
} catch (error: any) {
if (uploadBatch !== activeUploadBatch) return
console.error(`上传${props.voucherName}失败:`, error)
ElMessage.error(error?.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeUploadBatch) {
setUploadingCount(uploadingCount.value - 1)
}
}
}
const handleRemoveFile = (uploadFile: UploadFile) => {
removedUploadUids.add(uploadFile.uid)
selectedUploadUids.delete(uploadFile.uid)
revokeFileObjectUrl(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
emitVoucherKeys()
}
const focusUploadArea = () => {
nextTick(() => rootRef.value?.focus())
}
const getExtensionByMimeType = (mimeType: string) => {
const extensionMap: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'application/pdf': 'pdf',
'text/plain': 'txt'
}
return extensionMap[mimeType] || 'bin'
}
const normalizeClipboardFile = (file: File, index: number) => {
if (file.name) return file
const extension = getExtensionByMimeType(file.type)
return new File([file], `clipboard-${Date.now()}-${index + 1}.${extension}`, {
type: file.type || 'application/octet-stream'
})
}
const startUploadFile = (file: File) => {
const uploadRawFile = file as UploadRawFile
uploadRawFile.uid = genFileId()
uploadRef.value?.handleStart(uploadRawFile)
}
const getClipboardFiles = (event: ClipboardEvent) => {
const clipboardData = event.clipboardData
if (!clipboardData) return []
const files = Array.from(clipboardData.files || [])
if (files.length) return files
return Array.from(clipboardData.items || [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => !!file)
}
const handlePaste = (event: ClipboardEvent) => {
if (event.defaultPrevented) return
if (!isUploadVisible()) return
const files = getClipboardFiles(event)
if (!files.length) return
event.preventDefault()
const remainingCount = getMaxCount() - selectedUploadUids.size
if (remainingCount <= 0) {
showMaxCountWarning()
return
}
if (files.length > remainingCount) {
showMaxCountWarning()
}
files.slice(0, remainingCount).map(normalizeClipboardFile).forEach(startUploadFile)
}
const isUploadVisible = () => {
const element = rootRef.value
return (
!!element &&
!!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)
)
}
onMounted(() => {
document.addEventListener('paste', handlePaste)
})
onBeforeUnmount(() => {
document.removeEventListener('paste', handlePaste)
revokeAllFileObjectUrls()
})
defineExpose({
clearFiles
})
</script>
<style scoped lang="scss">
.voucher-upload {
width: 100%;
outline: none;
&:focus-within,
&:focus {
:deep(.el-upload-dragger) {
border-color: var(--el-color-primary);
}
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
:deep(.el-upload-list__item) {
height: auto;
padding: 8px;
line-height: normal;
}
&__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);
}
}
&__file-item {
display: flex;
gap: 10px;
align-items: center;
width: 100%;
min-width: 0;
}
&__file-cover {
flex: 0 0 auto;
width: 42px;
height: 42px;
object-fit: cover;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
}
&__file-cover--default {
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
}
&__file-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--el-text-color-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
&__file-remove {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 50%;
&:hover {
color: var(--el-color-danger);
background: var(--el-fill-color-light);
}
}
}
</style>

View File

@@ -301,28 +301,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
// 企业设备管理(设备授权)
{
path: 'enterprise-devices',
name: 'EnterpriseDevices',
component: RoutesAlias.EnterpriseDevices,
meta: {
title: 'menus.assetManagement.enterpriseDevices',
isHide: true,
keepAlive: false
}
},
// 企业卡授权
{
path: 'enterprise-cards',
name: 'EnterpriseCards',
component: RoutesAlias.EnterpriseCards,
meta: {
title: 'menus.accountManagement.enterpriseCards',
isHide: true,
keepAlive: false
}
},
// 换货管理
{
path: 'exchange-management',

View File

@@ -40,8 +40,6 @@ export enum RoutesAlias {
// 账号管理
Account = '/account-management/account', // 账号列表
EnterpriseCustomer = '/account-management/enterprise-customer', // 企业客户管理
EnterpriseCards = '/asset-management/enterprise-cards', // 企业卡授权
EnterpriseDevices = '/asset-management/enterprise-devices', // 企业设备授权
// 资产管理
AssetInformation = '/asset-management/asset-information', // 资产信息

View File

@@ -347,6 +347,9 @@ export interface StandaloneCardQueryParams extends PaginationParams {
carrier_name?: string // carrier name filter
is_standalone?: boolean // standalone filter
status?: StandaloneCardStatus // 状态
network_status?: number | null // 网络状态 (0:停机, 1:开机)
authorized_enterprise_id?: number // 有效授权企业ID
is_authorized_to_enterprise?: boolean | null // 是否已授权给企业
carrier_id?: number // 运营商ID
shop_id?: number // 分销商ID
iccid?: string // ICCID(模糊查询)
@@ -404,6 +407,8 @@ export interface StandaloneIotCard {
series_id: number | null // 套餐系列ID
series_name: string // 套餐系列名称
realname_policy: string // 实名制策略
authorized_enterprise_id?: number | null // 当前有效授权企业ID
authorized_enterprise_name?: string | null // 当前有效授权企业名称
}
// ========== 单卡批量分配和回收相关 ==========

View File

@@ -310,6 +310,7 @@ export interface MainWalletTransactionQueryParams extends PaginationParams {
transaction_type?: MainWalletTransactionType // 交易类型过滤
start_date?: string // 开始日期 YYYY-MM-DD
end_date?: string // 结束日期 YYYY-MM-DD
asset_identifier?: string // 资产标识精确检索ICCID 或设备虚拟号)
}
/**

View File

@@ -65,6 +65,8 @@ export interface Device {
imei?: string // IMEI设备国际移动设备识别码
sn?: string // 设备序列号
realname_policy?: string // 实名认证策略
authorized_enterprise_id?: number | null // 当前有效授权企业ID
authorized_enterprise_name?: string | null // 当前有效授权企业名称
}
// 设备查询参数
@@ -81,6 +83,8 @@ export interface DeviceQueryParams extends PaginationParams {
created_at_end?: string // 创建时间结束
series_id?: number // 套餐系列ID
keyword?: string // 关键词搜索虚拟号或IMEI
authorized_enterprise_id?: number // 有效授权企业ID
is_authorized_to_enterprise?: boolean | null // 是否已授权给企业
}
// 设备列表响应

View File

@@ -30,8 +30,26 @@ export interface AllocatedDevice {
* 授权卡给企业请求
*/
export interface AllocateCardsRequest {
/** 卡片选取方式 */
selection_type?: 'list' | 'range' | 'filter'
/** 需要授权的 ICCID 列表 */
iccids: string[]
iccids?: string[]
/** 起始 ICCID */
iccid_start?: string
/** 结束 ICCID */
iccid_end?: string
/** 运营商ID */
carrier_id?: number
/** 单个店铺ID */
shop_id?: number
/** 多个店铺ID */
shop_ids?: number[]
/** 批次号 */
batch_no?: string
/** ICCID模糊匹配 */
iccid?: string
/** 授权备注 */
remark?: string
/** 确认整体授权设备下所有卡 */
confirm_device_bundles?: boolean
}
@@ -212,8 +230,22 @@ export interface RecalledDevice {
* 回收卡授权请求
*/
export interface RecallCardsRequest {
/** 卡片选取方式 */
selection_type?: 'list' | 'range' | 'filter'
/** 需要回收授权的 ICCID 列表 */
iccids: string[]
iccids?: string[]
/** 起始 ICCID */
iccid_start?: string
/** 结束 ICCID */
iccid_end?: string
/** 运营商ID */
carrier_id?: number
/** 批次号 */
batch_no?: string
/** ICCID模糊匹配 */
iccid?: string
/** 回收备注 */
remark?: string
}
/**

View File

@@ -38,8 +38,12 @@ export interface EnterpriseDevicePageResult {
/**
* 授权设备请求参数
*/
export interface AllocateDevicesRequest {
device_nos: string[] | null // 设备号列表最多100个
export interface EnterpriseAllocateDevicesRequest {
selection_type?: 'list' | 'filter' // 设备选取方式
device_nos?: string[] | null // 设备号列表最多100个
batch_no?: string // 批次号
shop_id?: number // 店铺ID
virtual_no?: string // 设备虚拟号模糊匹配
remark?: string // 授权备注
}
@@ -63,7 +67,7 @@ export interface FailedDeviceItem {
/**
* 授权设备响应
*/
export interface AllocateDevicesResponse {
export interface EnterpriseAllocateDevicesResponse {
success_count: number // 成功数量
fail_count: number // 失败数量
authorized_devices: AuthorizedDeviceItem[] | null // 已授权设备列表
@@ -75,14 +79,17 @@ export interface AllocateDevicesResponse {
/**
* 撤销设备授权请求参数
*/
export interface RecallDevicesRequest {
device_nos: string[] | null // 设备号列表最多100个
export interface EnterpriseRecallDevicesRequest {
selection_type?: 'list' | 'filter' // 设备选取方式
device_nos?: string[] | null // 设备号列表最多100个
batch_no?: string // 批次号
virtual_no?: string // 设备虚拟号模糊匹配
}
/**
* 撤销设备授权响应
*/
export interface RecallDevicesResponse {
export interface EnterpriseRecallDevicesResponse {
success_count: number // 成功数量
fail_count: number // 失败数量
failed_items: FailedDeviceItem[] | null // 失败项列表

View File

@@ -47,6 +47,7 @@ export interface RefundQueryParams {
order_id?: number
shop_id?: number
shop_name?: string // 店铺名称(模糊查询)
asset_identifier?: string // 资产标识ICCID 或 设备虚拟号,精确匹配)
dateRange?: string[]
}

View File

@@ -155,6 +155,7 @@ declare module 'vue' {
TableContextMenuHint: typeof import('./../components/core/others/TableContextMenuHint.vue')['default']
ThemeSettings: typeof import('./../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default']
UpdateRealnameStatusDialog: typeof import('./../components/business/UpdateRealnameStatusDialog.vue')['default']
VoucherUpload: typeof import('./../components/business/VoucherUpload.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']

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;
}