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

@@ -0,0 +1,34 @@
## Context
Enterprise asset authorization currently has two hidden pages launched from enterprise customer rows. The requested workflow moves those operations to the asset lists so users can filter assets by enterprise authorization state and act on the same list they export from.
## Goals
- Add enterprise authorization filters to IoT card management and device management.
- Add direct card/device enterprise authorization and recall actions beside existing export actions.
- Protect each new action with an independent button permission code.
- Remove enterprise customer row authorization shortcuts and the replaced “企业卡管理” / “企业设备列表” hidden pages.
## Non-Goals
- Do not change backend endpoint paths.
- Do not add new route pages for this workflow.
- Do not preserve compatibility routes for the old “企业卡管理” or “企业设备列表” pages unless a product requirement is added later.
## Decisions
- New permission codes:
- `iot_card:authorize_enterprise` for “授权卡给企业”.
- `iot_card:recall_enterprise_authorization` for “回收卡授权”.
- `devices:authorize_enterprise` for “授权设备给企业”.
- `devices:recall_enterprise_authorization` for “撤销设备授权”.
- Authorization and recall dialogs will require a target/source enterprise ID, except recall may prefill or lock it when `authorized_enterprise_id` is present in current filters or a single selected-row enterprise ID can be safely inferred from list response data.
- List-mode operations use selected rows. Card operations submit `iccids`; device operations submit `device_nos` derived from available list response identifiers such as `virtual_no`.
- Filter-mode operations submit only non-empty filter fields supported by `docs/授权、回收.md`.
- The `shop_id` filter in “授权卡给企业” and “授权设备给企业” dialogs uses the same lazy `ElCascader` tree pattern as the add-shop dialogs “上级店铺” field: `ShopService.getShopsCascade({ parent_id })`, `checkStrictly: true`, and submit the last selected path node as `shop_id`.
- Old hidden pages are deleted rather than kept as redirects because the list-page authorization workflow replaces “企业卡管理” and “企业设备列表”.
## Risks
- The list response field names for enterprise authorization metadata may vary. Implementation should read current API response types and use returned fields when available; if a required source enterprise cannot be inferred, require explicit enterprise selection in the recall dialog.
- Existing `add-enterprise-device-authorization` OpenSpec change overlaps conceptually but proposes a separate enterprise-device page. This change supersedes that approach for the current product direction.

View File

@@ -0,0 +1,38 @@
# Change: Move Enterprise Asset Authorization To Asset Lists
## Why
企业卡授权和设备授权当前从企业客户详情入口进入独立页面操作路径长且与资产实际筛选、导出、批量操作割裂。IoT 卡管理和设备管理列表需要直接支持按企业授权状态过滤,并在列表页完成企业授权和授权回收。
## What Changes
- IoT 卡管理新增 `authorized_enterprise_id``is_authorized_to_enterprise` 查询过滤。
- IoT 卡管理在导出按钮左侧新增“授权卡给企业”和“回收卡授权”按钮,并为两个按钮添加独立权限编码。
- 设备管理新增 `authorized_enterprise_id``is_authorized_to_enterprise` 查询过滤。
- 设备管理在导出按钮左侧新增“授权设备给企业”和“撤销设备授权”按钮,并为两个按钮添加独立权限编码。
- 新列表按钮复用 `docs/授权、回收.md` 中的授权/回收接口,支持列表选择和筛选条件批量操作。
- 企业客户管理移除“卡授权”和“设备授权”操作入口。
- 删除已被替代的“企业卡管理”和“企业设备列表”两个旧页面,以及对应隐藏路由和路由别名。
## Impact
- Affected specs: `iot-card-management`, `device-management`, `enterprise-customer-management`
- Affected code:
- `src/views/asset-management/iot-card-management/index.vue`
- `src/views/asset-management/device-list/index.vue`
- `src/views/account-management/enterprise-customer/index.vue`
- `src/api/modules/enterprise.ts`
- `src/types/api/card.ts`
- `src/types/api/device.ts`
- `src/types/api/enterpriseCard.ts`
- `src/types/api/enterpriseDevice.ts`
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/views/asset-management/enterprise-cards/index.vue`(删除)
- `src/views/asset-management/enterprise-devices/index.vue`(删除)
## Dependencies
- Backend list APIs accept `authorized_enterprise_id` and `is_authorized_to_enterprise` as query parameters.
- Backend authorization APIs are described in `docs/授权、回收.md`.
- Existing enterprise list/search API can provide target enterprise options for the new dialogs.

View File

@@ -0,0 +1,83 @@
## ADDED Requirements
### Requirement: Device Enterprise Authorization Filters
The device management page SHALL support filtering devices by effective enterprise authorization.
#### Scenario: Filter devices by authorized enterprise ID
- **GIVEN** 用户在设备管理搜索栏选择或输入企业 ID
- **WHEN** 用户执行搜索
- **THEN** 系统 MUST send `authorized_enterprise_id` to the device list API
- **AND** the list MUST only show devices currently authorized to that enterprise according to the backend response
#### Scenario: Filter devices by enterprise authorization status
- **GIVEN** 用户在设备管理搜索栏选择企业授权状态
- **WHEN** 用户选择“已授权”并搜索
- **THEN** 系统 MUST send `is_authorized_to_enterprise=true`
- **WHEN** 用户选择“未授权”并搜索
- **THEN** 系统 MUST send `is_authorized_to_enterprise=false`
- **AND** clearing the filter MUST omit `is_authorized_to_enterprise` from the request
### Requirement: Device Enterprise Authorization Actions
The device management page SHALL provide enterprise device authorization actions directly in the table header before the export button.
#### Scenario: Show permission-controlled device authorization buttons
- **GIVEN** 用户访问设备管理页面
- **WHEN** the table header renders
- **THEN** the “授权设备给企业” button MUST appear to the left of the “导出” button only when the user has permission `devices:authorize_enterprise`
- **AND** the “撤销设备授权” button MUST appear to the left of the “导出” button only when the user has permission `devices:recall_enterprise_authorization`
#### Scenario: Authorize selected devices to an enterprise
- **GIVEN** 用户在设备管理列表选中了设备
- **WHEN** 用户点击“授权设备给企业”并选择目标企业
- **AND** 用户选择 `list` 选取方式
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/allocate-devices`
- **AND** the request body MUST include `selection_type: 'list'`
- **AND** the request body MUST include selected row device identifiers as `device_nos`
- **AND** identifiers MUST come from list response fields such as `virtual_no` when available
- **AND** the UI MUST display success count, fail count, authorized devices, and failed item reasons from the response
- **AND** the device list MUST refresh after any successful authorization
#### Scenario: Authorize devices by filters
- **GIVEN** 用户打开“授权设备给企业” dialog
- **WHEN** 用户选择 `filter` 选取方式并确认
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/allocate-devices`
- **AND** the request body MUST include `selection_type: 'filter'`
- **AND** filter fields MUST be populated from the dialog filter form or current list filters when applicable, including available values such as `batch_no`, `shop_id`, and `virtual_no`
- **AND** empty filter fields MUST be omitted
#### Scenario: Authorize devices filter selects shop from cascade tree
- **GIVEN** 用户在“授权设备给企业” dialog 中选择 `filter` 选取方式
- **WHEN** the shop filter field renders
- **THEN** the field MUST use the same lazy cascade tree pattern as the “上级店铺” field in the add-shop dialog
- **AND** it MUST load children through `ShopService.getShopsCascade({ parent_id })`
- **AND** it MUST allow selecting any level with `checkStrictly: true`
- **AND** when submitted, the request MUST use the last selected cascade node ID as `shop_id`
- **AND** clearing the cascade field MUST omit `shop_id`
#### Scenario: Recall selected device authorization
- **GIVEN** 用户在设备管理列表选中了 currently enterprise-authorized devices
- **WHEN** 用户点击“撤销设备授权”
- **THEN** the UI MUST require a source enterprise ID unless it can safely infer one from `authorized_enterprise_id` or selected row data returned by the list API
- **WHEN** 用户 confirms `list` recall
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/recall-devices`
- **AND** the request body MUST include `selection_type: 'list'` and selected row device identifiers as `device_nos`
- **AND** the UI MUST display success count, fail count, and failed item reasons
- **AND** the device list MUST refresh after any successful recall
#### Scenario: Recall device authorization by filters
- **GIVEN** 用户打开“撤销设备授权” dialog
- **WHEN** 用户选择 `filter` selection mode and confirms
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/recall-devices`
- **AND** the request body MUST include `selection_type: 'filter'`
- **AND** filter fields MUST follow the contract in `docs/授权、回收.md`, including available values such as `batch_no` and `virtual_no`
- **AND** empty filter fields MUST be omitted

View File

@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Enterprise Customer Asset Authorization Entry Removal
The enterprise customer management page SHALL NOT provide enterprise card or device authorization entry points, and the replaced “企业卡管理” and “企业设备列表” pages SHALL be removed because these operations move to the IoT card management and device management list pages.
#### Scenario: Enterprise customer action column no longer shows asset authorization
- **GIVEN** 用户访问企业客户管理页面
- **WHEN** the action column renders for an enterprise customer row
- **THEN** the UI MUST NOT show “卡授权”
- **AND** the UI MUST NOT show “设备授权”
#### Scenario: Replaced enterprise asset authorization pages are removed
- **GIVEN** the application routes are registered
- **WHEN** route configuration is loaded
- **THEN** `/asset-management/enterprise-cards` MUST NOT be registered
- **AND** `/asset-management/enterprise-devices` MUST NOT be registered
- **AND** route aliases for `EnterpriseCards` and `EnterpriseDevices` MUST NOT remain
- **AND** `src/views/asset-management/enterprise-cards/index.vue` MUST be deleted
- **AND** `src/views/asset-management/enterprise-devices/index.vue` MUST be deleted

View File

@@ -0,0 +1,88 @@
## ADDED Requirements
### Requirement: IoT Card Enterprise Authorization Filters
The IoT card management page SHALL support filtering standalone cards by effective enterprise authorization.
#### Scenario: Filter cards by authorized enterprise ID
- **GIVEN** 用户在 IoT 卡管理搜索栏选择或输入企业 ID
- **WHEN** 用户执行搜索
- **THEN** 系统 MUST send `authorized_enterprise_id` to the IoT card list API
- **AND** the list MUST only show cards currently authorized to that enterprise according to the backend response
#### Scenario: Filter cards by enterprise authorization status
- **GIVEN** 用户在 IoT 卡管理搜索栏选择企业授权状态
- **WHEN** 用户选择“已授权”并搜索
- **THEN** 系统 MUST send `is_authorized_to_enterprise=true`
- **WHEN** 用户选择“未授权”并搜索
- **THEN** 系统 MUST send `is_authorized_to_enterprise=false`
- **AND** clearing the filter MUST omit `is_authorized_to_enterprise` from the request
### Requirement: IoT Card Enterprise Authorization Actions
The IoT card management page SHALL provide enterprise card authorization actions directly in the table header before the export button.
#### Scenario: Show permission-controlled card authorization buttons
- **GIVEN** 用户访问 IoT 卡管理页面
- **WHEN** the table header renders
- **THEN** the “授权卡给企业” button MUST appear to the left of the “导出” button only when the user has permission `iot_card:authorize_enterprise`
- **AND** the “回收卡授权” button MUST appear to the left of the “导出” button only when the user has permission `iot_card:recall_enterprise_authorization`
#### Scenario: Authorize selected cards to an enterprise
- **GIVEN** 用户在 IoT 卡管理列表选中了卡
- **WHEN** 用户点击“授权卡给企业”并选择目标企业
- **AND** 用户选择 `list` 选取方式
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/allocate-cards`
- **AND** the request body MUST include `selection_type: 'list'`
- **AND** the request body MUST include selected row ICCIDs as `iccids`
- **AND** the UI MUST display success count, fail count, and failed item reasons from the response
- **AND** the card list MUST refresh after any successful authorization
#### Scenario: Authorize cards by range
- **GIVEN** 用户打开“授权卡给企业” dialog
- **WHEN** 用户选择 `range` 选取方式并填写起始和结束 ICCID
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/allocate-cards`
- **AND** the request body MUST include `selection_type: 'range'`, `iccid_start`, `iccid_end`, and optional `remark`
#### Scenario: Authorize cards by filters
- **GIVEN** 用户打开“授权卡给企业” dialog
- **WHEN** 用户选择 `filter` 选取方式并确认
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/allocate-cards`
- **AND** the request body MUST include `selection_type: 'filter'`
- **AND** filter fields MUST be populated from the dialog filter form or current list filters when applicable, including available values such as `carrier_id`, `shop_id`, `shop_ids`, `batch_no`, and `iccid`
- **AND** empty filter fields MUST be omitted
#### Scenario: Authorize cards filter selects shop from cascade tree
- **GIVEN** 用户在“授权卡给企业” dialog 中选择 `filter` 选取方式
- **WHEN** the shop filter field renders
- **THEN** the field MUST use the same lazy cascade tree pattern as the “上级店铺” field in the add-shop dialog
- **AND** it MUST load children through `ShopService.getShopsCascade({ parent_id })`
- **AND** it MUST allow selecting any level with `checkStrictly: true`
- **AND** when submitted, the request MUST use the last selected cascade node ID as `shop_id`
- **AND** clearing the cascade field MUST omit `shop_id`
#### Scenario: Recall selected card authorization
- **GIVEN** 用户在 IoT 卡管理列表选中了 currently enterprise-authorized cards
- **WHEN** 用户点击“回收卡授权”
- **THEN** the UI MUST require a source enterprise ID unless it can safely infer one from `authorized_enterprise_id` or selected row data returned by the list API
- **WHEN** 用户 confirms `list` recall
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/recall-cards`
- **AND** the request body MUST include `selection_type: 'list'` and selected row ICCIDs as `iccids`
- **AND** the UI MUST display success count, fail count, failed item reasons, and any `recalled_devices`
- **AND** the card list MUST refresh after any successful recall
#### Scenario: Recall card authorization by range or filters
- **GIVEN** 用户打开“回收卡授权” dialog
- **WHEN** 用户选择 `range` or `filter` selection mode and confirms
- **THEN** 系统 MUST call `POST /api/admin/enterprises/{id}/recall-cards`
- **AND** the request body MUST match the selected mode using the contract in `docs/授权、回收.md`
- **AND** empty filter fields MUST be omitted

View File

@@ -0,0 +1,46 @@
## 1. API And Types
- [x] 1.1 Extend IoT card list query params with `authorized_enterprise_id?: number` and `is_authorized_to_enterprise?: boolean | null`.
- [x] 1.2 Extend device list query params with `authorized_enterprise_id?: number` and `is_authorized_to_enterprise?: boolean | null`.
- [x] 1.3 Align enterprise card authorization request types with `selection_type: 'list' | 'range' | 'filter'`, list/range/filter fields, and optional `remark`.
- [x] 1.4 Align enterprise device authorization request types with `selection_type: 'list' | 'filter'`, list/filter fields, and optional `remark`.
- [x] 1.5 Keep using `EnterpriseService.allocateCards`, `recallCards`, `allocateDevices`, and `recallDevices`; update method types only if needed.
## 2. IoT Card Management
- [x] 2.1 Add enterprise authorization filters to the IoT card search form and request payload.
- [x] 2.2 Add “授权卡给企业” before the export button with permission `iot_card:authorize_enterprise`.
- [x] 2.3 Add “回收卡授权” before the export button with permission `iot_card:recall_enterprise_authorization`.
- [x] 2.4 Implement card authorization dialog with target enterprise selection, selection mode, list/range/filter inputs, optional remark, validation, submit, loading state, and result display.
- [x] 2.4.1 Update the card authorization dialog shop filter to use the same lazy shop cascade tree as the add-shop dialog “上级店铺” field, and submit the last selected node as `shop_id`.
- [x] 2.5 Implement card recall dialog with target/source enterprise resolution, selection mode, list/range/filter inputs, optional remark, validation, submit, loading state, and result display.
- [x] 2.6 Refresh the IoT card list after successful authorization or recall.
## 3. Device Management
- [x] 3.1 Add enterprise authorization filters to the device search form and request payload.
- [x] 3.2 Add “授权设备给企业” before the export button with permission `devices:authorize_enterprise`.
- [x] 3.3 Add “撤销设备授权” before the export button with permission `devices:recall_enterprise_authorization`.
- [x] 3.4 Implement device authorization dialog with target enterprise selection, selection mode, list/filter inputs, optional remark, validation, submit, loading state, and result display.
- [x] 3.4.1 Update the device authorization dialog shop filter to use the same lazy shop cascade tree as the add-shop dialog “上级店铺” field, and submit the last selected node as `shop_id`.
- [x] 3.5 Implement device recall dialog with target/source enterprise resolution, selection mode, list/filter inputs, validation, submit, loading state, and result display.
- [x] 3.6 Refresh the device list after successful authorization or recall.
## 4. Remove Replaced Enterprise Authorization Pages
- [x] 4.1 Remove “卡授权” and “设备授权” operations from the enterprise customer action column.
- [x] 4.2 Remove navigation logic to enterprise card/device authorization pages.
- [x] 4.3 Remove hidden routes for `/asset-management/enterprise-cards` and `/asset-management/enterprise-devices`.
- [x] 4.4 Remove `RoutesAlias.EnterpriseCards` and `RoutesAlias.EnterpriseDevices`.
- [x] 4.5 Delete the replaced “企业卡管理” page file `src/views/asset-management/enterprise-cards/index.vue`.
- [x] 4.6 Delete the replaced “企业设备列表” page file `src/views/asset-management/enterprise-devices/index.vue`.
- [x] 4.7 Verify no imports, route aliases, menu entries, or navigation calls reference the deleted pages.
## 5. Validation
- [x] 5.1 Verify IoT card list filters send correct query parameters and reset correctly.
- [x] 5.2 Verify device list filters send correct query parameters and reset correctly.
- [x] 5.3 Verify all four new buttons obey their permission codes.
- [x] 5.4 Verify list/range/filter authorization and recall flows against API contracts from `docs/授权、回收.md`.
- [x] 5.5 Run targeted ESLint for modified files.
- [ ] 5.6 Run `openspec validate update-enterprise-asset-authorization-entry --strict` when the OpenSpec CLI is available.

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