fetch(modify):修改原来的bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m53s

This commit is contained in:
sexygoat
2026-01-31 11:18:37 +08:00
parent 8a1388608c
commit 31440b2904
62 changed files with 3025 additions and 1421 deletions

View File

@@ -5,7 +5,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- 引入小米字体 CSS 文件 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/misans@4.1.0/lib/Normal/MiSans-Regular.min.css"
/>
<link rel="shortcut icon" type="image/x-icon" href="src/assets/img/logo.png" />
</head>

View File

@@ -5,6 +5,7 @@
The system currently has a basic device-import page but lacks comprehensive device management capabilities. Devices are critical assets that need to be tracked throughout their lifecycle - from import, allocation to shops, binding with SIM cards, to eventual recall. This change adds full device management to complement the existing card management features.
Key integration points:
- **Existing Card System**: Devices must bind with cards from the existing card-list module
- **Shop System**: Devices are allocated to shops using the existing ShopService
- **Object Storage**: Imports use the existing StorageService for large file uploads
@@ -25,16 +26,19 @@ Key integration points:
**Choice**: Use StorageService.getUploadUrl → uploadFile → DeviceService.importDevices
**Rationale**:
- Handles large files (thousands of devices) without timeout issues
- Decouples upload from processing (backend can process asynchronously)
- Consistent with modern cloud architecture patterns
- Allows progress tracking through task management
**Alternatives Considered**:
- Direct multipart upload to backend (rejected: not scalable for large files)
- Two-step process without pre-signed URL (rejected: less secure, more backend load)
**Implementation Notes**:
- Frontend only handles upload to object storage, not file parsing
- Backend processes the file asynchronously and creates task records
- Task management provides visibility into import progress
@@ -44,16 +48,19 @@ Key integration points:
**Choice**: Store binding with explicit slot_position (1-4) in device_cards table
**Rationale**:
- IoT devices have physical SIM slots that need explicit identification
- Each device can have 1-4 slots (max_sim_slots)
- One card can only bind to one device slot (enforced by backend)
- Slot position is critical for physical device configuration
**Alternatives Considered**:
- Auto-assign slot positions (rejected: operators need to know physical slot numbers)
- Allow one card to bind to multiple devices (rejected: not realistic for physical SIMs)
**Implementation Notes**:
- Device detail page shows a 4-slot grid (empty slots show "Bind Card" button)
- Binding dialog requires explicit slot selection
- Unbinding updates bound_card_count and frees the slot
@@ -63,16 +70,19 @@ Key integration points:
**Choice**: Extend existing task-management to support device import tasks
**Rationale**:
- Reuses existing task infrastructure
- Provides consistent UX for all import operations
- Avoids duplicate task tracking logic
- Allows unified search/filter across task types
**Alternatives Considered**:
- Separate device task management page (rejected: creates UX fragmentation)
- Embed task tracking only in device-import page (rejected: limited visibility)
**Implementation Notes**:
- Add task_type field to distinguish ICCID vs Device imports
- Task detail page renders different content based on task_type
- Device tasks show device_no and bound ICCIDs instead of just ICCID
@@ -82,35 +92,41 @@ Key integration points:
**Choice**: Show detailed results in dialog after batch allocation/recall
**Rationale**:
- Operations may partially succeed (some devices succeed, others fail)
- Operators need to know exactly which devices failed and why
- Allows retry of failed operations without re-selecting all devices
**Alternatives Considered**:
- Just show toast notification (rejected: insufficient detail for partial failures)
- Navigate to separate results page (rejected: disrupts workflow)
**Implementation Notes**:
- Dialog shows summary: total, success count, failure count
- Expandable table shows failed devices with error messages
- Success closes dialog, partial failure keeps it open for review
### Decision 5: Component Reuse Strategy
**Choice**: Use existing Art* components (ArtTableFullScreen, ArtSearchBar, ArtTable, ArtButtonTable)
**Choice**: Use existing Art\* components (ArtTableFullScreen, ArtSearchBar, ArtTable, ArtButtonTable)
**Rationale**:
- Maintains UI consistency across the application
- Reduces development time
- Leverages tested, stable components
- Easier for users familiar with other pages
**Reference Implementation**:
- Device-list follows role/index.vue pattern
- Device-detail follows card-list detail modal pattern
- Search form follows enterprise-customer search pattern
**Implementation Notes**:
- Use CommonStatus for status values (ENABLED/DISABLED)
- Use ElSwitch for status toggles
- Use ArtButtonTable for action buttons
@@ -119,6 +135,7 @@ Key integration points:
## Architecture Diagrams
### Device Import Flow
```
┌─────────┐ 1. Select CSV ┌──────────────┐
│ Admin │ ──────────────────> │ device-import│
@@ -155,6 +172,7 @@ Key integration points:
```
### Device-Card Binding
```
┌─────────┐ ┌──────────────┐
│ Device │ ───── bound to ────> │ Card │
@@ -175,12 +193,14 @@ Key integration points:
## Data Flow
### Device List Page Load
1. User navigates to /asset-management/device-list
2. Vue component mounts, calls DeviceService.getDevices(params)
3. Backend returns paginated device list with bound_card_count
4. Table renders with status switches and action buttons
### Batch Allocation Flow
1. User selects devices, clicks "Batch Allocate"
2. Dialog opens with shop selector
3. User selects shop, adds remarks, confirms
@@ -189,6 +209,7 @@ Key integration points:
6. Dialog shows summary and failed device details
### Card Binding Flow
1. User opens device detail page
2. Clicks "Bind Card" for an empty slot
3. Dialog shows card search and slot selection
@@ -202,11 +223,13 @@ Key integration points:
### Updating Existing Device Import Page
**Current State** (`src/views/batch/device-import/index.vue`):
- Uses ElUpload with drag-and-drop
- Mock data for import records
- No real API integration
**Migration Steps**:
1. Replace ElUpload with three-step upload logic
- Add getUploadUrl call
- Add uploadFile to object storage
@@ -216,6 +239,7 @@ Key integration points:
4. Update CSV format instructions
**Backward Compatibility**:
- This is a new feature area with no existing production data
- No API breaking changes
- UI changes are additive (improve existing page)
@@ -223,16 +247,19 @@ Key integration points:
### Extending Task Management
**Current State**:
- Only handles ICCID import tasks
- Single task type rendering
**Migration Steps**:
1. Add task_type filter dropdown (default: show all)
2. Add task_type badge in task list
3. Task detail page: switch rendering based on task_type
4. Add device-specific fields to task detail view
**Backward Compatibility**:
- Existing ICCID tasks continue to work unchanged
- Filter defaults to showing all types
- No database schema changes required (task_type already exists)
@@ -240,23 +267,27 @@ Key integration points:
## Testing Strategy
### Unit Testing
- DeviceService API methods with mock responses
- Form validation logic
- Utility functions (formatters, validators)
### Integration Testing
- Device list search and pagination
- Batch allocation with partial failures
- Card binding/unbinding workflow
- Import task creation and status tracking
### E2E Testing Scenarios
1. Import devices via CSV → verify task created → check task detail
2. Search devices → select multiple → allocate to shop → verify shop assignment
3. View device detail → bind card to slot 2 → unbind → verify empty slot
4. Batch recall devices → verify shop cleared → check operation history
### Performance Considerations
- Device list pagination (default 20 per page)
- Debounced search input (300ms delay)
- Batch operation result pagination (if >100 results)

View File

@@ -3,6 +3,7 @@
## Why
当前系统只有设备导入页面,缺少完整的设备管理能力。需要提供设备的全生命周期管理,包括:
- 查看和搜索设备列表
- 查看设备详情和绑定的卡信息
- 管理设备与卡的绑定关系
@@ -14,6 +15,7 @@
## What Changes
### 新增功能
- **设备列表页面**: 支持多条件搜索、分页、列筛选、批量操作(分配、回收、删除)
- **设备详情页面**: 展示设备基本信息、绑定的卡列表、操作历史
- **卡绑定管理**: 在设备详情页绑定/解绑卡
@@ -23,10 +25,12 @@
- **导入任务管理**: 任务列表和详情查看
### API 集成
- DeviceService: 11个API接口
- 类型定义: Device, DeviceBinding, ImportTask 等
### UI 组件
- 遵循现有组件模式 (ArtTableFullScreen, ArtSearchBar等)
- 复用 CommonStatus 统一状态变量
- 使用 ElDescriptions、ElTag、ElSwitch 等组件
@@ -34,12 +38,14 @@
## Impact
### 影响的功能模块
- **新增**: 资产管理 / 设备列表(主列表页)
- **新增**: 资产管理 / 设备详情
- **改进**: 批量操作 / 设备导入(改为对象存储模式)
- **新增**: 批量操作 / 导入任务列表(独立页面)
### 影响的代码
- `src/api/modules/device.ts` (新增)
- `src/types/api/device.ts` (新增)
- `src/views/asset-management/device-list/index.vue` (新增)
@@ -54,6 +60,7 @@
- `src/types/api/index.ts` (导出 Device 类型)
### 依赖关系
- 依赖现有的 StorageService (对象存储)
- 依赖现有的 ShopService (店铺选择)
- 设备与卡的关联管理

View File

@@ -7,140 +7,140 @@
The system SHALL provide a searchable device list with multi-condition filtering, pagination, and batch operations.
#### Scenario: Search devices by multiple criteria
**WHEN** an administrator enters search criteria (device number, device name, status, shop, batch number, device type, manufacturer, creation time range)
**THEN** the system shall display only devices matching all specified criteria with pagination support
**WHEN** an administrator enters search criteria (device number, device name, status, shop, batch number, device type, manufacturer, creation time range) **THEN** the system shall display only devices matching all specified criteria with pagination support
#### Scenario: View device list with bound card count
**WHEN** the device list is displayed
**THEN** each device row shall show: ID, device number, device name, device model, device type, manufacturer, max slots, bound card count, status, shop, batch number, activation time, creation time
**WHEN** the device list is displayed **THEN** each device row shall show: ID, device number, device name, device model, device type, manufacturer, max slots, bound card count, status, shop, batch number, activation time, creation time
#### Scenario: Toggle device status
**WHEN** an administrator clicks the status switch for a device
**THEN** the system shall update the device status between ENABLED and DISABLED and refresh the display
**WHEN** an administrator clicks the status switch for a device **THEN** the system shall update the device status between ENABLED and DISABLED and refresh the display
#### Scenario: Delete a device
**WHEN** an administrator clicks the delete button and confirms the deletion
**THEN** the system shall delete the device and refresh the list
**WHEN** an administrator clicks the delete button and confirms the deletion **THEN** the system shall delete the device and refresh the list
#### Scenario: Select multiple devices for batch operations
**WHEN** an administrator checks multiple device rows
**THEN** the system shall enable batch operation buttons (Allocate, Recall) and track selected device IDs
**WHEN** an administrator checks multiple device rows **THEN** the system shall enable batch operation buttons (Allocate, Recall) and track selected device IDs
### Requirement: Device Detail Viewing
The system SHALL display comprehensive device information including basic properties and bound SIM cards.
#### Scenario: View device basic information
**WHEN** an administrator navigates to a device detail page
**THEN** the system shall display all device properties in a descriptive layout: device number, name, model, type, manufacturer, max SIM slots, status, shop, batch number, activation time, creation time
**WHEN** an administrator navigates to a device detail page **THEN** the system shall display all device properties in a descriptive layout: device number, name, model, type, manufacturer, max SIM slots, status, shop, batch number, activation time, creation time
#### Scenario: View bound SIM cards with slot positions
**WHEN** viewing device details
**THEN** the system shall display a table of bound cards showing: slot position (1-4), ICCID, phone number, carrier, card status, and binding time
**WHEN** viewing device details **THEN** the system shall display a table of bound cards showing: slot position (1-4), ICCID, phone number, carrier, card status, and binding time
#### Scenario: Empty slots display
**WHEN** a device has fewer than max_sim_slots cards bound
**THEN** empty slot positions shall be clearly indicated with "Bind Card" action buttons
**WHEN** a device has fewer than max_sim_slots cards bound **THEN** empty slot positions shall be clearly indicated with "Bind Card" action buttons
### Requirement: Device-Card Binding Management
The system SHALL allow administrators to bind and unbind SIM cards to specific device slots.
#### Scenario: Bind a card to a device slot
**WHEN** an administrator selects a card and slot position (1-4) in the binding dialog
**THEN** the system shall create the binding, update the bound card count, and refresh the card list
**WHEN** an administrator selects a card and slot position (1-4) in the binding dialog **THEN** the system shall create the binding, update the bound card count, and refresh the card list
#### Scenario: Prevent duplicate slot binding
**WHEN** an administrator attempts to bind a card to an already occupied slot
**THEN** the system shall show an error message and prevent the binding
**WHEN** an administrator attempts to bind a card to an already occupied slot **THEN** the system shall show an error message and prevent the binding
#### Scenario: Unbind a card from device
**WHEN** an administrator clicks unbind for a bound card and confirms
**THEN** the system shall remove the binding, decrement the bound card count, and refresh the card list
**WHEN** an administrator clicks unbind for a bound card and confirms **THEN** the system shall remove the binding, decrement the bound card count, and refresh the card list
#### Scenario: Validate slot range
**WHEN** an administrator selects a slot position
**THEN** the system shall only allow values from 1 to the device's max_sim_slots value
**WHEN** an administrator selects a slot position **THEN** the system shall only allow values from 1 to the device's max_sim_slots value
### Requirement: Batch Device Allocation
The system SHALL support batch allocation of devices to shops with result tracking.
#### Scenario: Allocate multiple devices to a shop
**WHEN** an administrator selects multiple devices, chooses a target shop, adds optional remarks, and confirms allocation
**THEN** the system shall allocate all selected devices to the shop and display success/failure results for each device
**WHEN** an administrator selects multiple devices, chooses a target shop, adds optional remarks, and confirms allocation **THEN** the system shall allocate all selected devices to the shop and display success/failure results for each device
#### Scenario: Show allocation results
**WHEN** the batch allocation completes
**THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
**WHEN** the batch allocation completes **THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
#### Scenario: Prevent allocation of already allocated devices
**WHEN** the batch allocation includes devices already allocated to a shop
**THEN** the system shall show warnings for those devices but proceed with allocating unallocated devices
**WHEN** the batch allocation includes devices already allocated to a shop **THEN** the system shall show warnings for those devices but proceed with allocating unallocated devices
### Requirement: Batch Device Recall
The system SHALL support batch recall of devices from shops with result tracking.
#### Scenario: Recall multiple devices
**WHEN** an administrator selects multiple devices, adds optional remarks, and confirms recall
**THEN** the system shall recall all selected devices from their shops and display success/failure results
**WHEN** an administrator selects multiple devices, adds optional remarks, and confirms recall **THEN** the system shall recall all selected devices from their shops and display success/failure results
#### Scenario: Show recall results
**WHEN** the batch recall completes
**THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
**WHEN** the batch recall completes **THEN** the system shall display: total count, success count, failure count, and detailed failure reasons for each failed device
#### Scenario: Prevent recall of unallocated devices
**WHEN** the batch recall includes devices not allocated to any shop
**THEN** the system shall show warnings for those devices but proceed with recalling allocated devices
**WHEN** the batch recall includes devices not allocated to any shop **THEN** the system shall show warnings for those devices but proceed with recalling allocated devices
### Requirement: Device Import via Object Storage
The system SHALL support CSV-based device import using a three-step object storage upload process.
#### Scenario: Import devices with three-step process
**WHEN** an administrator uploads a CSV file
**THEN** the system shall:
**WHEN** an administrator uploads a CSV file **THEN** the system shall:
1. Get upload URL from StorageService.getUploadUrl
2. Upload file to object storage using StorageService.uploadFile
3. Call DeviceService.importDevices with the file_key
**AND** display the task number for tracking
3. Call DeviceService.importDevices with the file_key **AND** display the task number for tracking
#### Scenario: Validate CSV format
**WHEN** the CSV file is uploaded
**THEN** the system shall validate the presence of required columns: device_no, device_name, device_model, device_type, max_sim_slots, manufacturer
**WHEN** the CSV file is uploaded **THEN** the system shall validate the presence of required columns: device_no, device_name, device_model, device_type, max_sim_slots, manufacturer
#### Scenario: Import devices with card bindings
**WHEN** the CSV includes iccid_1, iccid_2, iccid_3, iccid_4 columns
**THEN** the system shall attempt to bind the specified cards to slots 1-4 respectively during import
**WHEN** the CSV includes iccid_1, iccid_2, iccid_3, iccid_4 columns **THEN** the system shall attempt to bind the specified cards to slots 1-4 respectively during import
#### Scenario: Handle import errors
**WHEN** the import process encounters errors (duplicate device_no, invalid ICCID, etc.)
**THEN** the system shall record failed rows in the import task with detailed error messages
**WHEN** the import process encounters errors (duplicate device_no, invalid ICCID, etc.) **THEN** the system shall record failed rows in the import task with detailed error messages
### Requirement: Import Task Management
The system SHALL track device import tasks with detailed status and record information.
#### Scenario: List import tasks with type filter
**WHEN** an administrator views the task management page
**THEN** the system shall display both ICCID import tasks and Device import tasks with a type filter dropdown
**WHEN** an administrator views the task management page **THEN** the system shall display both ICCID import tasks and Device import tasks with a type filter dropdown
#### Scenario: View device import task details
**WHEN** an administrator clicks on a device import task
**THEN** the system shall display: task ID, status, total count, success count, failed count, skipped count, created time, completed time
**WHEN** an administrator clicks on a device import task **THEN** the system shall display: task ID, status, total count, success count, failed count, skipped count, created time, completed time
#### Scenario: View successful import records
**WHEN** viewing a device import task detail
**THEN** the system shall show a table of successfully imported devices with: device_no, device_name, bound ICCIDs
**WHEN** viewing a device import task detail **THEN** the system shall show a table of successfully imported devices with: device_no, device_name, bound ICCIDs
#### Scenario: View failed import records
**WHEN** viewing a device import task detail with failures
**THEN** the system shall show a table of failed records with: row number, device_no, failure reason
**WHEN** viewing a device import task detail with failures **THEN** the system shall show a table of failed records with: row number, device_no, failure reason
#### Scenario: Navigate from import page to task detail
**WHEN** a device import completes successfully
**THEN** the system shall display the task number with a link to view task details
**WHEN** a device import completes successfully **THEN** the system shall display the task number with a link to view task details
## MODIFIED Requirements
@@ -151,9 +151,9 @@ Task management SHALL support multiple task types including ICCID import and Dev
**Previous behavior**: Task management only supported ICCID import tasks.
#### Scenario: Filter tasks by type
**WHEN** an administrator selects a task type filter (ICCID Import / Device Import)
**THEN** the system shall display only tasks of the selected type
**WHEN** an administrator selects a task type filter (ICCID Import / Device Import) **THEN** the system shall display only tasks of the selected type
#### Scenario: Display task type badges
**WHEN** viewing the task list
**THEN** each task shall display a type badge indicating whether it's an ICCID import or Device import task
**WHEN** viewing the task list **THEN** each task shall display a type badge indicating whether it's an ICCID import or Device import task

View File

@@ -15,18 +15,21 @@
新增企业设备授权管理功能,包括:
### 类型定义
- 新增 `src/types/api/enterpriseDevice.ts` 文件
- 定义设备列表项、查询参数、分页结果类型
- 定义授权/撤销请求和响应类型
-`src/types/api/index.ts` 中导出新类型
### API 服务层
- 扩展 `EnterpriseService` 类,新增3个方法:
- `allocateDevices(enterpriseId, data)` - POST 授权设备
- `getEnterpriseDevices(enterpriseId, params)` - GET 设备列表
- `recallDevices(enterpriseId, data)` - POST 撤销授权
### 视图层
- 新增 `src/views/asset-management/enterprise-devices/index.vue` 页面
- 实现设备列表展示 (表格、分页、搜索)
- 实现授权设备对话框 (支持批量输入设备号)
@@ -34,10 +37,12 @@
- 实现操作结果展示 (成功/失败统计)
### 路由配置
-`src/router/routesAlias.ts` 添加路由别名
-`src/router/routes/asyncRoutes.ts` 的资产管理模块下添加子路由
### 国际化
-`src/locales/langs/zh.json``en.json` 添加中英文翻译
- 包含菜单、表单、表格、对话框、提示消息等所有文案

View File

@@ -12,9 +12,8 @@
#### Scenario: 定义企业设备列表项类型
**Given** 需要展示企业设备列表
**When** 定义 `EnterpriseDeviceItem` 接口
**Then** 接口必须包含以下字段:
**Given** 需要展示企业设备列表 **When** 定义 `EnterpriseDeviceItem` 接口 **Then** 接口必须包含以下字段:
- `device_id: number` - 设备ID
- `device_no: string` - 设备号
- `device_name: string` - 设备名称
@@ -24,52 +23,49 @@
#### Scenario: 定义设备列表查询参数
**Given** 需要查询和搜索企业设备
**When** 定义 `EnterpriseDeviceListParams` 接口
**Then** 接口必须包含以下可选字段:
**Given** 需要查询和搜索企业设备 **When** 定义 `EnterpriseDeviceListParams` 接口 **Then** 接口必须包含以下可选字段:
- `page?: number` - 页码
- `page_size?: number` - 每页数量
- `device_no?: string` - 设备号模糊搜索
#### Scenario: 定义授权设备请求类型
**Given** 需要授权设备给企业
**When** 定义 `AllocateDevicesRequest` 接口
**Then** 接口必须包含:
**Given** 需要授权设备给企业 **When** 定义 `AllocateDevicesRequest` 接口 **Then** 接口必须包含:
- `device_nos: string[]` - 设备号列表 (nullable, 最多100个)
- `remark?: string` - 授权备注
#### Scenario: 定义授权设备响应类型
**Given** 授权操作需要返回详细结果
**When** 定义 `AllocateDevicesResponse` 接口
**Then** 接口必须包含:
**Given** 授权操作需要返回详细结果 **When** 定义 `AllocateDevicesResponse` 接口 **Then** 接口必须包含:
- `success_count: number` - 成功数量
- `fail_count: number` - 失败数量
- `authorized_devices: AuthorizedDeviceItem[]` - 已授权设备列表 (nullable)
- `failed_items: FailedDeviceItem[]` - 失败项列表 (nullable)
**And** `AuthorizedDeviceItem` 包含:
- `device_id: number` - 设备ID
- `device_no: string` - 设备号
- `card_count: number` - 绑定卡数量
**And** `FailedDeviceItem` 包含:
- `device_no: string` - 设备号
- `reason: string` - 失败原因
#### Scenario: 定义撤销授权请求类型
**Given** 需要撤销设备授权
**When** 定义 `RecallDevicesRequest` 接口
**Then** 接口必须包含:
**Given** 需要撤销设备授权 **When** 定义 `RecallDevicesRequest` 接口 **Then** 接口必须包含:
- `device_nos: string[]` - 设备号列表 (nullable, 最多100个)
#### Scenario: 定义撤销授权响应类型
**Given** 撤销操作需要返回结果统计
**When** 定义 `RecallDevicesResponse` 接口
**Then** 接口必须包含:
**Given** 撤销操作需要返回结果统计 **When** 定义 `RecallDevicesResponse` 接口 **Then** 接口必须包含:
- `success_count: number` - 成功数量
- `fail_count: number` - 失败数量
- `failed_items: FailedDeviceItem[]` - 失败项列表 (nullable)
@@ -82,28 +78,15 @@
#### Scenario: 授权设备给企业
**Given** 运营人员需要授权设备给企业客户
**When** 调用 `EnterpriseService.allocateDevices(enterpriseId, data)`
**Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/allocate-devices`
**And** 请求体必须包含设备号列表和可选备注
**And** 返回授权结果,包含成功/失败统计和详细列表
**Given** 运营人员需要授权设备给企业客户 **When** 调用 `EnterpriseService.allocateDevices(enterpriseId, data)` **Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/allocate-devices` **And** 请求体必须包含设备号列表和可选备注 **And** 返回授权结果,包含成功/失败统计和详细列表
#### Scenario: 获取企业设备列表
**Given** 需要查看企业的设备列表
**When** 调用 `EnterpriseService.getEnterpriseDevices(enterpriseId, params)`
**Then** 必须发送 GET 请求到 `/api/admin/enterprises/{id}/devices`
**And** 支持分页参数 (page, page_size)
**And** 支持设备号模糊搜索
**And** 返回设备列表和总数
**Given** 需要查看企业的设备列表 **When** 调用 `EnterpriseService.getEnterpriseDevices(enterpriseId, params)` **Then** 必须发送 GET 请求到 `/api/admin/enterprises/{id}/devices` **And** 支持分页参数 (page, page_size) **And** 支持设备号模糊搜索 **And** 返回设备列表和总数
#### Scenario: 撤销设备授权
**Given** 需要撤销企业的设备授权
**When** 调用 `EnterpriseService.recallDevices(enterpriseId, data)`
**Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/recall-devices`
**And** 请求体必须包含设备号列表
**And** 返回撤销结果,包含成功/失败统计和失败原因
**Given** 需要撤销企业的设备授权 **When** 调用 `EnterpriseService.recallDevices(enterpriseId, data)` **Then** 必须发送 POST 请求到 `/api/admin/enterprises/{id}/recall-devices` **And** 请求体必须包含设备号列表 **And** 返回撤销结果,包含成功/失败统计和失败原因
---
@@ -113,10 +96,8 @@
#### Scenario: 显示企业设备列表
**Given** 用户访问企业设备列表页面
**When** 页面加载完成
**Then** 必须显示设备列表表格
**And** 表格必须包含以下列:
**Given** 用户访问企业设备列表页面 **When** 页面加载完成 **Then** 必须显示设备列表表格 **And** 表格必须包含以下列:
- 设备ID
- 设备号
- 设备名称
@@ -124,56 +105,32 @@
- 绑定卡数量
- 授权时间
**And** 必须支持分页功能
**And** 必须显示加载状态
**And** 必须支持分页功能 **And** 必须显示加载状态
#### Scenario: 搜索企业设备
**Given** 设备列表已加载
**When** 用户在搜索框输入设备号
**And** 点击搜索按钮
**Then** 必须根据设备号模糊查询设备
**And** 必须更新设备列表显示
**And** 必须重置到第一页
**Given** 设备列表已加载 **When** 用户在搜索框输入设备号 **And** 点击搜索按钮 **Then** 必须根据设备号模糊查询设备 **And** 必须更新设备列表显示 **And** 必须重置到第一页
#### Scenario: 授权设备对话框
**Given** 用户点击"授权设备"按钮
**When** 授权设备对话框打开
**Then** 必须显示设备号输入框 (textarea)
**And** 必须显示备注输入框 (可选)
**And** 必须提示支持的输入格式 (换行或逗号分隔)
**And** 必须提示最多100个设备号限制
**And** 必须有表单验证 (设备号必填)
**Given** 用户点击"授权设备"按钮 **When** 授权设备对话框打开 **Then** 必须显示设备号输入框 (textarea) **And** 必须显示备注输入框 (可选) **And** 必须提示支持的输入格式 (换行或逗号分隔) **And** 必须提示最多100个设备号限制 **And** 必须有表单验证 (设备号必填)
#### Scenario: 提交授权设备
**Given** 用户在对话框中输入了设备号列表
**When** 用户点击提交按钮
**Then** 必须解析设备号列表 (支持换行和逗号分隔)
**And** 必须去除空白字符和空行
**And** 必须验证设备号数量不超过100个
**And** 必须调用授权 API
**And** 必须显示加载状态
**And** 授权完成后必须展示结果:
**Given** 用户在对话框中输入了设备号列表 **When** 用户点击提交按钮 **Then** 必须解析设备号列表 (支持换行和逗号分隔) **And** 必须去除空白字符和空行 **And** 必须验证设备号数量不超过100个 **And** 必须调用授权 API **And** 必须显示加载状态 **And** 授权完成后必须展示结果:
- 成功数量
- 失败数量
- 失败设备列表及原因
**And** 如果有成功授权的设备,必须刷新设备列表
**And** 必须关闭对话框
**And** 如果有成功授权的设备,必须刷新设备列表 **And** 必须关闭对话框
#### Scenario: 撤销设备授权
**Given** 用户选中了要撤销的设备
**When** 用户点击"撤销授权"按钮
**Then** 必须显示二次确认对话框
**And** 确认对话框必须显示将要撤销的设备数量
**Given** 用户选中了要撤销的设备 **When** 用户点击"撤销授权"按钮 **Then** 必须显示二次确认对话框 **And** 确认对话框必须显示将要撤销的设备数量
**When** 用户确认撤销 **Then** 必须调用撤销 API **And** 必须显示加载状态 **And** 撤销完成后必须展示结果:
**When** 用户确认撤销
**Then** 必须调用撤销 API
**And** 必须显示加载状态
**And** 撤销完成后必须展示结果:
- 成功数量
- 失败数量
- 失败设备列表及原因
@@ -182,19 +139,11 @@
#### Scenario: 错误处理
**Given** API 调用可能失败
**When** API 返回错误
**Then** 必须显示友好的错误提示消息
**And** 必须在控制台记录错误详情
**And** 必须停止加载状态
**Given** API 调用可能失败 **When** API 返回错误 **Then** 必须显示友好的错误提示消息 **And** 必须在控制台记录错误详情 **And** 必须停止加载状态
#### Scenario: 分页切换
**Given** 设备列表超过一页
**When** 用户切换页码或每页数量
**Then** 必须保持当前的搜索条件
**And** 必须重新加载设备列表
**And** 必须显示加载状态
**Given** 设备列表超过一页 **When** 用户切换页码或每页数量 **Then** 必须保持当前的搜索条件 **And** 必须重新加载设备列表 **And** 必须显示加载状态
---
@@ -204,13 +153,8 @@
#### Scenario: 注册企业设备列表路由
**Given** 需要访问企业设备列表页面
**When** 配置路由
**Then** 必须在资产管理模块 (`/asset-management`) 下添加子路由
**And** 路由路径必须为 `enterprise-devices`
**And** 路由名称必须为 `EnterpriseDevices`
**And** 必须使用路由别名 `RoutesAlias.EnterpriseDevices`
**And** 必须配置 meta 信息:
**Given** 需要访问企业设备列表页面 **When** 配置路由 **Then** 必须在资产管理模块 (`/asset-management`) 下添加子路由 **And** 路由路径必须为 `enterprise-devices` **And** 路由名称必须为 `EnterpriseDevices` **And** 必须使用路由别名 `RoutesAlias.EnterpriseDevices` **And** 必须配置 meta 信息:
- `title: 'menus.assetManagement.enterpriseDevices'`
- `keepAlive: true`
@@ -222,9 +166,8 @@
#### Scenario: 中文翻译
**Given** 系统语言设置为中文
**When** 访问企业设备相关页面
**Then** 所有文本必须显示中文,包括:
**Given** 系统语言设置为中文 **When** 访问企业设备相关页面 **Then** 所有文本必须显示中文,包括:
- 菜单标题: "企业设备列表"
- 搜索表单标签和占位符
- 表格列名
@@ -234,9 +177,8 @@
#### Scenario: 英文翻译
**Given** 系统语言设置为英文
**When** 访问企业设备相关页面
**Then** 所有文本必须显示英文,包括:
**Given** 系统语言设置为英文 **When** 访问企业设备相关页面 **Then** 所有文本必须显示英文,包括:
- 菜单标题: "Enterprise Devices"
- 搜索表单标签和占位符
- 表格列名
@@ -252,37 +194,28 @@
#### Scenario: 批量输入设备号
**Given** 用户需要授权多个设备
**When** 在设备号输入框中输入
**Then** 必须支持以下输入方式:
**Given** 用户需要授权多个设备 **When** 在设备号输入框中输入 **Then** 必须支持以下输入方式:
- 每行一个设备号
- 逗号分隔的设备号
- 混合使用换行和逗号
**And** 系统必须能正确解析所有格式
**And** 必须自动去除首尾空白字符
**And** 必须过滤空行
**And** 系统必须能正确解析所有格式 **And** 必须自动去除首尾空白字符 **And** 必须过滤空行
#### Scenario: 操作结果展示
**Given** 批量操作完成
**When** 显示操作结果
**Then** 必须清晰展示:
**Given** 批量操作完成 **When** 显示操作结果 **Then** 必须清晰展示:
- 总共处理的数量
- 成功的数量
- 失败的数量
- 每个失败项的设备号和失败原因
**And** 如果全部成功,必须显示成功提示
**And** 如果部分失败,必须显示警告提示
**And** 如果全部失败,必须显示错误提示
**And** 如果全部成功,必须显示成功提示 **And** 如果部分失败,必须显示警告提示 **And** 如果全部失败,必须显示错误提示
#### Scenario: 表格列管理
**Given** 设备列表表格已显示
**When** 用户点击列管理按钮
**Then** 必须能够选择显示/隐藏的列
**And** 列配置必须被保存
**Given** 设备列表表格已显示 **When** 用户点击列管理按钮 **Then** 必须能够选择显示/隐藏的列 **And** 列配置必须被保存
## Related Specs

View File

@@ -9,6 +9,7 @@
### Phase 1: Type Definitions (Foundation)
1. **创建企业设备类型定义文件**
- 创建 `src/types/api/enterpriseDevice.ts`
- 定义 `EnterpriseDeviceItem` 接口 (设备列表项)
- 定义 `EnterpriseDeviceListParams` 接口 (查询参数)
@@ -38,6 +39,7 @@
### Phase 3: Internationalization
4. **添加中文翻译**
-`src/locales/langs/zh.json``menus.assetManagement` 下添加 `enterpriseDevices` 条目
-`src/locales/langs/zh.json` 添加 `enterpriseDevices` 模块的所有中文文案:
- 页面标题和搜索表单
@@ -55,6 +57,7 @@
### Phase 4: Routing
6. **添加路由别名**
-`src/router/routesAlias.ts` 添加 `EnterpriseDevices = '/asset-management/enterprise-devices'`
- **验证**: 确认导出正确
@@ -66,6 +69,7 @@
### Phase 5: UI Components
8. **创建企业设备列表页面**
- 创建 `src/views/asset-management/enterprise-devices/index.vue`
- 实现页面基础结构:
- 使用 `ArtTableFullScreen` 布局
@@ -75,6 +79,7 @@
- **验证**: 页面能正常渲染,无控制台错误
9. **实现设备列表查询功能**
- 实现 `loadDeviceList()` 方法调用 API
- 实现搜索和重置功能
- 实现分页功能
@@ -82,6 +87,7 @@
- **验证**: 能正确展示设备列表数据,分页工作正常
10. **实现授权设备对话框**
- 创建授权设备对话框
- 使用 `ElForm` + `ElInput` (textarea) 输入设备号列表
- 支持多行输入或逗号分隔
@@ -90,6 +96,7 @@
- **验证**: 对话框显示正常,表单验证工作
11. **实现授权设备提交逻辑**
- 实现 `handleAllocateDevices()` 方法
- 解析设备号列表 (处理换行和逗号分隔)
- 调用 `EnterpriseService.allocateDevices()` API
@@ -99,6 +106,7 @@
- **验证**: 能成功授权设备,正确处理部分成功/失败情况
12. **实现撤销授权对话框**
- 创建撤销授权对话框
- 使用表格多选模式选择要撤销的设备
- 或者使用输入框输入设备号列表
@@ -116,18 +124,21 @@
### Phase 6: Polish & Testing
14. **完善表格列配置**
- 配置表格列 (设备ID,设备号,设备名称,设备型号,绑定卡数量,授权时间)
- 实现列显示/隐藏功能
- 添加时间格式化
- **验证**: 表格数据展示完整美观
15. **添加错误处理**
- 为所有 API 调用添加 try-catch
- 添加友好的错误提示消息
- 处理网络错误和业务错误
- **验证**: 各种错误场景都有适当提示
16. **样式调整**
- 确保页面样式与系统其他页面一致
- 响应式布局适配
- 对话框尺寸和布局优化

View File

@@ -3,6 +3,7 @@
## Why
The IoT management platform currently lacks order management capabilities. Users need to:
- View and query orders created by customers (personal and agent)
- Track order payment status and details
- Create orders for single card or device purchases
@@ -20,6 +21,7 @@ This capability is essential for financial tracking, commission calculation, and
- **NEW**: Router configuration for order management module
The order management module will support:
- Listing orders with filters (payment status, order type, date range, order number)
- Viewing order details including buyer information, order items (packages), and payment details
- Creating orders for single card or device purchases with package selection

View File

@@ -5,12 +5,14 @@
实现完整的套餐管理系统包括4个核心模块。该系统需要支持多级代理商体系的套餐分配和定价管理。
**背景**
- 项目已有类型定义src/types/api/package.ts但使用不同的字段命名和枚举值
- 后端 API 已实现,使用下划线命名(如 `series_name`
- 前端项目统一使用 CommonStatus 枚举0:禁用, 1:启用)
- 参考实现:`/system/role` 页面使用了组件化架构
**约束**
- 必须保留现有类型定义文件,不能破坏现有代码
- 需要兼容后端 API 的字段命名规范
- 需要适配项目的状态枚举规范
@@ -18,6 +20,7 @@
## Goals / Non-Goals
### Goals
1. 实现4个核心模块的完整 CRUD 功能
2. 建立统一的 API 服务层,封装后端接口
3. 实现组件化的页面结构,参考 `/system/role`
@@ -25,6 +28,7 @@
5. 确保数据隔离和权限控制
### Non-Goals
1. 不重构现有的 package.ts 类型定义
2. 不实现套餐的实时统计和报表功能(后续迭代)
3. 不实现套餐批量导入功能(后续迭代)
@@ -37,16 +41,19 @@
**问题**后端使用下划线命名snake_case前端类型通常使用驼峰命名camelCase
**决策**
- API 请求/响应保持下划线命名,与后端保持一致
- 创建新的类型文件 `packageManagement.ts`,使用下划线命名
- 在表单提交和响应处理时不做转换,直接使用下划线字段
**理由**
- 减少转换层的复杂性和错误风险
- 与后端 API 文档保持一致,便于对照
- TypeScript 支持下划线字段名,不影响类型安全
**示例**
```typescript
export interface PackageSeriesResponse {
id: number
@@ -63,11 +70,13 @@ export interface PackageSeriesResponse {
**问题**:文档中状态是 `1:启用, 2:禁用`,但项目 CommonStatus 是 `0:禁用, 1:启用`
**决策**
- **在常量配置中定义套餐专用的状态枚举**
- **前端页面使用项目统一的 CommonStatus0/1**
- **在 API 服务层进行状态值映射转换**
**映射规则**
```typescript
// 前端 -> 后端
CommonStatus.ENABLED (1) -> API Status (1)
@@ -79,6 +88,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
```
**理由**
- 保持前端 UI 的一致性
- 避免混淆项目开发者
- 集中在 API 服务层处理差异
@@ -88,18 +98,21 @@ API Status (2) -> CommonStatus.DISABLED (0)
**问题**:是创建单个 package.ts 服务,还是拆分为多个服务文件?
**决策**拆分为4个独立的服务文件
1. `packageSeries.ts` - 套餐系列管理
2. `package.ts` - 套餐管理
3. `myPackage.ts` - 代理可售套餐
4. `shopPackageAllocation.ts` - 单套餐分配
**理由**
- 每个模块功能独立,职责清晰
- 便于维护和扩展
- 符合单一职责原则
- 便于团队协作(不同开发者负责不同模块)
**替代方案**
- 单个 package.ts 文件 - **拒绝**,文件过大,难以维护
### Decision 4: 定价规则实现
@@ -107,11 +120,13 @@ API Status (2) -> CommonStatus.DISABLED (0)
**问题**:代理商的套餐成本价有两种计算方式:系列加价和单套餐覆盖。
**决策**
- **后端负责成本价计算**,前端只展示结果
- 前端接收 `price_source` 字段,标识价格来源
- 单套餐分配创建时,保存 `calculated_cost_price`(系列规则计算的价格)供参考
**数据流**
```
1. 系列分配pricing_mode + pricing_value -> 后端计算 -> cost_price
2. 单套餐分配:直接设置 cost_price覆盖系列规则
@@ -119,6 +134,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
```
**理由**
- 计算逻辑复杂,集中在后端便于维护
- 前端只负责展示,降低复杂度
- 保留 calculated_cost_price 便于调试和审计
@@ -128,16 +144,19 @@ API Status (2) -> CommonStatus.DISABLED (0)
**问题**:客户端验证 vs 服务端验证。
**决策****双重验证**
- 客户端:使用 Element Plus 的 FormRules 进行基础验证
- 服务端:后端 API 进行完整验证并返回详细错误
**客户端验证规则**
- 必填字段检查
- 长度限制(如系列名称 1-255 字符)
- 数值范围(如套餐时长 1-120 月)
- 格式验证(如价格必须为正整数)
**理由**
- 客户端验证提升用户体验,即时反馈
- 服务端验证保证数据安全性和完整性
- 符合 Web 应用最佳实践
@@ -147,20 +166,26 @@ API Status (2) -> CommonStatus.DISABLED (0)
**问题**:页面结构如何组织?
**决策**:参考 `/system/role` 页面,使用组件化结构:
```vue
<template>
<ArtTableFullScreen>
<ArtSearchBar /> <!-- 搜索栏 -->
<ArtSearchBar />
<!-- 搜索栏 -->
<ElCard>
<ArtTableHeader /> <!-- 表格头部刷新列设置操作按钮 -->
<ArtTable /> <!-- 数据表格 -->
<ElDialog /> <!-- 新增/编辑对话框 -->
<ArtTableHeader />
<!-- 表格头部刷新列设置操作按钮 -->
<ArtTable />
<!-- 数据表格 -->
<ElDialog />
<!-- 新增/编辑对话框 -->
</ElCard>
</ArtTableFullScreen>
</template>
```
**理由**
- 与项目现有页面风格一致
- 复用成熟的组件,减少开发工作量
- 便于维护和扩展
@@ -172,6 +197,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
**风险**:后端接口可能尚未实现或与文档不一致。
**缓解措施**
1. 先实现 API 服务层,使用 TypeScript 类型约束
2. 使用 Mock 数据进行前端开发(已有示例)
3. 与后端团队确认 API 规范和联调时间
@@ -182,6 +208,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
**风险**:在某些地方忘记转换状态值,导致显示错误。
**缓解措施**
1. 在 API 服务层统一处理转换
2. 创建工具函数封装映射逻辑
3. 编写单元测试覆盖映射函数
@@ -192,6 +219,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
**风险**:对定价规则的理解与实际业务需求有偏差。
**缓解措施**
1. 在实现前与产品确认定价规则
2. 编写测试用例覆盖各种定价场景
3. 在 UI 上清晰展示价格来源和计算方式
@@ -202,10 +230,12 @@ API Status (2) -> CommonStatus.DISABLED (0)
**取舍**:保留旧的 package.ts 类型定义,新增 packageManagement.ts。
**代价**
- 存在两套类型定义,可能造成混淆
- 占用额外的代码空间
**收益**
- 不影响现有代码,向后兼容
- 新旧系统可以并存,降低迁移风险
- 未来可以逐步迁移到新类型
@@ -215,10 +245,12 @@ API Status (2) -> CommonStatus.DISABLED (0)
**取舍**:在 API 服务层进行状态值转换。
**代价**
- 增加一层转换逻辑
- 可能影响性能(微小)
**收益**
- 前端 UI 保持一致性
- 业务逻辑更清晰
- 便于后续维护
@@ -226,27 +258,32 @@ API Status (2) -> CommonStatus.DISABLED (0)
## Migration Plan
### Phase 1: 基础设施1-2天
1. 创建类型定义文件
2. 创建常量配置文件
3. 设置状态映射工具函数
### Phase 2: API 服务层2-3天
1. 实现4个 API 服务模块
2. 编写单元测试(可选)
3. 使用 Mock 数据测试
### Phase 3: 页面实现4-5天
1. 套餐系列管理页面1天
2. 套餐管理页面1.5天)
3. 代理可售套餐页面1天
4. 单套餐分配页面1.5天)
### Phase 4: 集成测试1-2天
1. 与后端 API 联调
2. 端到端功能测试
3. 修复 Bug 和优化
### Phase 5: 上线1天
1. Code Review
2. 合并代码
3. 部署到测试环境
@@ -257,6 +294,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
### Rollback Plan
如果出现严重问题,回滚步骤:
1. 从 Git 回滚到上一个稳定版本
2. 移除新增的路由配置
3. 移除新增的 API 服务导出
@@ -267,6 +305,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
**问题**:如何统一处理各类错误和异常?
**决策**:分层错误处理机制
- **网络错误**axios 拦截器统一捕获,显示通用错误提示
- **401 未认证**:自动跳转到登录页面
- **403 无权限**:显示权限不足提示,不跳转
@@ -274,6 +313,7 @@ API Status (2) -> CommonStatus.DISABLED (0)
- **表单验证错误**:在表单字段下显示错误提示
**错误提示方式**
```typescript
// 网络错误或服务器错误
ElMessage.error('网络错误,请稍后重试')
@@ -286,6 +326,7 @@ ElMessage.success('操作成功')
```
**理由**
- 统一的错误处理提升用户体验
- 分层处理避免重复代码
- 清晰的错误提示帮助用户理解问题
@@ -297,6 +338,7 @@ ElMessage.success('操作成功')
**决策**:细粒度的 loading 状态管理
**Loading 状态分类**
```typescript
const loading = ref(false) // 表格数据加载
const submitLoading = ref(false) // 表单提交
@@ -304,29 +346,26 @@ const deleteLoading = ref<Record<number, boolean>>({}) // 删除操作(可选
```
**状态管理规则**
- **列表查询**:表格显示 loading 遮罩
- **新增/编辑提交**:提交按钮显示 loading禁用表单
- **删除操作**:可选择在按钮上显示 loading 或全局 loading
- **状态切换**ElSwitch 自带 loading 效果,先更新 UI 再调用 API
**理由**
- 细粒度控制提供更好的交互反馈
- 防止重复提交
- 清晰标识正在进行的操作
## Open Questions
1. **Q**: 套餐被删除后,历史订单如何处理?
**A**: 待产品确认,可能需要软删除机制
1. **Q**: 套餐被删除后,历史订单如何处理? **A**: 待产品确认,可能需要软删除机制
2. **Q**: 代理商可以自行调整套餐售价吗?
**A**: 待产品确认,当前设计只展示建议售价
2. **Q**: 代理商可以自行调整套餐售价吗? **A**: 待产品确认,当前设计只展示建议售价
3. **Q**: 套餐系列和套餐是否支持批量操作(批量启用/禁用)?
**A**: 当前不支持,后续迭代考虑
3. **Q**: 套餐系列和套餐是否支持批量操作(批量启用/禁用)? **A**: 当前不支持,后续迭代考虑
4. **Q**: 是否需要套餐变更历史记录?
**A**: 后端可能有审计日志,前端暂不展示
4. **Q**: 是否需要套餐变更历史记录? **A**: 后端可能有审计日志,前端暂不展示
5. **Q**: 单套餐分配的"原计算成本价"是否需要实时更新?
**A**: 待确认,当前设计是创建时计算一次,不自动更新
5. **Q**: 单套餐分配的"原计算成本价"是否需要实时更新? **A**: 待确认,当前设计是创建时计算一次,不自动更新

View File

@@ -18,6 +18,7 @@
采用模块化设计,拆分为 4 个独立的 API 服务文件:
- **新增**: `src/api/modules/packageSeries.ts` - 套餐系列 API 服务
- 套餐系列列表查询(分页、筛选)
- 创建套餐系列
- 获取套餐系列详情
@@ -26,6 +27,7 @@
- 更新套餐系列状态
- **新增**: `src/api/modules/package.ts` - 套餐管理 API 服务
- 套餐列表查询(分页、多条件筛选)
- 创建套餐
- 获取套餐详情
@@ -36,11 +38,13 @@
- 获取系列下拉选项(用于表单选择)
- **新增**: `src/api/modules/myPackage.ts` - 代理可售套餐 API 服务
- 我的可售套餐列表查询
- 获取可售套餐详情
- 我的被分配系列列表
- **新增**: `src/api/modules/shopPackageAllocation.ts` - 单套餐分配 API 服务
- 单套餐分配列表查询
- 创建单套餐分配
- 获取单套餐分配详情
@@ -64,12 +68,14 @@
### 3. 页面实现
**套餐系列管理** (`src/views/package-management/package-series/index.vue`)
- 列表展示(支持名称搜索、状态筛选)
- 新增/编辑套餐系列
- 删除套餐系列
- 状态开关(启用/禁用)
**套餐管理** (`src/views/package-management/package-list/index.vue`)
- 列表展示(支持多条件筛选:名称、系列、状态、上架状态、套餐类型)
- 新增/编辑套餐
- 删除套餐
@@ -77,11 +83,13 @@
- 上架状态开关(上架/下架)
**代理可售套餐** (`src/views/package-management/my-packages/index.vue`)
- 查看被分配的套餐列表(支持系列、类型筛选)
- 查看套餐详情(成本价、建议售价、利润空间等)
- 查看被分配系列列表
**单套餐分配** (`src/views/package-management/package-assign/index.vue`)
- 分配列表(支持店铺、套餐、状态筛选)
- 创建分配(选择套餐、店铺、设置成本价)
- 编辑分配(修改成本价)
@@ -100,12 +108,15 @@
### 5. 路由配置
已存在的路由(无需修改):
- `/package-management/package-series` - 套餐系列管理
- `/package-management/package-list` - 套餐管理
- `/package-management/package-assign` - 单套餐分配
需要新增的路由:
- **新增**: `src/router/routesAlias.ts` - 添加路由别名
- `MyPackages = '/package-management/my-packages'` - 代理可售套餐
- **新增**: `src/router/routes/asyncRoutes.ts` - 添加路由配置
@@ -114,12 +125,14 @@
## Impact
### 受影响的规范
- `package-series-management` - 新增能力
- `package-management` - 新增能力
- `my-packages` - 新增能力
- `shop-package-allocation` - 新增能力
### 受影响的代码
- `src/api/modules/*` - 新增 4 个 API 服务模块
- `src/types/api/*` - 新增类型定义文件
- `src/views/package-management/*` - 4 个页面完整实现
@@ -127,6 +140,7 @@
- `src/router/routes/asyncRoutes.ts` - 路由配置
### 依赖关系
- 依赖现有的组件库ArtTable、ArtSearchBar、ArtTableHeader 等)
- 依赖现有的 HTTP 请求工具request.ts
- 依赖现有的权限控制和路由守卫
@@ -134,10 +148,12 @@
- 后端 API 需已实现docs/套餐.md 中定义的接口)
**注意事项**
- ShopService 应该已经存在于 src/api/modules/shop.ts
- 如果不存在,需要先实现或使用 Mock 数据
### 风险评估
- **低风险**: 独立模块,不影响现有功能
- **API 依赖**: 需确保后端接口已实现并联调
- **权限控制**: 需配置对应的菜单和按钮权限

View File

@@ -9,6 +9,7 @@
## 2. API 服务层实现
### 2.1 套餐系列 APIpackageSeries.ts
- [ ] 2.1.1 实现 getPackageSeries套餐系列列表
- [ ] 2.1.2 实现 createPackageSeries创建套餐系列
- [ ] 2.1.3 实现 getPackageSeriesDetail获取套餐系列详情
@@ -17,6 +18,7 @@
- [ ] 2.1.6 实现 updatePackageSeriesStatus更新套餐系列状态
### 2.2 套餐管理 APIpackage.ts
- [ ] 2.2.1 实现 getPackages套餐列表
- [ ] 2.2.2 实现 createPackage创建套餐
- [ ] 2.2.3 实现 getPackageDetail获取套餐详情
@@ -26,11 +28,13 @@
- [ ] 2.2.7 实现 updatePackageShelfStatus更新套餐上架状态
### 2.3 代理可售套餐 APImyPackage.ts
- [ ] 2.3.1 实现 getMyPackages我的可售套餐列表
- [ ] 2.3.2 实现 getMyPackageDetail获取可售套餐详情
- [ ] 2.3.3 实现 getMySeriesAllocations我的被分配系列列表
### 2.4 单套餐分配 APIshopPackageAllocation.ts
- [ ] 2.4.1 实现 getShopPackageAllocations单套餐分配列表
- [ ] 2.4.2 实现 createShopPackageAllocation创建单套餐分配
- [ ] 2.4.3 实现 getShopPackageAllocationDetail获取单套餐分配详情
@@ -43,6 +47,7 @@
## 3. 页面实现
### 3.1 套餐系列管理页面package-series/index.vue
- [ ] 3.1.1 实现列表展示(表格、分页)
- [ ] 3.1.2 实现搜索栏(系列名称、状态筛选)
- [ ] 3.1.3 实现新增对话框(表单验证)
@@ -52,6 +57,7 @@
- [ ] 3.1.7 集成 API 服务并处理加载状态
### 3.2 套餐管理页面package-list/index.vue
- [ ] 3.2.1 实现列表展示(表格、分页)
- [ ] 3.2.2 实现搜索栏(名称、系列、状态、上架状态、类型筛选)
- [ ] 3.2.3 实现系列下拉选择器(加载套餐系列列表,只显示启用状态)
@@ -63,6 +69,7 @@
- [ ] 3.2.9 集成 API 服务并处理加载状态
### 3.3 代理可售套餐页面my-packages/index.vue
- [ ] 3.3.1 创建页面文件和基本结构
- [ ] 3.3.2 实现列表展示(表格、分页)
- [ ] 3.3.3 实现搜索栏(系列、类型筛选)
@@ -71,6 +78,7 @@
- [ ] 3.3.6 集成 API 服务并处理加载状态
### 3.4 单套餐分配页面package-assign/index.vue
- [ ] 3.4.1 创建页面文件和基本结构
- [ ] 3.4.2 实现列表展示(表格、分页)
- [ ] 3.4.3 实现搜索栏(店铺、套餐、状态筛选)
@@ -90,6 +98,7 @@
## 5. 集成测试
### 5.1 套餐系列管理测试
- [ ] 5.1.1 测试列表查询(空列表、有数据、分页)
- [ ] 5.1.2 测试搜索功能(名称模糊搜索、状态筛选)
- [ ] 5.1.3 测试新增功能(成功、编码重复、字段验证)
@@ -99,6 +108,7 @@
- [ ] 5.1.7 测试权限控制(未登录、无权限)
### 5.2 套餐管理测试
- [ ] 5.2.1 测试列表查询(空列表、有数据、分页)
- [ ] 5.2.2 测试多条件筛选(名称、系列、状态、上架状态、类型)
- [ ] 5.2.3 测试系列下拉选择器(只显示启用状态的系列)
@@ -110,6 +120,7 @@
- [ ] 5.2.9 测试权限控制(未登录、无权限)
### 5.3 代理可售套餐测试
- [ ] 5.3.1 测试列表查询(空列表、有数据、分页)
- [ ] 5.3.2 测试筛选功能(按系列、按类型)
- [ ] 5.3.3 测试详情查询(显示成本价、建议售价、利润空间、价格来源)
@@ -118,6 +129,7 @@
- [ ] 5.3.6 测试权限控制(非代理商用户无法访问)
### 5.4 单套餐分配测试
- [ ] 5.4.1 测试列表查询(空列表、有数据、分页)
- [ ] 5.4.2 测试筛选功能(按店铺、按套餐、按状态)
- [ ] 5.4.3 测试套餐下拉选择器(只显示启用且上架的套餐)
@@ -130,6 +142,7 @@
- [ ] 5.4.10 测试权限控制(仅管理员可操作)
### 5.5 通用功能测试
- [ ] 5.5.1 测试所有页面的表单验证(必填、长度、格式)
- [ ] 5.5.2 测试所有页面的 loading 状态(列表、提交、删除)
- [ ] 5.5.3 测试所有页面的错误处理(网络错误、业务错误)

View File

@@ -2,12 +2,14 @@
## Context
当前系统使用简单的定价模式(pricing_mode/pricing_value)和一次性佣金(one_time_commission_*)来管理套餐系列分配。随着业务发展,需要更复杂的佣金系统:
当前系统使用简单的定价模式(pricing*mode/pricing_value)和一次性佣金(one_time_commission*\*)来管理套餐系列分配。随着业务发展,需要更复杂的佣金系统:
- 支持基础返佣(固定金额或百分比)
- 支持梯度返佣(根据销量或销售额分档返佣)
- 更清晰的数据模型和API接口
**背景约束**:
- 前后端需要同步部署(Breaking Change)
- 需要数据迁移方案
- 影响现有的套餐系列分配功能
@@ -15,12 +17,14 @@
## Goals / Non-Goals
**Goals**:
- 实现新的佣金配置模型,支持基础返佣和梯度返佣
- 提供清晰的UI界面让用户配置复杂的返佣规则
- 保证数据一致性和类型安全
- 提供良好的用户体验(表单验证、错误提示)
**Non-Goals**:
- 不处理历史数据的完整性验证(由后端负责)
- 不实现佣金计算逻辑(由后端负责)
- 不处理佣金结算流程(属于其他模块)
@@ -32,11 +36,13 @@
**决策**: 采用嵌套对象结构表示佣金配置
**理由**:
- `base_commission: { mode, value }` - 清晰表达基础返佣的两个维度
- `tier_config: { period_type, tier_type, tiers[] }` - 梯度配置与基础配置分离,可选性强
- `tiers: [{ threshold, mode, value }]` - 每个档位都有独立的返佣模式和值
**替代方案考虑**:
- ❌ 平铺所有字段 - 会导致字段过多,语义不清晰
- ❌ 使用JSON字符串存储配置 - 失去类型安全,不利于表单编辑
@@ -45,6 +51,7 @@
**决策**: 使用渐进式表单设计
**表单结构**:
```
1. 基础返佣配置 (必填)
- 返佣模式: 单选 (固定金额/百分比)
@@ -63,11 +70,13 @@
```
**理由**:
- 渐进式设计降低初始复杂度
- 只有启用梯度返佣时才显示相关配置
- 动态档位列表提供灵活性
**替代方案考虑**:
- ❌ 全部平铺展示 - 对不需要梯度返佣的用户造成困扰
- ❌ 使用向导模式 - 增加操作步骤,不适合编辑场景
@@ -76,7 +85,9 @@
**决策**: 分层验证 + 条件验证
**验证规则**:
1. 基础返佣配置:
- mode: 必选
- value: 必填,>= 0
@@ -91,6 +102,7 @@
- 档位阈值必须递增
**实现方式**:
- 使用 Element Plus 的表单验证
- 自定义validator处理档位阈值递增验证
- 使用 computed 动态生成验证规则
@@ -100,11 +112,13 @@
**决策**: 统一使用 `list` 字段名,添加适配层
**理由**:
- 后端统一规范使用 `list` 而非 `items`
- 添加 `total_pages` 字段提供更完整的分页信息
- 保持前端代码与后端规范一致
**迁移策略**:
```typescript
// Before
const res = await getShopSeriesAllocations(params)
@@ -122,6 +136,7 @@ allocationList.value = res.data.list || []
**风险**: 前后端必须同时部署,否则会出现接口不兼容
**缓解措施**:
- 与后端团队协调部署窗口
- 准备回退方案(前端代码分支)
- 在测试环境充分验证
@@ -131,6 +146,7 @@ allocationList.value = res.data.list || []
**风险**: 梯度返佣配置较复杂,用户可能不理解
**缓解措施**:
- 提供清晰的字段说明
- 添加示例或帮助文档链接
- 使用默认值简化初次配置
@@ -140,6 +156,7 @@ allocationList.value = res.data.list || []
**风险**: 旧数据无法完全转换为新模型
**缓解措施**:
- 要求后端提供数据迁移脚本和验证
- 在迁移后检查数据完整性
- 保留旧数据备份
@@ -147,22 +164,26 @@ allocationList.value = res.data.list || []
## Migration Plan
### Phase 1: 准备阶段
1. 与后端确认API变更细节和时间表
2. 在开发环境实现前端变更
3. 与后端在测试环境联调
### Phase 2: 测试阶段
1. 功能测试: 创建、编辑、删除、列表展示
2. 集成测试: 与后端API集成
3. 用户验收测试: 业务人员验证
### Phase 3: 部署阶段
1. 准备回退方案
2. 与后端协调部署窗口
3. 同步部署前后端
4. 验证生产环境功能
### Phase 4: 监控阶段
1. 监控API错误率
2. 收集用户反馈
3. 修复遗留问题
@@ -170,6 +191,7 @@ allocationList.value = res.data.list || []
### Rollback Plan
如果部署后发现严重问题:
1. 前端回退到上一版本
2. 后端回退API(如果可能)
3. 通知用户暂时不可用
@@ -178,12 +200,15 @@ allocationList.value = res.data.list || []
## Open Questions
1. **Q**: 梯度返佣的档位数量是否有上限?
- **A**: 待后端确认,前端可以先不限制或设置合理上限(如10个)
2. **Q**: 返佣值的单位和精度如何处理?
- **A**: 固定金额使用"分"为单位,百分比使用千分比(如200=20%),待后端确认
3. **Q**: 是否需要在列表页显示梯度返佣信息?
- **A**: 暂时只显示基础返佣,梯度信息在详情或编辑时查看
4. **Q**: 旧数据如何映射到新模型?

View File

@@ -3,6 +3,7 @@
## Why
当前套餐系列分配使用简单的定价模式(固定加价/百分比加价)和一次性佣金配置,不支持复杂的返佣规则。新的业务需求要求支持:
- 基础返佣配置(固定金额或百分比)
- 梯度返佣系统(根据销量或销售额分档返佣)
- 更灵活的佣金计算模型
@@ -32,12 +33,14 @@
## Breaking Changes
1. **API响应结构变更**:
- 列表接口响应从 `{ items, page, page_size, total }` 改为 `{ list, page, page_size, total, total_pages }`
- 移除 `pricing_mode`, `pricing_value`, `calculated_cost_price` 字段
- 移除一次性佣金相关字段
- 新增 `base_commission`, `enable_tier_commission` 字段
2. **API请求结构变更**:
- 创建/更新接口需要新的 `base_commission` 对象
- 支持可选的 `enable_tier_commission``tier_config`

View File

@@ -129,15 +129,17 @@
**Reason**: 旧的定价模式 (pricing_mode, pricing_value) 已被新的佣金模型替代
**Migration**: 旧数据通过后端迁移脚本转换为基础返佣配置:
- pricing_mode="fixed" → base_commission.mode="fixed"
- pricing_mode="percentage" → base_commission.mode="percent"
- pricing_value → base_commission.value (需要单位转换)
### Requirement: 一次性佣金配置
**Reason**: 一次性佣金配置 (one_time_commission_*) 已被梯度返佣系统替代
**Reason**: 一次性佣金配置 (one*time_commission*\*) 已被梯度返佣系统替代
**Migration**: 旧的一次性佣金通过以下方式迁移:
- 如果设置了一次性佣金,转换为单档位的梯度返佣
- trigger → tier_type mapping (first_activation → sales_count, cumulative_recharge → sales_amount)
- threshold → tiers[0].threshold
@@ -165,7 +167,7 @@
- **WHEN** base_commission.mode = "percent"
- **THEN** base_commission.value 表示返佣百分比的千分比 (如200表示20%)
- **AND** 每笔交易返佣 = 交易金额 * (value / 1000)
- **AND** 每笔交易返佣 = 交易金额 \* (value / 1000)
### Requirement: 梯度返佣配置

View File

@@ -43,8 +43,9 @@
try {
const res = await UserService.getUserInfo()
if (res.code === ApiStatus.success && res.data) {
// API 返回的是 { user, permissions },我们需要保存 user
// API 返回的是 { user, permissions },我们需要保存 user 和 permissions
userStore.setUserInfo(res.data.user)
userStore.setPermissions(res.data.permissions || [])
}
} catch (error) {
console.error('获取用户信息失败:', error)

View File

@@ -38,9 +38,6 @@ export class AuthorizationService extends BaseService {
id: number,
data: UpdateAuthorizationRemarkRequest
): Promise<BaseResponse<AuthorizationItem>> {
return this.put<BaseResponse<AuthorizationItem>>(
`/api/admin/authorizations/${id}/remark`,
data
)
return this.put<BaseResponse<AuthorizationItem>>(`/api/admin/authorizations/${id}/remark`, data)
}
}

View File

@@ -77,10 +77,7 @@ export class DeviceService extends BaseService {
id: number,
data: BindCardToDeviceRequest
): Promise<BaseResponse<BindCardToDeviceResponse>> {
return this.post<BaseResponse<BindCardToDeviceResponse>>(
`/api/admin/devices/${id}/cards`,
data
)
return this.post<BaseResponse<BindCardToDeviceResponse>>(`/api/admin/devices/${id}/cards`, data)
}
/**
@@ -106,19 +103,14 @@ export class DeviceService extends BaseService {
static allocateDevices(
data: AllocateDevicesRequest
): Promise<BaseResponse<AllocateDevicesResponse>> {
return this.post<BaseResponse<AllocateDevicesResponse>>(
'/api/admin/devices/allocate',
data
)
return this.post<BaseResponse<AllocateDevicesResponse>>('/api/admin/devices/allocate', data)
}
/**
* 批量回收设备
* @param data 回收参数
*/
static recallDevices(
data: RecallDevicesRequest
): Promise<BaseResponse<RecallDevicesResponse>> {
static recallDevices(data: RecallDevicesRequest): Promise<BaseResponse<RecallDevicesResponse>> {
return this.post<BaseResponse<RecallDevicesResponse>>('/api/admin/devices/recall', data)
}
@@ -128,9 +120,7 @@ export class DeviceService extends BaseService {
* 批量导入设备
* @param data 导入参数
*/
static importDevices(
data: ImportDeviceRequest
): Promise<BaseResponse<ImportDeviceResponse>> {
static importDevices(data: ImportDeviceRequest): Promise<BaseResponse<ImportDeviceResponse>> {
return this.post<BaseResponse<ImportDeviceResponse>>('/api/admin/devices/import', data)
}

View File

@@ -135,9 +135,7 @@ export class EnterpriseService extends BaseService {
* @param cardId 卡ID
*/
static resumeCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`
)
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`)
}
/**
@@ -146,9 +144,7 @@ export class EnterpriseService extends BaseService {
* @param cardId 卡ID
*/
static suspendCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`
)
return this.post<BaseResponse>(`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`)
}
/**

View File

@@ -87,5 +87,4 @@ export class PackageManageService extends BaseService {
const data: UpdatePackageShelfStatusRequest = { shelf_status }
return this.patch<BaseResponse>(`/api/admin/packages/${id}/shelf`, data)
}
}

View File

@@ -73,10 +73,7 @@ export class PackageSeriesService extends BaseService {
* @param id 系列ID
* @param status 状态 (1:启用, 2:禁用)
*/
static updatePackageSeriesStatus(
id: number,
status: number
): Promise<BaseResponse> {
static updatePackageSeriesStatus(id: number, status: number): Promise<BaseResponse> {
const data: UpdatePackageSeriesStatusRequest = { status }
return this.put<BaseResponse>(`/api/admin/package-series/${id}/status`, data)
}

View File

@@ -36,10 +36,7 @@ export class ShopPackageAllocationService extends BaseService {
static createShopPackageAllocation(
data: CreateShopPackageAllocationRequest
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.create<ShopPackageAllocationResponse>(
'/api/admin/shop-package-allocations',
data
)
return this.create<ShopPackageAllocationResponse>('/api/admin/shop-package-allocations', data)
}
/**
@@ -50,9 +47,7 @@ export class ShopPackageAllocationService extends BaseService {
static getShopPackageAllocationDetail(
id: number
): Promise<BaseResponse<ShopPackageAllocationResponse>> {
return this.getOne<ShopPackageAllocationResponse>(
`/api/admin/shop-package-allocations/${id}`
)
return this.getOne<ShopPackageAllocationResponse>(`/api/admin/shop-package-allocations/${id}`)
}
/**

View File

@@ -22,10 +22,7 @@ export class ShopSeriesAllocationService extends BaseService {
static getShopSeriesAllocations(
params?: ShopSeriesAllocationQueryParams
): Promise<PaginationResponse<ShopSeriesAllocationResponse>> {
return this.getPage<ShopSeriesAllocationResponse>(
'/api/admin/shop-series-allocations',
params
)
return this.getPage<ShopSeriesAllocationResponse>('/api/admin/shop-series-allocations', params)
}
/**

View File

@@ -2,7 +2,15 @@
// 强制所有元素使用小米字体
* {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
.btn-icon {

View File

@@ -10,7 +10,9 @@
// 按钮粗度
--el-font-weight-primary: 400 !important;
// Element Plus 全局字体
--el-font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
--el-font-family:
'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif !important;
--el-component-custom-height: 36px !important;
@@ -182,7 +184,15 @@
// 修改el-button样式
.el-button {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
&.el-button--text {
background-color: transparent !important;
@@ -202,7 +212,15 @@
border-radius: 6px !important;
font-weight: bold;
transition: all 0s !important;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
// 为所有 Element Plus 组件添加小米字体
@@ -227,7 +245,15 @@
.el-upload,
.el-card,
.el-divider {
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif !important;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif !important;
}
.el-checkbox-group {

View File

@@ -34,7 +34,15 @@ h5 {
body {
color: var(--art-text-gray-700);
text-align: left;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
'MiSans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
}
select {

Binary file not shown.

View File

@@ -252,10 +252,7 @@ export const PRICE_SOURCE_MAP = PRICE_SOURCE_OPTIONS.reduce(
map[item.value] = item
return map
},
{} as Record<
PriceSource,
{ label: string; value: PriceSource; type: 'primary' | 'warning' }
>
{} as Record<PriceSource, { label: string; value: PriceSource; type: 'primary' | 'warning' }>
)
/**

View File

@@ -1,17 +1,41 @@
import { router } from '@/router'
import { App, Directive } from 'vue'
import { App, Directive, DirectiveBinding } from 'vue'
import { useUserStore } from '@/store/modules/user'
/**
* 权限指令(后端控制模式可用)
* 权限指令
* 用法:
* v-permission="'menu:get'" - 单个权限
* v-permission="['menu:get', 'role:get']" - 多个权限(满足任意一个即可)
* v-permission:all="['menu:get', 'role:get']" - 多个权限(需要全部满足)
*
* 兼容旧的v-auth指令:
* <el-button v-auth="'add'">按钮</el-button>
*/
const authDirective: Directive = {
const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
const authList = (router.currentRoute.value.meta.authList as Array<{ auth_mark: string }>) || []
const userStore = useUserStore()
const { value, arg } = binding
const hasPermission = authList.some((item) => item.auth_mark === binding.value)
// 如果没有值,直接返回
if (!value) return
let hasPermission = false
if (typeof value === 'string') {
// 单个权限
hasPermission = userStore.hasPermission(value)
} else if (Array.isArray(value)) {
// 多个权限
if (arg === 'all') {
// 需要全部满足
hasPermission = userStore.hasAllPermissions(value)
} else {
// 满足任意一个即可
hasPermission = userStore.hasAnyPermission(value)
}
}
// 如果没有权限,移除元素
if (!hasPermission) {
el.parentNode?.removeChild(el)
}
@@ -19,5 +43,7 @@ const authDirective: Directive = {
}
export function setupPermissionDirective(app: App) {
app.directive('auth', authDirective)
// 注册为 v-permission 和 v-auth (向后兼容)
app.directive('permission', permissionDirective)
app.directive('auth', permissionDirective)
}

View File

@@ -442,7 +442,6 @@
"standaloneCardList": "IoT卡管理",
"iotCardTask": "IoT卡任务",
"deviceTask": "设备任务",
"taskDetail": "任务详情",
"devices": "设备管理",
"deviceDetail": "设备详情",
"assetAssign": "分配记录",
@@ -473,13 +472,6 @@
"paymentMerchant": "支付商户",
"developerApi": "开发者API",
"commissionTemplate": "分佣模板"
},
"batch": {
"title": "批量操作",
"simImport": "网卡导入",
"deviceImport": "设备导入",
"offlineBatchRecharge": "线下批量充值",
"cardChangeNotice": "换卡通知"
}
},
"table": {

View File

@@ -890,24 +890,6 @@ export const asyncRoutes: AppRouteRecord[] = [
icon: '&#xe816;'
},
children: [
{
path: 'iot-card-query',
name: 'CardSearch',
component: RoutesAlias.CardSearch,
meta: {
title: 'menus.assetManagement.cardSearch',
keepAlive: true
}
},
{
path: 'device-search',
name: 'DeviceSearch',
component: RoutesAlias.DeviceSearch,
meta: {
title: 'menus.assetManagement.deviceSearch',
keepAlive: true
}
},
{
path: 'iot-card-management',
name: 'StandaloneCardList',
@@ -935,16 +917,6 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true
}
},
{
path: 'task-detail',
name: 'TaskDetail',
component: RoutesAlias.TaskDetail,
meta: {
title: 'menus.assetManagement.taskDetail',
isHide: true,
keepAlive: false
}
},
{
path: 'devices',
name: 'DeviceList',
@@ -1129,7 +1101,7 @@ export const asyncRoutes: AppRouteRecord[] = [
]
}
]
},
}
// {
// path: '/settings',
// name: 'Settings',
@@ -1167,52 +1139,5 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// }
// ]
// },
{
path: '/batch',
name: 'Batch',
component: RoutesAlias.Home,
meta: {
title: 'menus.batch.title',
icon: '&#xe820;'
},
children: [
{
path: 'sim-import',
name: 'SimImport',
component: RoutesAlias.SimImport,
meta: {
title: 'menus.batch.simImport',
keepAlive: true
}
},
{
path: 'device-import',
name: 'DeviceImport',
component: RoutesAlias.DeviceImport,
meta: {
title: 'menus.batch.deviceImport',
keepAlive: true
}
},
// {
// path: 'offline-batch-recharge',
// name: 'OfflineBatchRecharge',
// component: RoutesAlias.OfflineBatchRecharge,
// meta: {
// title: 'menus.batch.offlineBatchRecharge',
// keepAlive: true
// }
// },
// {
// path: 'card-change-notice',
// name: 'CardChangeNotice',
// component: RoutesAlias.CardChangeNotice,
// meta: {
// title: 'menus.batch.cardChangeNotice',
// keepAlive: true
// }
// }
]
}
]

View File

@@ -91,12 +91,9 @@ export enum RoutesAlias {
SimCardAssign = '/product/sim-card-assign', // 号卡分配
// 资产管理
CardSearch = '/asset-management/iot-card-query', // IoT卡查询
DeviceSearch = '/asset-management/device-search', // 设备查询
StandaloneCardList = '/asset-management/iot-card-management', // IoT卡管理
IotCardTask = '/asset-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/device-task', // 设备任务
TaskDetail = '/asset-management/task-detail', // 任务详情
DeviceList = '/asset-management/device-list', // 设备列表
DeviceDetail = '/asset-management/device-detail', // 设备详情
AssetAssign = '/asset-management/asset-assign', // 资产分配(分配记录)
@@ -121,12 +118,7 @@ export enum RoutesAlias {
// 设置管理
PaymentMerchant = '/settings/payment-merchant', // 支付商户
DeveloperApi = '/settings/developer-api', // 开发者API
CommissionTemplate = '/settings/commission-template', // 分佣模板
// 批量操作
SimImport = '/batch/sim-import', // 网卡批量导入
DeviceImport = '/batch/device-import', // 设备批量导入
CardChangeNotice = '/batch/card-change-notice' // 换卡通知
CommissionTemplate = '/settings/commission-template' // 分佣模板
}
// 主页路由

View File

@@ -23,15 +23,45 @@ export const useUserStore = defineStore(
const searchHistory = ref<AppRouteRecord[]>([])
const accessToken = ref('')
const refreshToken = ref('')
const permissions = ref<string[]>([])
const getUserInfo = computed(() => info.value)
const getSettingState = computed(() => useSettingStore().$state)
const getWorktabState = computed(() => useWorktabStore().$state)
const getPermissions = computed(() => permissions.value)
const setUserInfo = (newInfo: UserInfo) => {
info.value = newInfo
}
const setPermissions = (perms: string[]) => {
permissions.value = perms
}
// 检查是否是超级管理员
const isSuperAdmin = computed(() => info.value.user_type === 1)
// 检查是否有某个权限
const hasPermission = (permission: string): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return permissions.value.includes(permission)
}
// 检查是否有某些权限中的任意一个
const hasAnyPermission = (perms: string[]): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return perms.some((perm) => permissions.value.includes(perm))
}
// 检查是否有所有指定权限
const hasAllPermissions = (perms: string[]): boolean => {
// 超级管理员拥有所有权限
if (isSuperAdmin.value) return true
return perms.every((perm) => permissions.value.includes(perm))
}
const setLoginStatus = (status: boolean) => {
isLogin.value = status
}
@@ -74,6 +104,7 @@ export const useUserStore = defineStore(
lockPassword.value = ''
accessToken.value = ''
refreshToken.value = ''
permissions.value = []
useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes')
resetRouterState(router)
@@ -90,10 +121,17 @@ export const useUserStore = defineStore(
searchHistory,
accessToken,
refreshToken,
permissions,
getUserInfo,
getSettingState,
getWorktabState,
getPermissions,
isSuperAdmin,
setUserInfo,
setPermissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
setLoginStatus,
setLanguage,
setSearchHistory,

View File

@@ -6,7 +6,7 @@
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const EffectScope: (typeof import('vue'))['EffectScope']
const ElButton: (typeof import('element-plus/es'))['ElButton']
const ElMessage: (typeof import('element-plus/es'))['ElMessage']
const ElMessageBox: (typeof import('element-plus/es'))['ElMessageBox']
@@ -14,304 +14,319 @@ declare global {
const ElPopconfirm: (typeof import('element-plus/es'))['ElPopconfirm']
const ElPopover: (typeof import('element-plus/es'))['ElPopover']
const ElTableColumn: (typeof import('element-plus/es'))['ElTableColumn']
const ElTag: typeof import('element-plus/es')['ElTag']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
const ElTag: (typeof import('element-plus/es'))['ElTag']
const acceptHMRUpdate: (typeof import('pinia'))['acceptHMRUpdate']
const asyncComputed: (typeof import('@vueuse/core'))['asyncComputed']
const autoResetRef: (typeof import('@vueuse/core'))['autoResetRef']
const computed: (typeof import('vue'))['computed']
const computedAsync: (typeof import('@vueuse/core'))['computedAsync']
const computedEager: (typeof import('@vueuse/core'))['computedEager']
const computedInject: (typeof import('@vueuse/core'))['computedInject']
const computedWithControl: (typeof import('@vueuse/core'))['computedWithControl']
const controlledComputed: (typeof import('@vueuse/core'))['controlledComputed']
const controlledRef: (typeof import('@vueuse/core'))['controlledRef']
const createApp: (typeof import('vue'))['createApp']
const createEventHook: (typeof import('@vueuse/core'))['createEventHook']
const createGlobalState: (typeof import('@vueuse/core'))['createGlobalState']
const createInjectionState: (typeof import('@vueuse/core'))['createInjectionState']
const createPinia: (typeof import('pinia'))['createPinia']
const createReactiveFn: (typeof import('@vueuse/core'))['createReactiveFn']
const createReusableTemplate: (typeof import('@vueuse/core'))['createReusableTemplate']
const createSharedComposable: (typeof import('@vueuse/core'))['createSharedComposable']
const createTemplatePromise: (typeof import('@vueuse/core'))['createTemplatePromise']
const createUnrefFn: (typeof import('@vueuse/core'))['createUnrefFn']
const customRef: (typeof import('vue'))['customRef']
const debouncedRef: (typeof import('@vueuse/core'))['debouncedRef']
const debouncedWatch: (typeof import('@vueuse/core'))['debouncedWatch']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const defineStore: (typeof import('pinia'))['defineStore']
const eagerComputed: (typeof import('@vueuse/core'))['eagerComputed']
const effectScope: (typeof import('vue'))['effectScope']
const extendRef: (typeof import('@vueuse/core'))['extendRef']
const getActivePinia: (typeof import('pinia'))['getActivePinia']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const h: (typeof import('vue'))['h']
const ignorableWatch: (typeof import('@vueuse/core'))['ignorableWatch']
const inject: (typeof import('vue'))['inject']
const injectLocal: (typeof import('@vueuse/core'))['injectLocal']
const isDefined: (typeof import('@vueuse/core'))['isDefined']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const makeDestructurable: (typeof import('@vueuse/core'))['makeDestructurable']
const mapActions: (typeof import('pinia'))['mapActions']
const mapGetters: (typeof import('pinia'))['mapGetters']
const mapState: (typeof import('pinia'))['mapState']
const mapStores: (typeof import('pinia'))['mapStores']
const mapWritableState: (typeof import('pinia'))['mapWritableState']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeRouteLeave: (typeof import('vue-router'))['onBeforeRouteLeave']
const onBeforeRouteUpdate: (typeof import('vue-router'))['onBeforeRouteUpdate']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onClickOutside: (typeof import('@vueuse/core'))['onClickOutside']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onKeyStroke: (typeof import('@vueuse/core'))['onKeyStroke']
const onLongPress: (typeof import('@vueuse/core'))['onLongPress']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onStartTyping: (typeof import('@vueuse/core'))['onStartTyping']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
const pausableWatch: (typeof import('@vueuse/core'))['pausableWatch']
const provide: (typeof import('vue'))['provide']
const provideLocal: (typeof import('@vueuse/core'))['provideLocal']
const reactify: (typeof import('@vueuse/core'))['reactify']
const reactifyObject: (typeof import('@vueuse/core'))['reactifyObject']
const reactive: (typeof import('vue'))['reactive']
const reactiveComputed: (typeof import('@vueuse/core'))['reactiveComputed']
const reactiveOmit: (typeof import('@vueuse/core'))['reactiveOmit']
const reactivePick: (typeof import('@vueuse/core'))['reactivePick']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const refAutoReset: (typeof import('@vueuse/core'))['refAutoReset']
const refDebounced: (typeof import('@vueuse/core'))['refDebounced']
const refDefault: (typeof import('@vueuse/core'))['refDefault']
const refThrottled: (typeof import('@vueuse/core'))['refThrottled']
const refWithControl: (typeof import('@vueuse/core'))['refWithControl']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const resolveRef: (typeof import('@vueuse/core'))['resolveRef']
const resolveUnref: (typeof import('@vueuse/core'))['resolveUnref']
const setActivePinia: (typeof import('pinia'))['setActivePinia']
const setMapStoreSuffix: (typeof import('pinia'))['setMapStoreSuffix']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const storeToRefs: (typeof import('pinia'))['storeToRefs']
const syncRef: (typeof import('@vueuse/core'))['syncRef']
const syncRefs: (typeof import('@vueuse/core'))['syncRefs']
const templateRef: (typeof import('@vueuse/core'))['templateRef']
const throttledRef: (typeof import('@vueuse/core'))['throttledRef']
const throttledWatch: (typeof import('@vueuse/core'))['throttledWatch']
const toRaw: (typeof import('vue'))['toRaw']
const toReactive: (typeof import('@vueuse/core'))['toReactive']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const tryOnBeforeMount: (typeof import('@vueuse/core'))['tryOnBeforeMount']
const tryOnBeforeUnmount: (typeof import('@vueuse/core'))['tryOnBeforeUnmount']
const tryOnMounted: (typeof import('@vueuse/core'))['tryOnMounted']
const tryOnScopeDispose: (typeof import('@vueuse/core'))['tryOnScopeDispose']
const tryOnUnmounted: (typeof import('@vueuse/core'))['tryOnUnmounted']
const unref: (typeof import('vue'))['unref']
const unrefElement: (typeof import('@vueuse/core'))['unrefElement']
const until: (typeof import('@vueuse/core'))['until']
const useActiveElement: (typeof import('@vueuse/core'))['useActiveElement']
const useAnimate: (typeof import('@vueuse/core'))['useAnimate']
const useArrayDifference: (typeof import('@vueuse/core'))['useArrayDifference']
const useArrayEvery: (typeof import('@vueuse/core'))['useArrayEvery']
const useArrayFilter: (typeof import('@vueuse/core'))['useArrayFilter']
const useArrayFind: (typeof import('@vueuse/core'))['useArrayFind']
const useArrayFindIndex: (typeof import('@vueuse/core'))['useArrayFindIndex']
const useArrayFindLast: (typeof import('@vueuse/core'))['useArrayFindLast']
const useArrayIncludes: (typeof import('@vueuse/core'))['useArrayIncludes']
const useArrayJoin: (typeof import('@vueuse/core'))['useArrayJoin']
const useArrayMap: (typeof import('@vueuse/core'))['useArrayMap']
const useArrayReduce: (typeof import('@vueuse/core'))['useArrayReduce']
const useArraySome: (typeof import('@vueuse/core'))['useArraySome']
const useArrayUnique: (typeof import('@vueuse/core'))['useArrayUnique']
const useAsyncQueue: (typeof import('@vueuse/core'))['useAsyncQueue']
const useAsyncState: (typeof import('@vueuse/core'))['useAsyncState']
const useAttrs: (typeof import('vue'))['useAttrs']
const useBase64: (typeof import('@vueuse/core'))['useBase64']
const useBattery: (typeof import('@vueuse/core'))['useBattery']
const useBluetooth: (typeof import('@vueuse/core'))['useBluetooth']
const useBreakpoints: (typeof import('@vueuse/core'))['useBreakpoints']
const useBroadcastChannel: (typeof import('@vueuse/core'))['useBroadcastChannel']
const useBrowserLocation: (typeof import('@vueuse/core'))['useBrowserLocation']
const useCached: (typeof import('@vueuse/core'))['useCached']
const useClipboard: (typeof import('@vueuse/core'))['useClipboard']
const useClipboardItems: (typeof import('@vueuse/core'))['useClipboardItems']
const useCloned: (typeof import('@vueuse/core'))['useCloned']
const useColorMode: (typeof import('@vueuse/core'))['useColorMode']
const useConfirmDialog: (typeof import('@vueuse/core'))['useConfirmDialog']
const useCounter: (typeof import('@vueuse/core'))['useCounter']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVar: (typeof import('@vueuse/core'))['useCssVar']
const useCssVars: (typeof import('vue'))['useCssVars']
const useCurrentElement: (typeof import('@vueuse/core'))['useCurrentElement']
const useCycleList: (typeof import('@vueuse/core'))['useCycleList']
const useDark: (typeof import('@vueuse/core'))['useDark']
const useDateFormat: (typeof import('@vueuse/core'))['useDateFormat']
const useDebounce: (typeof import('@vueuse/core'))['useDebounce']
const useDebounceFn: (typeof import('@vueuse/core'))['useDebounceFn']
const useDebouncedRefHistory: (typeof import('@vueuse/core'))['useDebouncedRefHistory']
const useDeviceMotion: (typeof import('@vueuse/core'))['useDeviceMotion']
const useDeviceOrientation: (typeof import('@vueuse/core'))['useDeviceOrientation']
const useDevicePixelRatio: (typeof import('@vueuse/core'))['useDevicePixelRatio']
const useDevicesList: (typeof import('@vueuse/core'))['useDevicesList']
const useDisplayMedia: (typeof import('@vueuse/core'))['useDisplayMedia']
const useDocumentVisibility: (typeof import('@vueuse/core'))['useDocumentVisibility']
const useDraggable: (typeof import('@vueuse/core'))['useDraggable']
const useDropZone: (typeof import('@vueuse/core'))['useDropZone']
const useElementBounding: (typeof import('@vueuse/core'))['useElementBounding']
const useElementByPoint: (typeof import('@vueuse/core'))['useElementByPoint']
const useElementHover: (typeof import('@vueuse/core'))['useElementHover']
const useElementSize: (typeof import('@vueuse/core'))['useElementSize']
const useElementVisibility: (typeof import('@vueuse/core'))['useElementVisibility']
const useEventBus: (typeof import('@vueuse/core'))['useEventBus']
const useEventListener: (typeof import('@vueuse/core'))['useEventListener']
const useEventSource: (typeof import('@vueuse/core'))['useEventSource']
const useEyeDropper: (typeof import('@vueuse/core'))['useEyeDropper']
const useFavicon: (typeof import('@vueuse/core'))['useFavicon']
const useFetch: (typeof import('@vueuse/core'))['useFetch']
const useFileDialog: (typeof import('@vueuse/core'))['useFileDialog']
const useFileSystemAccess: (typeof import('@vueuse/core'))['useFileSystemAccess']
const useFocus: (typeof import('@vueuse/core'))['useFocus']
const useFocusWithin: (typeof import('@vueuse/core'))['useFocusWithin']
const useFps: (typeof import('@vueuse/core'))['useFps']
const useFullscreen: (typeof import('@vueuse/core'))['useFullscreen']
const useGamepad: (typeof import('@vueuse/core'))['useGamepad']
const useGeolocation: (typeof import('@vueuse/core'))['useGeolocation']
const useId: (typeof import('vue'))['useId']
const useIdle: (typeof import('@vueuse/core'))['useIdle']
const useImage: (typeof import('@vueuse/core'))['useImage']
const useInfiniteScroll: (typeof import('@vueuse/core'))['useInfiniteScroll']
const useIntersectionObserver: (typeof import('@vueuse/core'))['useIntersectionObserver']
const useInterval: (typeof import('@vueuse/core'))['useInterval']
const useIntervalFn: (typeof import('@vueuse/core'))['useIntervalFn']
const useKeyModifier: (typeof import('@vueuse/core'))['useKeyModifier']
const useLastChanged: (typeof import('@vueuse/core'))['useLastChanged']
const useLink: (typeof import('vue-router'))['useLink']
const useLocalStorage: (typeof import('@vueuse/core'))['useLocalStorage']
const useMagicKeys: (typeof import('@vueuse/core'))['useMagicKeys']
const useManualRefHistory: (typeof import('@vueuse/core'))['useManualRefHistory']
const useMediaControls: (typeof import('@vueuse/core'))['useMediaControls']
const useMediaQuery: (typeof import('@vueuse/core'))['useMediaQuery']
const useMemoize: (typeof import('@vueuse/core'))['useMemoize']
const useMemory: (typeof import('@vueuse/core'))['useMemory']
const useModel: (typeof import('vue'))['useModel']
const useMounted: (typeof import('@vueuse/core'))['useMounted']
const useMouse: (typeof import('@vueuse/core'))['useMouse']
const useMouseInElement: (typeof import('@vueuse/core'))['useMouseInElement']
const useMousePressed: (typeof import('@vueuse/core'))['useMousePressed']
const useMutationObserver: (typeof import('@vueuse/core'))['useMutationObserver']
const useNavigatorLanguage: (typeof import('@vueuse/core'))['useNavigatorLanguage']
const useNetwork: (typeof import('@vueuse/core'))['useNetwork']
const useNow: (typeof import('@vueuse/core'))['useNow']
const useObjectUrl: (typeof import('@vueuse/core'))['useObjectUrl']
const useOffsetPagination: (typeof import('@vueuse/core'))['useOffsetPagination']
const useOnline: (typeof import('@vueuse/core'))['useOnline']
const usePageLeave: (typeof import('@vueuse/core'))['usePageLeave']
const useParallax: (typeof import('@vueuse/core'))['useParallax']
const useParentElement: (typeof import('@vueuse/core'))['useParentElement']
const usePerformanceObserver: (typeof import('@vueuse/core'))['usePerformanceObserver']
const usePermission: (typeof import('@vueuse/core'))['usePermission']
const usePointer: (typeof import('@vueuse/core'))['usePointer']
const usePointerLock: (typeof import('@vueuse/core'))['usePointerLock']
const usePointerSwipe: (typeof import('@vueuse/core'))['usePointerSwipe']
const usePreferredColorScheme: (typeof import('@vueuse/core'))['usePreferredColorScheme']
const usePreferredContrast: (typeof import('@vueuse/core'))['usePreferredContrast']
const usePreferredDark: (typeof import('@vueuse/core'))['usePreferredDark']
const usePreferredLanguages: (typeof import('@vueuse/core'))['usePreferredLanguages']
const usePreferredReducedMotion: (typeof import('@vueuse/core'))['usePreferredReducedMotion']
const usePrevious: (typeof import('@vueuse/core'))['usePrevious']
const useRafFn: (typeof import('@vueuse/core'))['useRafFn']
const useRefHistory: (typeof import('@vueuse/core'))['useRefHistory']
const useResizeObserver: (typeof import('@vueuse/core'))['useResizeObserver']
const useRoute: (typeof import('vue-router'))['useRoute']
const useRouter: (typeof import('vue-router'))['useRouter']
const useScreenOrientation: (typeof import('@vueuse/core'))['useScreenOrientation']
const useScreenSafeArea: (typeof import('@vueuse/core'))['useScreenSafeArea']
const useScriptTag: (typeof import('@vueuse/core'))['useScriptTag']
const useScroll: (typeof import('@vueuse/core'))['useScroll']
const useScrollLock: (typeof import('@vueuse/core'))['useScrollLock']
const useSessionStorage: (typeof import('@vueuse/core'))['useSessionStorage']
const useShare: (typeof import('@vueuse/core'))['useShare']
const useSlots: (typeof import('vue'))['useSlots']
const useSorted: (typeof import('@vueuse/core'))['useSorted']
const useSpeechRecognition: (typeof import('@vueuse/core'))['useSpeechRecognition']
const useSpeechSynthesis: (typeof import('@vueuse/core'))['useSpeechSynthesis']
const useStepper: (typeof import('@vueuse/core'))['useStepper']
const useStorage: (typeof import('@vueuse/core'))['useStorage']
const useStorageAsync: (typeof import('@vueuse/core'))['useStorageAsync']
const useStyleTag: (typeof import('@vueuse/core'))['useStyleTag']
const useSupported: (typeof import('@vueuse/core'))['useSupported']
const useSwipe: (typeof import('@vueuse/core'))['useSwipe']
const useTemplateRef: (typeof import('vue'))['useTemplateRef']
const useTemplateRefsList: (typeof import('@vueuse/core'))['useTemplateRefsList']
const useTextDirection: (typeof import('@vueuse/core'))['useTextDirection']
const useTextSelection: (typeof import('@vueuse/core'))['useTextSelection']
const useTextareaAutosize: (typeof import('@vueuse/core'))['useTextareaAutosize']
const useThrottle: (typeof import('@vueuse/core'))['useThrottle']
const useThrottleFn: (typeof import('@vueuse/core'))['useThrottleFn']
const useThrottledRefHistory: (typeof import('@vueuse/core'))['useThrottledRefHistory']
const useTimeAgo: (typeof import('@vueuse/core'))['useTimeAgo']
const useTimeout: (typeof import('@vueuse/core'))['useTimeout']
const useTimeoutFn: (typeof import('@vueuse/core'))['useTimeoutFn']
const useTimeoutPoll: (typeof import('@vueuse/core'))['useTimeoutPoll']
const useTimestamp: (typeof import('@vueuse/core'))['useTimestamp']
const useTitle: (typeof import('@vueuse/core'))['useTitle']
const useToNumber: (typeof import('@vueuse/core'))['useToNumber']
const useToString: (typeof import('@vueuse/core'))['useToString']
const useToggle: (typeof import('@vueuse/core'))['useToggle']
const useTransition: (typeof import('@vueuse/core'))['useTransition']
const useUrlSearchParams: (typeof import('@vueuse/core'))['useUrlSearchParams']
const useUserMedia: (typeof import('@vueuse/core'))['useUserMedia']
const useVModel: (typeof import('@vueuse/core'))['useVModel']
const useVModels: (typeof import('@vueuse/core'))['useVModels']
const useVibrate: (typeof import('@vueuse/core'))['useVibrate']
const useVirtualList: (typeof import('@vueuse/core'))['useVirtualList']
const useWakeLock: (typeof import('@vueuse/core'))['useWakeLock']
const useWebNotification: (typeof import('@vueuse/core'))['useWebNotification']
const useWebSocket: (typeof import('@vueuse/core'))['useWebSocket']
const useWebWorker: (typeof import('@vueuse/core'))['useWebWorker']
const useWebWorkerFn: (typeof import('@vueuse/core'))['useWebWorkerFn']
const useWindowFocus: (typeof import('@vueuse/core'))['useWindowFocus']
const useWindowScroll: (typeof import('@vueuse/core'))['useWindowScroll']
const useWindowSize: (typeof import('@vueuse/core'))['useWindowSize']
const watch: (typeof import('vue'))['watch']
const watchArray: (typeof import('@vueuse/core'))['watchArray']
const watchAtMost: (typeof import('@vueuse/core'))['watchAtMost']
const watchDebounced: (typeof import('@vueuse/core'))['watchDebounced']
const watchDeep: (typeof import('@vueuse/core'))['watchDeep']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchIgnorable: (typeof import('@vueuse/core'))['watchIgnorable']
const watchImmediate: (typeof import('@vueuse/core'))['watchImmediate']
const watchOnce: (typeof import('@vueuse/core'))['watchOnce']
const watchPausable: (typeof import('@vueuse/core'))['watchPausable']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
const watchThrottled: (typeof import('@vueuse/core'))['watchThrottled']
const watchTriggerable: (typeof import('@vueuse/core'))['watchTriggerable']
const watchWithFilter: (typeof import('@vueuse/core'))['watchWithFilter']
const whenever: (typeof import('@vueuse/core'))['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type {
Component,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue'
import('vue')
}

View File

@@ -84,9 +84,9 @@
<!-- 分配角色对话框 -->
<ElDialog v-model="roleDialogVisible" title="分配角色" width="500px">
<ElRadioGroup v-model="selectedRole" class="role-radio-group">
<div v-for="role in allRoles" :key="role.ID" class="role-radio-item">
<ElRadio :label="role.ID">
<ElCheckboxGroup v-model="selectedRoles">
<div v-for="role in allRoles" :key="role.ID" style="margin-bottom: 12px">
<ElCheckbox :label="role.ID">
{{ role.role_name }}
<ElTag
:type="role.role_type === 1 ? 'primary' : 'success'"
@@ -95,9 +95,9 @@
>
{{ role.role_type === 1 ? '平台角色' : '客户角色' }}
</ElTag>
</ElRadio>
</ElCheckbox>
</div>
</ElRadioGroup>
</ElCheckboxGroup>
<template #footer>
<div class="dialog-footer">
<ElButton @click="roleDialogVisible = false">取消</ElButton>
@@ -114,7 +114,7 @@
<script setup lang="ts">
import { h } from 'vue'
import { FormInstance, ElSwitch } from 'element-plus'
import { FormInstance, ElSwitch, ElCheckbox, ElCheckboxGroup, ElTag } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import type { FormRules } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -134,7 +134,7 @@
const loading = ref(false)
const roleSubmitLoading = ref(false)
const currentAccountId = ref<number>(0)
const selectedRole = ref<number | undefined>(undefined)
const selectedRoles = ref<number[]>([])
const allRoles = ref<PlatformRole[]>([])
// 定义表单搜索初始值
@@ -292,7 +292,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -356,15 +357,18 @@
// 显示分配角色对话框
const showRoleDialog = async (row: any) => {
currentAccountId.value = row.ID
selectedRole.value = undefined
selectedRoles.value = []
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
try {
const res = await AccountService.getAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID(只取第一个角色)
// 提取角色ID数组
const roles = res.data || []
selectedRole.value = roles.length > 0 ? roles[0].ID : undefined
selectedRoles.value = roles.map((role: any) => role.ID)
// 数据加载完成后再打开对话框
roleDialogVisible.value = true
}
@@ -375,17 +379,13 @@
// 提交分配角色
const handleAssignRoles = async () => {
if (selectedRole.value === undefined) {
ElMessage.warning('请选择一个角色')
return
}
roleSubmitLoading.value = true
try {
// 将单个角色ID包装成数组传给后端
await AccountService.assignRolesToAccount(currentAccountId.value, [selectedRole.value])
await AccountService.assignRolesToAccount(currentAccountId.value, selectedRoles.value)
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {
@@ -503,13 +503,4 @@
.account-page {
// 账号管理页面样式
}
.role-radio-group {
width: 100%;
}
.role-radio-item {
padding: 8px;
margin-bottom: 16px;
}
</style>

View File

@@ -328,7 +328,8 @@
activeText: '启用',
inactiveText: '禁用',
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -2,7 +2,7 @@
<ArtTableFullScreen>
<div class="enterprise-cards-page" id="table-full-screen">
<!-- 企业信息卡片 -->
<ElCard shadow="never" style="margin-bottom: 16px">
<ElCard shadow="never" class="enterprise-info-card">
<template #header>
<div class="card-header">
<span>企业信息</span>
@@ -10,8 +10,12 @@
</div>
</template>
<ElDescriptions :column="3" border v-if="enterpriseInfo">
<ElDescriptionsItem label="企业名称">{{ enterpriseInfo.enterprise_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{ enterpriseInfo.enterprise_code }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业名称">{{
enterpriseInfo.enterprise_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{
enterpriseInfo.enterprise_code
}}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ enterpriseInfo.contact_name }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
@@ -67,92 +71,51 @@
<ElDialog
v-model="allocateDialogVisible"
title="授权卡给企业"
width="700px"
width="85%"
@close="handleAllocateDialogClose"
>
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElFormItem label="ICCID列表" prop="iccids">
<ElInput
v-model="iccidsText"
type="textarea"
:rows="6"
placeholder="请输入ICCID每行一个或用逗号分隔"
@input="handleIccidsChange"
<!-- 搜索过滤条件 -->
<ArtSearchBar
v-model:filter="cardSearchForm"
:items="cardSearchFormItems"
label-width="85"
@reset="handleCardSearchReset"
@search="handleCardSearch"
></ArtSearchBar>
<!-- 卡列表 -->
<div class="card-selection-info"> 已选择 {{ selectedAvailableCards.length }} 张卡 </div>
<ArtTable
ref="availableCardsTableRef"
row-key="id"
:loading="availableCardsLoading"
:data="availableCardsList"
:currentPage="cardPagination.page"
:pageSize="cardPagination.pageSize"
:total="cardPagination.total"
:marginTop="10"
@size-change="handleCardPageSizeChange"
@current-change="handleCardPageChange"
@selection-change="handleAvailableCardsSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn
v-for="col in availableCardColumns"
:key="col.prop || col.type"
v-bind="col"
/>
<div style="color: var(--el-color-info); margin-top: 4px; font-size: 12px">
已输入 {{ allocateForm.iccids?.length || 0 }} 个ICCID
</div>
</ElFormItem>
<ElFormItem label="确认设备绑定">
<ElCheckbox v-model="allocateForm.confirm_device_bundles">
我确认已了解设备绑定关系同意一起授权
</ElCheckbox>
</ElFormItem>
</ElForm>
<!-- 预检结果 -->
<div v-if="previewData" style="margin-top: 20px">
<ElDivider content-position="left">预检结果</ElDivider>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="待授权卡数">
{{ previewData.summary.total_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="可授权卡数">
<ElTag type="success">{{ previewData.summary.valid_cards }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="独立卡数">
{{ previewData.summary.standalone_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备绑定数">
<ElTag type="warning">{{ previewData.summary.device_bundles }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数" :span="2">
<ElTag type="danger">{{ previewData.summary.failed_cards }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<!-- 失败项详情 -->
<div v-if="previewData.failed_items && previewData.failed_items.length > 0" style="margin-top: 16px">
<ElAlert title="以下ICCID无法授权" type="error" :closable="false" style="margin-bottom: 8px" />
<ElTable :data="previewData.failed_items" border max-height="200">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<!-- 设备绑定详情 -->
<div v-if="previewData.device_bundles && previewData.device_bundles.length > 0" style="margin-top: 16px">
<ElAlert
title="以下ICCID与设备绑定授权后设备也将一起授权给企业"
type="warning"
:closable="false"
style="margin-bottom: 8px"
/>
<ElTable :data="previewData.device_bundles" border max-height="200">
<ElTableColumn prop="device_imei" label="设备IMEI" width="180" />
<ElTableColumn label="绑定卡数">
<template #default="{ row }">
{{ row.iccids?.length || 0 }}
</template>
</ElTableColumn>
<ElTableColumn label="ICCID列表">
<template #default="{ row }">
{{ row.iccids?.join(', ') }}
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
</ArtTable>
<template #footer>
<div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">取消</ElButton>
<ElButton @click="handlePreview" :loading="previewLoading">预检</ElButton>
<ElButton
type="primary"
@click="handleAllocate"
:loading="allocateLoading"
:disabled="!previewData || previewData.summary.valid_cards === 0"
:disabled="selectedAvailableCards.length === 0"
>
确认授权
</ElButton>
@@ -191,7 +154,12 @@
</ElDialog>
<!-- 结果对话框 -->
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
<ElDialog
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
@close="handleResultDialogClose"
>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ operationResult.success_count }}</ElTag>
@@ -203,7 +171,7 @@
<div
v-if="operationResult.failed_items && operationResult.failed_items.length > 0"
style="margin-top: 20px"
class="result-section"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="operationResult.failed_items" border max-height="300">
@@ -215,7 +183,7 @@
<!-- 显示授权的设备 -->
<div
v-if="operationResult.allocated_devices && operationResult.allocated_devices.length > 0"
style="margin-top: 20px"
class="result-section"
>
<ElDivider content-position="left">已授权设备</ElDivider>
<ElTable :data="operationResult.allocated_devices" border max-height="200">
@@ -242,21 +210,21 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { EnterpriseService } from '@/api/modules'
import { EnterpriseService, CardService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { BgColorEnum } from '@/enums/appEnum'
import type {
EnterpriseCardItem,
AllocateCardsPreviewResponse,
AllocateCardsResponse,
RecallCardsResponse,
FailedItem
RecallCardsResponse
} from '@/types/api/enterpriseCard'
import type { EnterpriseItem } from '@/types/api'
import type { StandaloneIotCard } from '@/types/api/card'
defineOptions({ name: 'EnterpriseCards' })
@@ -265,19 +233,15 @@
const loading = ref(false)
const allocateDialogVisible = ref(false)
const allocateLoading = ref(false)
const previewLoading = ref(false)
const recallDialogVisible = ref(false)
const recallLoading = ref(false)
const resultDialogVisible = ref(false)
const resultTitle = ref('')
const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const selectedCards = ref<EnterpriseCardItem[]>([])
const enterpriseId = ref<number>(0)
const enterpriseInfo = ref<EnterpriseItem | null>(null)
const iccidsText = ref('')
const previewData = ref<AllocateCardsPreviewResponse | null>(null)
const operationResult = ref<AllocateCardsResponse | RecallCardsResponse>({
success_count: 0,
fail_count: 0,
@@ -285,40 +249,39 @@
allocated_devices: null
})
// 可用卡列表相关
const availableCardsTableRef = ref()
const availableCardsLoading = ref(false)
const availableCardsList = ref<StandaloneIotCard[]>([])
const selectedAvailableCards = ref<StandaloneIotCard[]>([])
// 卡搜索表单初始值
const initialCardSearchState = {
status: undefined,
carrier_id: undefined,
iccid: '',
msisdn: '',
is_distributed: undefined
}
const cardSearchForm = reactive({ ...initialCardSearchState })
const cardPagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单初始值
const initialSearchState = {
iccid: '',
msisdn: '',
status: undefined as number | undefined,
authorization_status: undefined as number | undefined
device_no: '',
carrier_id: undefined as number | undefined,
status: undefined as number | undefined
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 授权表单
const allocateForm = reactive({
iccids: [] as string[],
confirm_device_bundles: false
})
// 授权表单验证规则
const allocateRules = reactive<FormRules>({
iccids: [
{
required: true,
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请输入至少一个ICCID'))
} else {
callback()
}
},
trigger: 'change'
}
]
})
// 回收表单
const recallForm = reactive({
reason: ''
@@ -346,16 +309,30 @@
}
},
{
label: '手机号',
prop: 'msisdn',
label: '设备号',
prop: 'device_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
placeholder: '请输入设备号'
}
},
{
label: '卡状态',
label: '运营商',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 1 },
{ label: '中国联通', value: 2 },
{ label: '中国电信', value: 3 }
]
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
@@ -363,21 +340,74 @@
placeholder: '全部'
},
options: () => [
{ label: '激活', value: 1 },
{ label: '停机', value: 2 }
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
}
]
// 卡列表搜索表单配置
const cardSearchFormItems: SearchFormItem[] = [
{
label: '授权状态',
prop: 'authorization_status',
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '有效', value: 1 },
{ label: '已回收', value: 0 }
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
},
{
label: '运营商',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '中国移动', value: 1 },
{ label: '中国联通', value: 2 },
{ label: '中国电信', value: 3 }
]
},
{
label: 'ICCID',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID'
}
},
{
label: '卡接入号',
prop: 'msisdn',
type: 'input',
config: {
clearable: true,
placeholder: '请输入卡接入号'
}
},
{
label: '是否已分销',
prop: 'is_distributed',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '是', value: true },
{ label: '否', value: false }
]
}
]
@@ -385,12 +415,15 @@
// 列配置
const columnOptions = [
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn' },
{ label: '卡接入号', prop: 'msisdn' },
{ label: '设备号', prop: 'device_no' },
{ label: '运营商ID', prop: 'carrier_id' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '卡状态', prop: 'status' },
{ label: '授权状态', prop: 'authorization_status' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '授权人', prop: 'authorizer_name' },
{ label: '套餐名称', prop: 'package_name' },
{ label: '状态', prop: 'status' },
{ label: '状态名称', prop: 'status_name' },
{ label: '网络状态', prop: 'network_status' },
{ label: '网络状态名称', prop: 'network_status_name' },
{ label: '操作', prop: 'operation' }
]
@@ -398,22 +431,44 @@
// 获取卡状态标签类型
const getCardStatusTag = (status: number) => {
switch (status) {
case 1:
return 'info'
case 2:
return 'warning'
case 3:
return 'success'
case 4:
return 'danger'
default:
return 'info'
}
}
// 获取卡状态文本 - 使用API返回的status_name
const getCardStatusText = (status: number) => {
switch (status) {
case 1:
return '在库'
case 2:
return '已分销'
case 3:
return '已激活'
case 4:
return '已停用'
default:
return '未知'
}
}
// 获取网络状态标签类型
const getNetworkStatusTag = (status: number) => {
return status === 1 ? 'success' : 'danger'
}
// 获取状态文本
const getCardStatusText = (status: number) => {
return status === 1 ? '激活' : '停机'
}
// 获取授权状态标签类型
const getAuthStatusTag = (status: number) => {
return status === 1 ? 'success' : 'info'
}
// 获取授权状态文本
const getAuthStatusText = (status: number) => {
return status === 1 ? '有效' : '已回收'
// 获取网络状态文本 - 使用API返回的network_status_name
const getNetworkStatusText = (status: number) => {
return status === 1 ? '开机' : '停机'
}
// 动态列配置
@@ -421,64 +476,77 @@
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
minWidth: 200
},
{
prop: 'msisdn',
label: '手机号',
width: 120
label: '卡接入号',
width: 130
},
{
prop: 'device_no',
label: '设备号',
width: 150
},
{
prop: 'carrier_id',
label: '运营商ID',
width: 100
},
{
prop: 'carrier_name',
label: '运营商',
width: 100
width: 120
},
{
prop: 'package_name',
label: '套餐名称',
width: 150
},
{
prop: 'status',
label: '状态',
label: '状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(ElTag, { type: getCardStatusTag(row.status) }, () => getCardStatusText(row.status))
}
},
{
prop: 'authorization_status',
label: '授权状态',
prop: 'status_name',
label: '状态名称',
width: 100
},
{
prop: 'network_status',
label: '网络状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(
ElTag,
{ type: getAuthStatusTag(row.authorization_status) },
() => getAuthStatusText(row.authorization_status)
return h(ElTag, { type: getNetworkStatusTag(row.network_status) }, () =>
getNetworkStatusText(row.network_status)
)
}
},
{
prop: 'authorized_at',
label: '授权时间',
width: 180,
formatter: (row: EnterpriseCardItem) =>
row.authorized_at ? formatDateTime(row.authorized_at) : '-'
},
{
prop: 'authorizer_name',
label: '授权人',
width: 120
prop: 'network_status_name',
label: '网络状态名称',
width: 130
},
{
prop: 'operation',
label: '操作',
width: 150,
width: 100,
fixed: 'right',
formatter: (row: EnterpriseCardItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
row.status === 2
row.network_status === 0
? h(ArtButtonTable, {
text: '复机',
iconClass: BgColorEnum.SUCCESS,
onClick: () => handleResume(row)
})
: h(ArtButtonTable, {
text: '停机',
iconClass: BgColorEnum.ERROR,
onClick: () => handleSuspend(row)
})
])
@@ -522,9 +590,9 @@
page: pagination.page,
page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined,
msisdn: searchForm.msisdn || undefined,
status: searchForm.status,
authorization_status: searchForm.authorization_status
device_no: searchForm.device_no || undefined,
carrier_id: searchForm.carrier_id,
status: searchForm.status
}
// 清理空值
@@ -586,84 +654,213 @@
router.back()
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
iccidsText.value = ''
allocateForm.iccids = []
allocateForm.confirm_device_bundles = false
previewData.value = null
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
// 获取可用卡状态类型
const getAvailableCardStatusType = (status: number) => {
switch (status) {
case 1:
return 'info'
case 2:
return 'warning'
case 3:
return 'success'
case 4:
return 'danger'
default:
return 'info'
}
}
// 处理ICCID输入变化
const handleIccidsChange = () => {
// 解析输入的ICCID支持逗号、空格、换行分隔
const iccids = iccidsText.value
.split(/[,\s\n]+/)
.map((iccid) => iccid.trim())
.filter((iccid) => iccid.length > 0)
allocateForm.iccids = iccids
// 清除预检结果
previewData.value = null
// 获取可用卡状态文本
const getAvailableCardStatusText = (status: number) => {
switch (status) {
case 1:
return '在库'
case 2:
return '已分销'
case 3:
return '已激活'
case 4:
return '已停用'
default:
return '未知'
}
}
// 预检
const handlePreview = async () => {
if (!allocateFormRef.value) return
// 可用卡列表列配置
const availableCardColumns = computed(() => [
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
},
{
prop: 'msisdn',
label: '卡接入号',
width: 130
},
{
prop: 'carrier_name',
label: '运营商',
width: 100
},
{
prop: 'cost_price',
label: '成本价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.cost_price / 100).toFixed(2)}`
},
{
prop: 'distribute_price',
label: '分销价',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.distribute_price / 100).toFixed(2)}`
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
return h(ElTag, { type: getAvailableCardStatusType(row.status) }, () =>
getAvailableCardStatusText(row.status)
)
}
},
{
prop: 'activation_status',
label: '激活状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.activation_status === 1 ? 'success' : 'info'
const text = row.activation_status === 1 ? '已激活' : '未激活'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'network_status',
label: '网络状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.network_status === 1 ? 'success' : 'danger'
const text = row.network_status === 1 ? '开机' : '停机'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'real_name_status',
label: '实名状态',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.real_name_status === 1 ? 'success' : 'warning'
const text = row.real_name_status === 1 ? '已实名' : '未实名'
return h(ElTag, { type }, () => text)
}
},
{
prop: 'data_usage_mb',
label: '累计流量(MB)',
width: 120
},
{
prop: 'first_commission_paid',
label: '首次佣金',
width: 100,
formatter: (row: StandaloneIotCard) => {
const type = row.first_commission_paid ? 'success' : 'info'
const text = row.first_commission_paid ? '已支付' : '未支付'
return h(ElTag, { type, size: 'small' }, () => text)
}
},
{
prop: 'accumulated_recharge',
label: '累计充值',
width: 100,
formatter: (row: StandaloneIotCard) => `¥${(row.accumulated_recharge / 100).toFixed(2)}`
}
])
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
previewLoading.value = true
// 获取可用卡列表
const getAvailableCardsList = async () => {
availableCardsLoading.value = true
try {
const res = await EnterpriseService.previewAllocateCards(enterpriseId.value, {
iccids: allocateForm.iccids
const params: any = {
page: cardPagination.page,
page_size: cardPagination.pageSize,
...cardSearchForm
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await CardService.getStandaloneIotCards(params)
if (res.code === 0) {
previewData.value = res.data
ElMessage.success('预检完成')
availableCardsList.value = res.data.items || []
cardPagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error('预检失败')
ElMessage.error('获取卡列表失败')
} finally {
previewLoading.value = false
availableCardsLoading.value = false
}
}
})
// 处理卡列表选择变化
const handleAvailableCardsSelectionChange = (selection: StandaloneIotCard[]) => {
selectedAvailableCards.value = selection
}
// 卡列表搜索
const handleCardSearch = () => {
cardPagination.page = 1
getAvailableCardsList()
}
// 卡列表重置
const handleCardSearchReset = () => {
Object.assign(cardSearchForm, { ...initialCardSearchState })
cardPagination.page = 1
getAvailableCardsList()
}
// 卡列表分页大小变化
const handleCardPageSizeChange = (newPageSize: number) => {
cardPagination.pageSize = newPageSize
getAvailableCardsList()
}
// 卡列表页码变化
const handleCardPageChange = (newPage: number) => {
cardPagination.page = newPage
getAvailableCardsList()
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
selectedAvailableCards.value = []
// 重置搜索条件
Object.assign(cardSearchForm, { ...initialCardSearchState })
cardPagination.page = 1
cardPagination.pageSize = 20
// 加载可用卡列表
getAvailableCardsList()
}
// 执行授权
const handleAllocate = async () => {
if (!allocateFormRef.value) return
if (!previewData.value) {
ElMessage.warning('请先进行预检')
return
}
if (previewData.value.summary.valid_cards === 0) {
ElMessage.warning('没有可授权的卡')
return
}
// 如果有设备绑定且未确认,提示用户
if (
previewData.value.device_bundles &&
previewData.value.device_bundles.length > 0 &&
!allocateForm.confirm_device_bundles
) {
ElMessage.warning('请确认设备绑定关系')
if (selectedAvailableCards.value.length === 0) {
ElMessage.warning('请选择要授权的卡')
return
}
allocateLoading.value = true
try {
const iccids = selectedAvailableCards.value.map((card) => card.iccid)
const res = await EnterpriseService.allocateCards(enterpriseId.value, {
iccids: allocateForm.iccids,
confirm_device_bundles: allocateForm.confirm_device_bundles || undefined
iccids
})
if (res.code === 0) {
@@ -683,10 +880,7 @@
// 关闭授权对话框
const handleAllocateDialogClose = () => {
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
previewData.value = null
selectedAvailableCards.value = []
}
// 显示批量回收对话框
@@ -711,7 +905,7 @@
recallLoading.value = true
try {
const res = await EnterpriseService.recallCards(enterpriseId.value, {
card_ids: selectedCards.value.map((card) => card.id)
iccids: selectedCards.value.map((card) => card.iccid)
})
if (res.code === 0) {
@@ -743,6 +937,11 @@
}
}
// 关闭结果对话框
const handleResultDialogClose = () => {
getTableData()
}
// 停机卡
const handleSuspend = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', {
@@ -786,10 +985,34 @@
<style lang="scss" scoped>
.enterprise-cards-page {
.enterprise-info-card {
margin-bottom: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
.card-selection-info {
margin-top: 10px;
margin-bottom: 12px;
color: var(--el-color-info);
font-size: 14px;
}
.card-pagination {
margin-top: 16px;
text-align: right;
}
.result-section {
margin-top: 20px;
}
.mt-20 {
margin-top: 20px;
}
}
</style>

View File

@@ -209,6 +209,7 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime } from '@/utils/business/format'
import { BgColorEnum } from '@/enums/appEnum'
defineOptions({ name: 'EnterpriseCustomer' })
@@ -367,7 +368,8 @@
{
prop: 'enterprise_code',
label: '企业编号',
minWidth: 150
minWidth: 150,
showOverflowTooltip: true
},
{
prop: 'enterprise_name',
@@ -423,7 +425,8 @@
activeText: '启用',
inactiveText: '禁用',
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -436,21 +439,24 @@
{
prop: 'operation',
label: '操作',
width: 230,
width: 260,
fixed: 'right',
formatter: (row: EnterpriseItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
icon: '&#xe679;',
text: '编辑',
iconClass: BgColorEnum.SECONDARY,
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
text: '卡授权',
iconClass: BgColorEnum.PRIMARY,
onClick: () => manageCards(row)
}),
h(ArtButtonTable, {
icon: '&#xe72b;',
text: '修改密码',
iconClass: BgColorEnum.WARNING,
onClick: () => showPasswordDialog(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
})
])
}

View File

@@ -367,7 +367,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},
@@ -447,8 +448,11 @@
currentAccountId.value = row.ID
selectedRoles.value = []
// 先加载当前账号的角色,再打开对话框
try {
// 每次打开对话框时重新加载最新的角色列表
await loadAllRoles()
// 先加载当前账号的角色,再打开对话框
const res = await PlatformAccountService.getPlatformAccountRoles(row.ID)
if (res.code === 0) {
// 提取角色ID数组
@@ -471,6 +475,8 @@
})
ElMessage.success('分配角色成功')
roleDialogVisible.value = false
// 刷新列表以更新角色显示
await getAccountList()
} catch (error) {
console.error(error)
} finally {

View File

@@ -300,15 +300,15 @@
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
label: 'ID'
},
{
prop: 'username',
label: '用户名',
label: '用户名'
},
{
prop: 'phone',
label: '手机号',
label: '手机号'
},
{
prop: 'shop_name',
@@ -326,7 +326,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -12,7 +12,9 @@
<ElSkeleton :loading="loading" :rows="10" animated>
<template #default>
<ElDescriptions v-if="recordDetail" title="基本信息" :column="3" border>
<ElDescriptionsItem label="分配单号">{{ recordDetail.allocation_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="分配单号">{{
recordDetail.allocation_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分配类型">
<ElTag :type="getAllocationTypeType(recordDetail.allocation_type)">
{{ recordDetail.allocation_name }}
@@ -23,14 +25,24 @@
{{ recordDetail.asset_type_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="资产标识符">{{ recordDetail.asset_identifier }}</ElDescriptionsItem>
<ElDescriptionsItem label="关联卡数量">{{ recordDetail.related_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="资产标识符">{{
recordDetail.asset_identifier
}}</ElDescriptionsItem>
<ElDescriptionsItem label="关联卡数量">{{
recordDetail.related_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="关联设备ID">
{{ recordDetail.related_device_id || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
<ElDescriptions v-if="recordDetail" title="所有者信息" :column="2" border style="margin-top: 20px">
<ElDescriptions
v-if="recordDetail"
title="所有者信息"
:column="2"
border
style="margin-top: 20px"
>
<ElDescriptionsItem label="来源所有者">
{{ recordDetail.from_owner_name }} ({{ recordDetail.from_owner_type }})
</ElDescriptionsItem>
@@ -39,8 +51,16 @@
</ElDescriptionsItem>
</ElDescriptions>
<ElDescriptions v-if="recordDetail" title="操作信息" :column="2" border style="margin-top: 20px">
<ElDescriptionsItem label="操作人">{{ recordDetail.operator_name }}</ElDescriptionsItem>
<ElDescriptions
v-if="recordDetail"
title="操作信息"
:column="2"
border
style="margin-top: 20px"
>
<ElDescriptionsItem label="操作人">{{
recordDetail.operator_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(recordDetail.created_at) }}
</ElDescriptionsItem>
@@ -50,7 +70,14 @@
</ElDescriptions>
<!-- 关联卡列表 -->
<div v-if="recordDetail && recordDetail.related_card_ids && recordDetail.related_card_ids.length > 0" style="margin-top: 20px">
<div
v-if="
recordDetail &&
recordDetail.related_card_ids &&
recordDetail.related_card_ids.length > 0
"
style="margin-top: 20px"
>
<ElDivider content-position="left">关联卡列表</ElDivider>
<ElTable :data="relatedCardsList" border>
<ElTableColumn type="index" label="序号" width="60" />
@@ -155,8 +182,8 @@
.allocation-record-detail-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -53,11 +53,7 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import type {
AssetAllocationRecord,
AllocationTypeEnum,
AssetTypeEnum
} from '@/types/api/card'
import type { AssetAllocationRecord, AllocationTypeEnum, AssetTypeEnum } from '@/types/api/card'
defineOptions({ name: 'AssetAllocationRecords' })

View File

@@ -46,7 +46,9 @@
{{ authorizationDetail.revoker_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="回收时间">
{{ authorizationDetail.revoked_at ? formatDateTime(authorizationDetail.revoked_at) : '-' }}
{{
authorizationDetail.revoked_at ? formatDateTime(authorizationDetail.revoked_at) : '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">
@@ -109,8 +111,8 @@
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -240,10 +240,8 @@
label: '授权人类型',
width: 100,
formatter: (row: AuthorizationItem) => {
return h(
ElTag,
{ type: getAuthorizerTypeTag(row.authorizer_type) },
() => getAuthorizerTypeText(row.authorizer_type)
return h(ElTag, { type: getAuthorizerTypeTag(row.authorizer_type) }, () =>
getAuthorizerTypeText(row.authorizer_type)
)
}
},

View File

@@ -21,7 +21,7 @@
<ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数">
<span style="color: #67c23a; font-weight: bold">{{ deviceInfo.bound_card_count }}</span>
<span style="font-weight: bold; color: #67c23a">{{ deviceInfo.bound_card_count }}</span>
/ {{ deviceInfo.max_sim_slots }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属店铺">
@@ -118,7 +118,11 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect v-model="bindForm.slot_position" placeholder="请选择插槽位置" style="width: 100%">
<ElSelect
v-model="bindForm.slot_position"
placeholder="请选择插槽位置"
style="width: 100%"
>
<ElOption
v-for="slot in availableSlots"
:key="slot"

View File

@@ -56,9 +56,14 @@
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElSelect
@@ -93,7 +98,8 @@
style="margin-bottom: 10px"
>
<template #title>
成功分配 {{ allocateResult.success_count }} 失败 {{ allocateResult.fail_count }}
成功分配 {{ allocateResult.success_count }} 失败
{{ allocateResult.fail_count }}
</template>
</ElAlert>
<div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0">
@@ -101,7 +107,7 @@
<div
v-for="item in allocateResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -129,7 +135,7 @@
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElFormItem label="已选设备数">
<span style="color: #e6a23c; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
@@ -157,7 +163,7 @@
<div
v-for="item in recallResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -182,10 +188,19 @@
</ElDialog>
<!-- 批量设置套餐系列绑定对话框 -->
<ElDialog v-model="seriesBindingDialogVisible" title="批量设置设备套餐系列绑定" width="600px">
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px">
<ElDialog
v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="600px"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span>
<span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="套餐系列分配" prop="series_allocation_id">
<ElSelect
@@ -214,15 +229,18 @@
style="margin-bottom: 10px"
>
<template #title>
成功设置 {{ seriesBindingResult.success_count }} 失败 {{ seriesBindingResult.fail_count }}
成功设置 {{ seriesBindingResult.success_count }} 失败
{{ seriesBindingResult.fail_count }}
</template>
</ElAlert>
<div v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0">
<div
v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0"
>
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in seriesBindingResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
style="margin-bottom: 8px; font-size: 12px; color: #f56c6c"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
@@ -245,6 +263,61 @@
</div>
</template>
</ElDialog>
<!-- 设备详情弹窗 -->
<ElDialog v-model="deviceDetailDialogVisible" title="设备详情" width="900px">
<div v-if="deviceDetailLoading" style="text-align: center; padding: 40px 0">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
</div>
<ElDescriptions v-else-if="currentDeviceDetail" :column="3" border>
<ElDescriptionsItem label="设备ID">{{ currentDeviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
currentDeviceDetail.device_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
currentDeviceDetail.device_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
currentDeviceDetail.device_model || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{
currentDeviceDetail.device_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{
currentDeviceDetail.manufacturer || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{
currentDeviceDetail.max_sim_slots
}}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{
currentDeviceDetail.bound_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getDeviceStatusTagType(currentDeviceDetail.status)">
{{ currentDeviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{
currentDeviceDetail.shop_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{
currentDeviceDetail.batch_no || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
currentDeviceDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
currentDeviceDetail.created_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
currentDeviceDetail.updated_at || '--'
}}</ElDescriptionsItem>
</ElDescriptions>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -255,7 +328,8 @@
import { useRouter } from 'vue-router'
import { DeviceService, ShopService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import { ElMessage, ElMessageBox, ElTag, ElSwitch, ElIcon } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Device,
@@ -301,6 +375,11 @@
})
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 设备详情弹窗相关
const deviceDetailDialogVisible = ref(false)
const deviceDetailLoading = ref(false)
const currentDeviceDetail = ref<any>(null)
// 搜索表单初始值
const initialSearchState = {
device_no: '',
@@ -424,6 +503,40 @@
remark: ''
})
// 查看设备详情(通过弹窗)
const goToDeviceSearchDetail = async (deviceNo: string) => {
deviceDetailDialogVisible.value = true
deviceDetailLoading.value = true
currentDeviceDetail.value = null
try {
const res = await DeviceService.getDeviceByImei(deviceNo)
if (res.code === 0 && res.data) {
currentDeviceDetail.value = res.data
} else {
ElMessage.error(res.message || '查询失败')
deviceDetailDialogVisible.value = false
}
} catch (error: any) {
console.error('查询设备详情失败:', error)
ElMessage.error(error?.message || '查询失败,请检查设备号是否正确')
deviceDetailDialogVisible.value = false
} finally {
deviceDetailLoading.value = false
}
}
// 获取设备状态标签类型
const getDeviceStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info', // 在库
2: 'warning', // 已分销
3: 'success', // 已激活
4: 'danger' // 已停用
}
return typeMap[status] || 'info'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
@@ -434,7 +547,17 @@
{
prop: 'device_no',
label: '设备号',
minWidth: 150
minWidth: 150,
formatter: (row: Device) => {
return h(
'span',
{
style: { color: 'var(--el-color-primary)', cursor: 'pointer' },
onClick: () => goToDeviceSearchDetail(row.device_no)
},
row.device_no
)
}
},
{
prop: 'device_name',

View File

@@ -18,9 +18,7 @@
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSearch"> 查询 </ElButton>
</template>
</ElInput>
</ElFormItem>
@@ -38,25 +36,41 @@
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="设备ID">{{ deviceDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{ deviceDetail.device_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号" :span="2">{{
deviceDetail.device_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ deviceDetail.device_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{ deviceDetail.device_model || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ deviceDetail.device_type || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{
deviceDetail.device_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{
deviceDetail.device_model || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{
deviceDetail.device_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ deviceDetail.manufacturer || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{
deviceDetail.manufacturer || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceDetail.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{ deviceDetail.bound_card_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数量">{{
deviceDetail.bound_card_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(deviceDetail.status)">
{{ deviceDetail.status_name }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ deviceDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{
deviceDetail.shop_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ deviceDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ deviceDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{
deviceDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ deviceDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ deviceDetail.updated_at }}</ElDescriptionsItem>
</ElDescriptions>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
>
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入设备
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
@@ -36,25 +43,149 @@
</ArtTable>
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p
>4.
必填字段device_no设备号device_name设备名称device_model设备型号</p
>
<p
>5.
可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p
>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 任务详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入任务详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.task_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(currentDetail.status)">{{ currentDetail.status_text }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ currentDetail.batch_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.file_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.success_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skip_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.fail_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warning_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.started_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completed_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.error_message
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div
v-if="currentDetail.failed_items && currentDetail.failed_items.length"
style="max-height: 300px; overflow-y: auto"
>
<ElTable :data="currentDetail.failed_items" border size="small">
<ElTableColumn label="行号" type="index" width="80" :index="(index) => index + 1" />
<ElTableColumn label="设备编号" prop="device_no" width="150" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
:icon="Download"
@click="downloadFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus } from '@/types/api/device'
defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const loading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const fileList = ref<File[]>([])
const uploading = ref(false)
const importDialogVisible = ref(false)
const detailDialogVisible = ref(false)
// 搜索表单初始值
const initialSearchState = {
@@ -116,19 +247,21 @@
// 列配置
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '批次号', prop: 'batch_no' },
{ label: '文件名', prop: 'file_name' },
{ label: '任务状态', prop: 'status' },
{ label: '文件名', prop: 'file_name' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'fail_count' },
{ label: '跳过数', prop: 'skip_count' },
{ label: '创建时间', prop: 'created_at' },
{ label: '开始时间', prop: 'started_at' },
{ label: '完成时间', prop: 'completed_at' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
const taskList = ref<DeviceImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
@@ -147,14 +280,22 @@
}
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
router.push({
path: '/asset-management/task-detail',
query: {
id: row.id,
task_type: 'device'
const viewDetail = async (row: DeviceImportTask) => {
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
currentDetail.value = {
...res.data,
started_at: res.data.started_at ? formatDateTime(res.data.started_at) : '-',
completed_at: res.data.completed_at ? formatDateTime(res.data.completed_at) : '-',
created_at: res.data.created_at ? formatDateTime(res.data.created_at) : '-'
}
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
})
}
// 动态列配置
@@ -162,17 +303,7 @@
{
prop: 'task_no',
label: '任务编号',
width: 150
},
{
prop: 'batch_no',
label: '批次号',
width: 120
},
{
prop: 'file_name',
label: '文件名',
minWidth: 200
width: 180
},
{
prop: 'status',
@@ -182,6 +313,12 @@
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
}
},
{
prop: 'file_name',
label: '文件名',
minWidth: 250,
showOverflowTooltip: true
},
{
prop: 'total_count',
label: '总数',
@@ -190,15 +327,17 @@
{
prop: 'success_count',
label: '成功数',
width: 80
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: DeviceImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success'
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
@@ -207,27 +346,59 @@
width: 80
},
{
prop: 'created_at',
label: '创建时间',
width: 160,
formatter: (row: DeviceImportTask) => formatDateTime(row.created_at)
prop: 'started_at',
label: '开始时间',
width: 180,
formatter: (row: DeviceImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
},
{
prop: 'completed_at',
label: '完成时间',
width: 160,
formatter: (row: DeviceImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
width: 180,
formatter: (row: DeviceImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
},
{
prop: 'error_message',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: DeviceImportTask) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 100,
width: 180,
fixed: 'right',
formatter: (row: DeviceImportTask) => {
return h(ArtButtonTable, {
type: 'view',
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
)
// 如果有失败数据,显示"失败数据"按钮
if (row.fail_count > 0) {
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailDataByRow(row)
})
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -262,7 +433,7 @@
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
taskList.value = res.data.list || []
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
@@ -301,10 +472,195 @@
pagination.page = newCurrentPage
getTableData()
}
// 下载模板
const downloadTemplate = () => {
const csvContent = [
'device_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
'DEV001,智能水表01,WM-2000,智能水表,华为,1',
'DEV002,GPS定位器01,GPS-3000,定位设备,小米,2',
'DEV003,智能燃气表01,GM-1500,智能燃气表,海尔,1'
].join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', '设备导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('设备导入模板下载成功')
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 10 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: `DEV-${Date.now()}`
})
if (importRes.code !== 0) {
throw new Error(importRes.msg || '创建导入任务失败')
}
const taskNo = importRes.data.task_no
handleCancelImport()
getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
showClose: true
})
} catch (error: any) {
console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败')
} finally {
uploading.value = false
}
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
downloadFailDataFromDetail(detail, row.batch_no)
}
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
// 下载失败数据(从详情对话框)
const downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.batch_no)
}
// 下载失败数据的通用方法
const downloadFailDataFromDetail = (detail: any, batchNo: string) => {
const failReasons =
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
deviceCode: item.device_no || '-',
iccid: item.iccid || '-',
message: item.reason || item.error || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${batchNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
</script>
<style lang="scss" scoped>
.device-task-page {
// Device task page styles
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;
color: var(--el-text-color-placeholder);
}
:deep(.el-upload__text) {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -90,7 +90,7 @@
:placeholder="$t('enterpriseDevices.form.deviceNosPlaceholder')"
@input="handleDeviceNosChange"
/>
<div style="color: var(--el-color-info); margin-top: 4px; font-size: 12px">
<div style="margin-top: 4px; font-size: 12px; color: var(--el-color-info)">
{{
$t('enterpriseDevices.form.selectedCount', {
count: allocateForm.device_nos?.length || 0
@@ -110,9 +110,7 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">{{
$t('common.cancel')
}}</ElButton>
<ElButton @click="allocateDialogVisible = false">{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" @click="handleAllocate" :loading="allocateLoading">
{{ $t('common.confirm') }}
</ElButton>
@@ -635,8 +633,8 @@
.enterprise-devices-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
}
</style>

View File

@@ -19,13 +19,25 @@
>
<template #left>
<ElButton type="primary" @click="showImportDialog">导入ICCID</ElButton>
<ElButton type="success" :disabled="selectedCards.length === 0" @click="showAllocateDialog">
<ElButton
type="success"
:disabled="selectedCards.length === 0"
@click="showAllocateDialog"
>
批量分配
</ElButton>
<ElButton type="warning" :disabled="selectedCards.length === 0" @click="showRecallDialog">
<ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showRecallDialog"
>
批量回收
</ElButton>
<ElButton type="info" :disabled="selectedCards.length === 0" @click="showSeriesBindingDialog">
<ElButton
type="info"
:disabled="selectedCards.length === 0"
@click="showSeriesBindingDialog"
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="cardDistribution">网卡分销</ElButton>
@@ -65,7 +77,11 @@
>
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="100px">
<ElFormItem label="运营商" prop="carrier_id">
<ElSelect v-model="importForm.carrier_id" placeholder="请选择运营商" style="width: 100%">
<ElSelect
v-model="importForm.carrier_id"
placeholder="请选择运营商"
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
@@ -91,7 +107,7 @@
<template #tip>
<div class="el-upload__tip">
<div>只支持上传CSV文件且不超过10MB</div>
<div style="color: var(--el-color-info); margin-top: 4px">
<div style="margin-top: 4px; color: var(--el-color-info)">
CSV格式ICCID,MSISDN两列逗号分隔每行一条记录
</div>
</div>
@@ -116,9 +132,18 @@
width="600px"
@close="handleAllocateDialogClose"
>
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElForm
ref="allocateFormRef"
:model="allocateForm"
:rules="allocateRules"
label-width="120px"
>
<ElFormItem label="目标店铺" prop="to_shop_id">
<ElSelect v-model="allocateForm.to_shop_id" placeholder="请选择目标店铺" style="width: 100%">
<ElSelect
v-model="allocateForm.to_shop_id"
placeholder="请选择目标店铺"
style="width: 100%"
>
<ElOption label="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" />
@@ -136,22 +161,40 @@
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'range'" label="起始ICCID" prop="iccid_start">
<ElFormItem
v-if="allocateForm.selection_type === 'range'"
label="起始ICCID"
prop="iccid_start"
>
<ElInput v-model="allocateForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'range'" label="结束ICCID" prop="iccid_end">
<ElFormItem
v-if="allocateForm.selection_type === 'range'"
label="结束ICCID"
prop="iccid_end"
>
<ElInput v-model="allocateForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="运营商">
<ElSelect v-model="allocateForm.carrier_id" placeholder="请选择运营商" clearable style="width: 100%">
<ElSelect
v-model="allocateForm.carrier_id"
placeholder="请选择运营商"
clearable
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="卡状态">
<ElSelect v-model="allocateForm.status" placeholder="请选择状态" clearable style="width: 100%">
<ElSelect
v-model="allocateForm.status"
placeholder="请选择状态"
clearable
style="width: 100%"
>
<ElOption label="在库" :value="1" />
<ElOption label="已分销" :value="2" />
<ElOption label="已激活" :value="3" />
@@ -163,7 +206,12 @@
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="allocateForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
<ElInput
v-model="allocateForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -185,7 +233,11 @@
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px">
<ElFormItem label="来源店铺" prop="from_shop_id">
<ElSelect v-model="recallForm.from_shop_id" placeholder="请选择来源店铺" style="width: 100%">
<ElSelect
v-model="recallForm.from_shop_id"
placeholder="请选择来源店铺"
style="width: 100%"
>
<ElOption label="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" />
@@ -203,15 +255,28 @@
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'range'" label="起始ICCID" prop="iccid_start">
<ElFormItem
v-if="recallForm.selection_type === 'range'"
label="起始ICCID"
prop="iccid_start"
>
<ElInput v-model="recallForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'range'" label="结束ICCID" prop="iccid_end">
<ElFormItem
v-if="recallForm.selection_type === 'range'"
label="结束ICCID"
prop="iccid_end"
>
<ElInput v-model="recallForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'filter'" label="运营商">
<ElSelect v-model="recallForm.carrier_id" placeholder="请选择运营商" clearable style="width: 100%">
<ElSelect
v-model="recallForm.carrier_id"
placeholder="请选择运营商"
clearable
style="width: 100%"
>
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
@@ -222,7 +287,12 @@
</ElFormItem>
<ElFormItem label="备注">
<ElInput v-model="recallForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" />
<ElInput
v-model="recallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem>
</ElForm>
<template #footer>
@@ -236,14 +306,14 @@
</ElDialog>
<!-- 分配结果对话框 -->
<ElDialog
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
>
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="操作单号">{{ allocationResult.allocation_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="待处理总数">{{ allocationResult.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作单号">{{
allocationResult.allocation_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="待处理总数">{{
allocationResult.total_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ allocationResult.success_count }}</ElTag>
</ElDescriptionsItem>
@@ -252,7 +322,10 @@
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="allocationResult.failed_items && allocationResult.failed_items.length > 0" style="margin-top: 20px">
<div
v-if="allocationResult.failed_items && allocationResult.failed_items.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="allocationResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -274,7 +347,12 @@
width="600px"
@close="handleSeriesBindingDialogClose"
>
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px">
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选择卡数">
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
@@ -307,11 +385,7 @@
</ElDialog>
<!-- 套餐系列绑定结果对话框 -->
<ElDialog
v-model="seriesBindingResultDialogVisible"
title="设置结果"
width="700px"
>
<ElDialog v-model="seriesBindingResultDialogVisible" title="设置结果" width="700px">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ seriesBindingResult.success_count }}</ElTag>
@@ -321,7 +395,10 @@
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0" style="margin-top: 20px">
<div
v-if="seriesBindingResult.failed_items && seriesBindingResult.failed_items.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="seriesBindingResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -331,11 +408,99 @@
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="seriesBindingResultDialogVisible = false">确定</ElButton>
<ElButton type="primary" @click="seriesBindingResultDialogVisible = false"
>确定</ElButton
>
</div>
</template>
</ElDialog>
<!-- 卡详情对话框 -->
<ElDialog v-model="cardDetailDialogVisible" title="卡片详情" width="900px">
<div v-if="cardDetailLoading" style="text-align: center; padding: 40px">
<ElIcon class="is-loading" :size="40"><Loading /></ElIcon>
<div style="margin-top: 16px">加载中...</div>
</div>
<ElDescriptions v-else-if="currentCardDetail" :column="3" border>
<ElDescriptionsItem label="卡ID">{{ currentCardDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID" :span="2">{{
currentCardDetail.iccid
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡接入号">{{
currentCardDetail.msisdn || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{
currentCardDetail.carrier_name || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{
getCarrierTypeText(currentCardDetail.carrier_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{
currentCardDetail.card_type || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{
getCardCategoryText(currentCardDetail.card_category)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{
formatCardPrice(currentCardDetail.cost_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{
formatCardPrice(currentCardDetail.distribute_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getCardDetailStatusTagType(currentCardDetail.status)">
{{ getCardDetailStatusText(currentCardDetail.status) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="激活状态">
<ElTag :type="currentCardDetail.activation_status === 1 ? 'success' : 'info'">
{{ currentCardDetail.activation_status === 1 ? '已激活' : '未激活' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="实名状态">
<ElTag :type="currentCardDetail.real_name_status === 1 ? 'success' : 'warning'">
{{ currentCardDetail.real_name_status === 1 ? '已实名' : '未实名' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="网络状态">
<ElTag :type="currentCardDetail.network_status === 1 ? 'success' : 'danger'">
{{ currentCardDetail.network_status === 1 ? '开机' : '停机' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用"
>{{ currentCardDetail.data_usage_mb }} MB</ElDescriptionsItem
>
<ElDescriptionsItem label="首次佣金">
<ElTag :type="currentCardDetail.first_commission_paid ? 'success' : 'info'">
{{ currentCardDetail.first_commission_paid ? '已支付' : '未支付' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="累计充值">{{
formatCardPrice(currentCardDetail.accumulated_recharge)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(currentCardDetail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间" :span="2">{{
formatDateTime(currentCardDetail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未找到卡片信息" />
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="cardDetailDialogVisible = false">关闭</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -343,9 +508,11 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService, StorageService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation'
import { ElMessage, ElTag, ElUpload } from 'element-plus'
import { ElMessage, ElTag, ElUpload, ElIcon } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadProps, UploadUserFile } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -362,6 +529,7 @@
defineOptions({ name: 'StandaloneCardList' })
const router = useRouter()
const loading = ref(false)
const importDialogVisible = ref(false)
const importLoading = ref(false)
@@ -405,13 +573,17 @@
failed_items: null
})
// 卡详情弹窗相关
const cardDetailDialogVisible = ref(false)
const cardDetailLoading = ref(false)
const currentCardDetail = ref<any>(null)
// 搜索表单初始值
const initialSearchState = {
status: undefined,
carrier_id: undefined,
iccid: '',
msisdn: '',
batch_no: '',
is_distributed: undefined
}
@@ -576,15 +748,6 @@
placeholder: '请输入卡接入号'
}
},
{
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{
label: '是否已分销',
prop: 'is_distributed',
@@ -653,12 +816,88 @@
}
}
// 打开卡详情弹窗
const goToCardDetail = async (iccid: string) => {
cardDetailDialogVisible.value = true
cardDetailLoading.value = true
currentCardDetail.value = null
try {
const res = await CardService.getIotCardDetailByIccid(iccid)
if (res.code === 0 && res.data) {
currentCardDetail.value = res.data
} else {
ElMessage.error(res.message || '查询失败')
cardDetailDialogVisible.value = false
}
} catch (error: any) {
console.error('查询卡片详情失败:', error)
ElMessage.error(error?.message || '查询失败请检查ICCID是否正确')
cardDetailDialogVisible.value = false
} finally {
cardDetailLoading.value = false
}
}
// 卡详情辅助函数
const getCarrierTypeText = (type: string) => {
const typeMap: Record<string, string> = {
CMCC: '中国移动',
CUCC: '中国联通',
CTCC: '中国电信',
CBN: '中国广电'
}
return typeMap[type] || type
}
const getCardCategoryText = (category: string) => {
const categoryMap: Record<string, string> = {
normal: '普通卡',
industry: '行业卡'
}
return categoryMap[category] || category
}
const getCardDetailStatusText = (status: number) => {
const statusMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
return statusMap[status] || '未知'
}
const getCardDetailStatusTagType = (status: number) => {
const typeMap: Record<number, any> = {
1: 'info',
2: 'warning',
3: 'success',
4: 'danger'
}
return typeMap[status] || 'info'
}
const formatCardPrice = (price: number) => {
return `¥${(price / 100).toFixed(2)}`
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'iccid',
label: 'ICCID',
minWidth: 190
minWidth: 200,
formatter: (row: StandaloneIotCard) => {
return h(
'span',
{
style: { color: 'var(--el-color-primary)', cursor: 'pointer' },
onClick: () => goToCardDetail(row.iccid)
},
row.iccid
)
}
},
{
prop: 'msisdn',
@@ -754,7 +993,7 @@
{
prop: 'created_at',
label: '创建时间',
width: 160,
width: 180,
formatter: (row: StandaloneIotCard) => formatDateTime(row.created_at)
}
])
@@ -897,7 +1136,9 @@
})
if (importRes.code === 0) {
ElMessage.success(importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度')
ElMessage.success(
importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度'
)
importDialogVisible.value = false
getTableData()
}
@@ -1167,7 +1408,9 @@
} else if (res.data.success_count === 0) {
ElMessage.error('套餐系列绑定设置失败')
} else {
ElMessage.warning(`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`)
ElMessage.warning(
`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`
)
}
}
} catch (error) {

View File

@@ -18,9 +18,7 @@
@keyup.enter="handleSearch"
>
<template #append>
<ElButton type="primary" :loading="loading" @click="handleSearch">
查询
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSearch"> 查询 </ElButton>
</template>
</ElInput>
</ElFormItem>
@@ -44,9 +42,13 @@
<ElDescriptionsItem label="卡接入号">{{ cardDetail.msisdn || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ cardDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{ getCarrierTypeText(cardDetail.carrier_type) }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商类型">{{
getCarrierTypeText(cardDetail.carrier_type)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="卡类型">{{ cardDetail.card_type }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{ getCardCategoryText(cardDetail.card_category) }}</ElDescriptionsItem>
<ElDescriptionsItem label="卡业务类型">{{
getCardCategoryText(cardDetail.card_category)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusTagType(cardDetail.status)">
@@ -73,11 +75,19 @@
<ElDescriptionsItem label="供应商">{{ cardDetail.supplier || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺名称">{{ cardDetail.shop_name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{ formatPrice(cardDetail.cost_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{ formatPrice(cardDetail.distribute_price) }}</ElDescriptionsItem>
<ElDescriptionsItem label="成本价">{{
formatPrice(cardDetail.cost_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="分销价">{{
formatPrice(cardDetail.distribute_price)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用">{{ cardDetail.data_usage_mb }} MB</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">{{ cardDetail.activated_at || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="累计流量使用"
>{{ cardDetail.data_usage_mb }} MB</ElDescriptionsItem
>
<ElDescriptionsItem label="激活时间">{{
cardDetail.activated_at || '--'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ cardDetail.created_at }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
>
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入IoT卡
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
@@ -36,25 +43,152 @@
</ArtTable>
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入IoT卡" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 CSV 模板文件按照模板格式填写IoT卡信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段iccidICCIDmsisdnMSISDN/手机号</p>
<p>5. 必须选择运营商</p>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElFormItem label="运营商" required style="margin-bottom: 20px">
<ElSelect v-model="selectedCarrierId" placeholder="请选择运营商" style="width: 100%">
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
</ElSelect>
</ElFormItem>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length || !selectedCarrierId"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 任务详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="IoT卡导入任务详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.task_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(currentDetail.status)">{{ currentDetail.status_text }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ currentDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.file_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.success_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skip_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.fail_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warning_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.started_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completed_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.error_message
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div
v-if="currentDetail.failed_items && currentDetail.failed_items.length"
style="max-height: 300px; overflow-y: auto"
>
<ElTable :data="currentDetail.failed_items" border size="small">
<ElTableColumn label="行号" type="index" width="80" :index="(index) => index + 1" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="MSISDN" prop="msisdn" width="150" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
:icon="Download"
@click="downloadFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { ElMessage, ElTag, ElFormItem, ElSelect, ElOption } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
defineOptions({ name: 'IotCardTask' })
const router = useRouter()
const loading = ref(false)
const tableRef = ref()
const uploadRef = ref<UploadInstance>()
const fileList = ref<File[]>([])
const uploading = ref(false)
const importDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const selectedCarrierId = ref<number>()
// 搜索表单初始值
const initialSearchState = {
@@ -145,6 +279,7 @@
]
const taskList = ref<IotCardImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型
const getStatusType = (status: IotCardImportTaskStatus) => {
@@ -163,14 +298,24 @@
}
// 查看详情
const viewDetail = (row: IotCardImportTask) => {
router.push({
path: '/asset-management/task-detail',
query: {
id: row.id,
task_type: 'card'
const viewDetail = async (row: IotCardImportTask) => {
try {
const res = await CardService.getIotCardImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
currentDetail.value = {
...res.data,
started_at: res.data.started_at ? formatDateTime(res.data.started_at) : '-',
completed_at: res.data.completed_at ? formatDateTime(res.data.completed_at) : '-',
created_at: res.data.created_at ? formatDateTime(res.data.created_at) : '-',
carrier_name: res.data.carrier_name || '-',
error_message: res.data.error_message || '-'
}
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
})
}
// 动态列配置
@@ -196,7 +341,8 @@
{
prop: 'file_name',
label: '文件名',
minWidth: 250
minWidth: 250,
showOverflowTooltip: true
},
{
prop: 'total_count',
@@ -206,15 +352,17 @@
{
prop: 'success_count',
label: '成功数',
width: 80
width: 80,
formatter: (row: IotCardImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: IotCardImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success'
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
@@ -225,37 +373,57 @@
{
prop: 'started_at',
label: '开始时间',
width: 160,
width: 180,
formatter: (row: IotCardImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
},
{
prop: 'completed_at',
label: '完成时间',
width: 160,
formatter: (row: IotCardImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
width: 180,
formatter: (row: IotCardImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
},
{
prop: 'error_message',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true,
formatter: (row: IotCardImportTask) => row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 160,
width: 180,
formatter: (row: IotCardImportTask) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 120,
width: 180,
fixed: 'right',
formatter: (row: IotCardImportTask) => {
return h(ArtButtonTable, {
text: '查看详情',
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
)
// 如果有失败数据,显示"失败数据"按钮
if (row.fail_count > 0) {
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailDataByRow(row)
})
)
}
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
@@ -330,10 +498,202 @@
pagination.page = newCurrentPage
getTableData()
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: IotCardImportTask) => {
try {
const res = await CardService.getIotCardImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
downloadFailDataFromDetail(detail, row.task_no)
}
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
// 下载失败数据(从详情对话框)
const downloadFailData = () => {
downloadFailDataFromDetail(currentDetail.value, currentDetail.value.task_no)
}
// 下载失败数据的通用方法
const downloadFailDataFromDetail = (detail: any, taskNo: string) => {
const failReasons =
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
iccid: item.iccid || '-',
msisdn: item.msisdn || '-',
message: item.reason || item.error || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', 'ICCID', 'MSISDN', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.row, item.iccid, item.msisdn, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `IoT卡导入失败数据_${taskNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
// 下载模板
const downloadTemplate = () => {
const csvContent = [
'iccid,msisdn',
'89860123456789012345,13800138000',
'89860123456789012346,13800138001',
'89860123456789012347,13800138002'
].join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', 'IoT卡导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('IoT卡导入模板下载成功')
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 10 * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 10MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.csv')) {
ElMessage.error('只能上传 CSV 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
selectedCarrierId.value = undefined
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!selectedCarrierId.value) {
ElMessage.warning('请先选择运营商')
return
}
if (!fileList.value.length) {
ElMessage.warning('请先选择CSV文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'text/csv',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
throw new Error(uploadUrlRes.msg || '获取上传地址失败')
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
ElMessage.info('正在创建导入任务...')
const importRes = await CardService.importIotCards({
carrier_id: selectedCarrierId.value,
file_key,
batch_no: `IOT-${Date.now()}`
})
if (importRes.code !== 0) {
throw new Error(importRes.msg || '创建导入任务失败')
}
const taskNo = importRes.data.task_no
handleCancelImport()
getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
showClose: true
})
} catch (error: any) {
console.error('IoT卡导入失败:', error)
ElMessage.error(error.message || 'IoT卡导入失败')
} finally {
uploading.value = false
}
}
</script>
<style lang="scss" scoped>
.iot-card-task-page {
// IoT card task page styles
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;
color: var(--el-text-color-placeholder);
}
:deep(.el-upload__text) {
font-size: 14px;
color: var(--el-text-color-regular);
em {
font-style: normal;
color: var(--el-color-primary);
}
}
}
</style>

View File

@@ -62,18 +62,8 @@
</ElDivider>
<ElTable :data="taskDetail.failed_items" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="device_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="失败原因" min-width="300" />
</ElTable>
</div>
@@ -85,18 +75,8 @@
</ElDivider>
<ElTable :data="taskDetail.skipped_items" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="device_no" label="设备号" min-width="180" />
<ElTableColumn prop="reason" label="跳过原因" min-width="300" />
</ElTable>
</div>
@@ -108,7 +88,15 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { CardService, DeviceService } from '@/api/modules'
import { ElMessage, ElTag, ElDescriptions, ElDescriptionsItem, ElDivider, ElTable, ElTableColumn } from 'element-plus'
import {
ElMessage,
ElTag,
ElDescriptions,
ElDescriptionsItem,
ElDivider,
ElTable,
ElTableColumn
} from 'element-plus'
import { formatDateTime } from '@/utils/business/format'
import type { IotCardImportTaskDetail, IotCardImportTaskStatus } from '@/types/api/card'
import type { DeviceImportTaskDetail } from '@/types/api/device'

View File

@@ -15,8 +15,14 @@
<p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段device_no设备号device_name设备名称device_model设备型号</p>
<p>5. 可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p>
<p
>4.
必填字段device_no设备号device_name设备名称device_model设备型号</p
>
<p
>5.
可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p
>
<p>6. 设备号重复将自动跳过导入后可在任务管理中查看详情</p>
</div>
</template>
@@ -59,52 +65,6 @@
</ElRow>
</ElCard>
<!-- 导入统计 -->
<ElRow :gutter="20" style="margin-top: 20px">
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">今日导入</div>
<div class="stat-value">1,250</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-primary)"><Upload /></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功绑定</div>
<div class="stat-value" style="color: var(--el-color-success)">1,180</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-success)"
><SuccessFilled
/></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">导入失败</div>
<div class="stat-value" style="color: var(--el-color-danger)">70</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-danger)"
><CircleCloseFilled
/></el-icon>
</ElCard>
</ElCol>
<ElCol :xs="24" :sm="12" :lg="6">
<ElCard shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-label">成功率</div>
<div class="stat-value">94.4%</div>
</div>
<el-icon class="stat-icon" style="color: var(--el-color-warning)"
><TrendCharts
/></el-icon>
</ElCard>
</ElCol>
</ElRow>
<!-- 导入记录 -->
<ElCard shadow="never" style="margin-top: 20px">
<template #header>
@@ -117,71 +77,27 @@
style="width: 120px; margin-right: 12px"
clearable
>
<ElOption label="全部" value="" />
<ElOption label="处理" value="processing" />
<ElOption label="完成" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="全部" :value="null" />
<ElOption label="处理" :value="1" />
<ElOption label="处理中" :value="2" />
<ElOption label="已完成" :value="3" />
<ElOption label="失败" :value="4" />
</ElSelect>
<ElButton @click="refreshList">刷新</ElButton>
</div>
</div>
</template>
<ArtTable :data="filteredRecords" index>
<template #default>
<ElTableColumn label="导入批次号" prop="batchNo" width="180" />
<ElTableColumn label="文件名" prop="fileName" min-width="200" show-overflow-tooltip />
<ElTableColumn label="设备总数" prop="totalCount" width="100" />
<ElTableColumn label="成功数" prop="successCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-success)">{{ scope.row.successCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="失败数" prop="failCount" width="100">
<template #default="scope">
<span style="color: var(--el-color-danger)">{{ scope.row.failCount }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="已绑定ICCID" prop="bindCount" width="120">
<template #default="scope">
<ElTag type="success" size="small">{{ scope.row.bindCount }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入状态" prop="status" width="120">
<template #default="scope">
<ElTag v-if="scope.row.status === 'processing'" type="warning">
<el-icon class="is-loading"><Loading /></el-icon>
处理中
</ElTag>
<ElTag v-else-if="scope.row.status === 'success'" type="success">完成</ElTag>
<ElTag v-else-if="scope.row.status === 'failed'" type="danger">失败</ElTag>
<ElTag v-else type="info">待处理</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="导入进度" prop="progress" width="150">
<template #default="scope">
<ElProgress
:percentage="scope.row.progress"
:status="scope.row.status === 'failed' ? 'exception' : undefined"
/>
</template>
</ElTableColumn>
<ElTableColumn label="导入时间" prop="importTime" width="180" />
<ElTableColumn label="操作人" prop="operator" width="120" />
<ElTableColumn fixed="right" label="操作" width="200">
<template #default="scope">
<el-button link :icon="View" @click="viewDetail(scope.row)">查看详情</el-button>
<el-button
v-if="scope.row.failCount > 0"
link
type="primary"
:icon="Download"
@click="downloadFailData(scope.row)"
<ArtTable
rowKey="id"
ref="tableRef"
:loading="loading"
:data="filteredRecords"
:marginTop="10"
:stripe="false"
>
失败数据
</el-button>
</template>
</ElTableColumn>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
@@ -189,20 +105,31 @@
<!-- 导入详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.taskNo
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{ currentDetail.statusText }}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ currentDetail.fileName }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功导入">
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.fileName
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入失败">
<ElDescriptionsItem label="跳过数">{{ currentDetail.skipCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定ICCID">
<ElTag type="success">{{ currentDetail.bindCount }}</ElTag>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warningCount }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{ currentDetail.importTime }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentDetail.operator }}</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.startedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.createdAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.errorMessage
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
@@ -235,22 +162,16 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { h, computed, watch } from 'vue'
import { ElMessage, ElTag, ElProgress, ElIcon, ElButton } from 'element-plus'
import { useRouter } from 'vue-router'
import {
Download,
UploadFilled,
View,
Loading,
Upload,
SuccessFilled,
CircleCloseFilled,
TrendCharts
} from '@element-plus/icons-vue'
import { Download, UploadFilled, View, Loading } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import { StorageService } from '@/api/modules/storage'
import { DeviceService } from '@/api/modules'
import { formatDateTime } from '@/utils/business/format'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
defineOptions({ name: 'DeviceImport' })
@@ -265,107 +186,213 @@
interface ImportRecord {
id: string
taskNo: string
status: number
statusText: string
batchNo: string
fileName: string
totalCount: number
successCount: number
skipCount: number
failCount: number
bindCount: number
status: 'pending' | 'processing' | 'success' | 'failed'
progress: number
importTime: string
operator: string
warningCount: number
startedAt: string
completedAt: string
errorMessage: string
createdAt: string
failReasons?: FailReason[]
}
const uploadRef = ref<UploadInstance>()
const tableRef = ref()
const fileList = ref<File[]>([])
const uploading = ref(false)
const detailDialogVisible = ref(false)
const statusFilter = ref('')
const statusFilter = ref<number | null>(null)
const loading = ref(false)
const importRecords = ref<ImportRecord[]>([
{
id: '1',
batchNo: 'DEV20260109001',
fileName: '设备导入模板_20260109.xlsx',
totalCount: 300,
successCount: 285,
failCount: 15,
bindCount: 285,
status: 'success',
progress: 100,
importTime: '2026-01-09 11:30:00',
operator: 'admin',
failReasons: [
{ row: 12, deviceCode: 'DEV001', iccid: '89860123456789012345', message: 'ICCID 不存在' },
{ row: 23, deviceCode: 'DEV002', iccid: '89860123456789012346', message: '设备编号已存在' },
{ row: 45, deviceCode: '', iccid: '89860123456789012347', message: '设备编号为空' },
{ row: 67, deviceCode: 'DEV003', iccid: '', message: 'ICCID 为空' },
{ row: 89, deviceCode: 'DEV004', iccid: '89860123456789012348', message: '设备类型不存在' }
]
},
{
id: '2',
batchNo: 'DEV20260108001',
fileName: '智能水表设备批量导入.xlsx',
totalCount: 150,
successCount: 150,
failCount: 0,
bindCount: 150,
status: 'success',
progress: 100,
importTime: '2026-01-08 14:20:00',
operator: 'admin'
},
{
id: '3',
batchNo: 'DEV20260107001',
fileName: 'GPS定位器导入.xlsx',
totalCount: 200,
successCount: 180,
failCount: 20,
bindCount: 180,
status: 'success',
progress: 100,
importTime: '2026-01-07 10:15:00',
operator: 'operator01',
failReasons: [
{
row: 10,
deviceCode: 'GPS001',
iccid: '89860123456789012349',
message: 'ICCID 已被其他设备绑定'
},
{ row: 20, deviceCode: 'GPS002', iccid: '89860123456789012350', message: 'ICCID 状态异常' }
]
}
])
const importRecords = ref<ImportRecord[]>([])
const currentDetail = ref<ImportRecord>({
id: '',
taskNo: '',
status: 1,
statusText: '',
batchNo: '',
fileName: '',
totalCount: 0,
successCount: 0,
skipCount: 0,
failCount: 0,
bindCount: 0,
status: 'pending',
progress: 0,
importTime: '',
operator: ''
warningCount: 0,
startedAt: '',
completedAt: '',
errorMessage: '',
createdAt: ''
})
const filteredRecords = computed(() => {
if (!statusFilter.value) return importRecords.value
if (statusFilter.value === null) return importRecords.value
return importRecords.value.filter((item) => item.status === statusFilter.value)
})
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'taskNo',
label: '任务编号',
width: 200,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 120,
formatter: (row: ImportRecord) => {
if (row.status === 1) {
return h(ElTag, { type: 'info' }, () => '待处理')
} else if (row.status === 2) {
return h(
ElTag,
{ type: 'warning' },
{
default: () => [
h(ElIcon, { class: 'is-loading' }, () => h(Loading)),
h('span', { style: { marginLeft: '4px' } }, '处理中')
]
}
)
} else if (row.status === 3) {
return h(ElTag, { type: 'success' }, () => '已完成')
} else if (row.status === 4) {
return h(ElTag, { type: 'danger' }, () => '失败')
} else {
return h(ElTag, { type: 'info' }, () => row.statusText || '-')
}
}
},
{
prop: 'batchNo',
label: '批次号',
width: 180,
showOverflowTooltip: true
},
{
prop: 'fileName',
label: '文件名',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'totalCount',
label: '总数',
width: 100
},
{
prop: 'successCount',
label: '成功数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.successCount)
}
},
{
prop: 'skipCount',
label: '跳过数',
width: 100
},
{
prop: 'failCount',
label: '失败数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.failCount)
}
},
{
prop: 'warningCount',
label: '警告数',
width: 100,
formatter: (row: ImportRecord) => {
return h('span', { style: { color: 'var(--el-color-warning)' } }, row.warningCount)
}
},
{
prop: 'startedAt',
label: '开始时间',
width: 180
},
{
prop: 'completedAt',
label: '完成时间',
width: 180
},
{
prop: 'errorMessage',
label: '错误信息',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'createdAt',
label: '创建时间',
width: 180
},
{
prop: 'operation',
label: '操作',
width: 180,
fixed: 'right',
formatter: (row: ImportRecord) => {
const buttons = []
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row)
})
),
buttons.push(
h(ArtButtonTable, {
text: '失败数据',
type: 'danger',
onClick: () => downloadFailData(row)
})
)
return h('div', { style: 'display: flex; gap: 8px;' }, buttons)
}
}
])
const downloadTemplate = () => {
ElMessage.success('模板下载中...')
setTimeout(() => {
// CSV模板内容 - 包含表头和示例数据
const csvContent = [
// 表头
'device_no,device_name,device_model,device_type,manufacturer,max_sim_slots',
// 示例数据
'DEV001,智能水表01,WM-2000,智能水表,华为,1',
'DEV002,GPS定位器01,GPS-3000,定位设备,小米,2',
'DEV003,智能燃气表01,GM-1500,智能燃气表,海尔,1'
].join('\n')
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建下载链接
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', '设备导入模板.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('设备导入模板下载成功')
}, 1000)
}
const handleFileChange = (uploadFile: any) => {
@@ -444,17 +471,15 @@
// 清空文件列表
clearFiles()
// 显示成功消息并提供跳转链接
// 刷新任务列表
await fetchImportTasks()
// 显示成功消息
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 5000,
duration: 3000,
showClose: true
})
// 3秒后跳转到任务管理页面
setTimeout(() => {
router.push('/asset-management/task-management')
}, 3000)
} catch (error: any) {
console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败')
@@ -463,21 +488,133 @@
}
}
const refreshList = () => {
ElMessage.success('刷新成功')
// 获取导入任务列表
const fetchImportTasks = async () => {
loading.value = true
try {
const params: any = {
page: 1,
page_size: 100
}
const viewDetail = (row: ImportRecord) => {
currentDetail.value = { ...row }
// 如果有状态筛选,添加到参数
if (statusFilter.value !== null) {
params.status = statusFilter.value
}
const res = await DeviceService.getImportTasks(params)
if (res.code === 0 && res.data) {
// 将API返回的数据映射到本地格式
importRecords.value = res.data.items.map((item: any) => ({
id: item.id.toString(),
taskNo: item.task_no || '-',
status: item.status,
statusText: item.status_text || '-',
batchNo: item.batch_no || '-',
fileName: item.file_name || '-',
totalCount: item.total_count || 0,
successCount: item.success_count || 0,
skipCount: item.skip_count || 0,
failCount: item.fail_count || 0,
warningCount: item.warning_count || 0,
startedAt: item.started_at ? formatDateTime(item.started_at) : '-',
completedAt: item.completed_at ? formatDateTime(item.completed_at) : '-',
errorMessage: item.error_message || '-',
createdAt: item.created_at ? formatDateTime(item.created_at) : '-'
}))
}
} catch (error) {
console.error('获取导入任务列表失败:', error)
ElMessage.error('获取导入任务列表失败')
} finally {
loading.value = false
}
}
const refreshList = () => {
fetchImportTasks()
}
const viewDetail = async (row: ImportRecord) => {
try {
const res = await DeviceService.getImportTaskDetail(Number(row.id))
if (res.code === 0 && res.data) {
const detail = res.data
currentDetail.value = {
id: detail.id.toString(),
taskNo: detail.task_no || '-',
status: detail.status,
statusText: detail.status_text || '-',
batchNo: detail.batch_no || '-',
fileName: detail.file_name || '-',
totalCount: detail.total_count || 0,
successCount: detail.success_count || 0,
skipCount: detail.skip_count || 0,
failCount: detail.fail_count || 0,
warningCount: detail.warning_count || 0,
startedAt: detail.started_at ? formatDateTime(detail.started_at) : '-',
completedAt: detail.completed_at ? formatDateTime(detail.completed_at) : '-',
errorMessage: detail.error_message || '-',
createdAt: detail.created_at ? formatDateTime(detail.created_at) : '-',
failReasons:
detail.failed_items?.map((item: any, index: number) => ({
row: index + 1,
deviceCode: item.device_no || '-',
iccid: item.iccid || '-',
message: item.reason || item.error || '未知错误'
})) || []
}
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
}
const downloadFailData = (row: ImportRecord) => {
ElMessage.info(`正在下载批次 ${row.batchNo} 的失败数据...`)
setTimeout(() => {
ElMessage.success('失败数据下载完成')
}, 1000)
if (!row.failReasons || row.failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
// 生成失败数据CSV
const headers = ['行号', '设备编号', 'ICCID', '失败原因']
const csvRows = [
headers.join(','),
...row.failReasons.map((item) =>
[item.row, item.deviceCode, item.iccid, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
// 添加 BOM 头确保 Excel 正确识别 UTF-8 编码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
// 创建下载链接
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${row.batchNo}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
}
// 页面加载时获取任务列表
onMounted(() => {
fetchImportTasks()
})
// 监听状态筛选变化
watch(statusFilter, () => {
fetchImportTasks()
})
</script>
<style lang="scss" scoped>

View File

@@ -125,7 +125,12 @@
</ElTableColumn>
<ElTableColumn label="订单号" prop="order_no" min-width="180" show-overflow-tooltip />
<ElTableColumn label="ICCID" prop="iccid" min-width="150" show-overflow-tooltip />
<ElTableColumn label="设备号" prop="device_no" min-width="150" show-overflow-tooltip />
<ElTableColumn
label="设备号"
prop="device_no"
min-width="150"
show-overflow-tooltip
/>
<ElTableColumn label="入账时间" prop="created_at" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}

View File

@@ -54,10 +54,7 @@
:placeholder="t('orderManagement.searchForm.orderTypePlaceholder')"
style="width: 100%"
>
<ElOption
:label="t('orderManagement.orderType.singleCard')"
value="single_card"
/>
<ElOption :label="t('orderManagement.orderType.singleCard')" value="single_card" />
<ElOption :label="t('orderManagement.orderType.device')" value="device" />
</ElSelect>
</ElFormItem>
@@ -154,7 +151,10 @@
</ElDescriptions>
<!-- 订单项列表 -->
<div v-if="currentOrder.items && currentOrder.items.length > 0" style="margin-top: 20px">
<div
v-if="currentOrder.items && currentOrder.items.length > 0"
style="margin-top: 20px"
>
<h4>{{ t('orderManagement.orderItems') }}</h4>
<ElTable :data="currentOrder.items" border style="margin-top: 10px">
<ElTableColumn
@@ -176,11 +176,7 @@
{{ formatCurrency(row.unit_price) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="amount"
:label="t('orderManagement.items.amount')"
width="120"
>
<ElTableColumn prop="amount" :label="t('orderManagement.items.amount')" width="120">
<template #default="{ row }">
{{ formatCurrency(row.amount) }}
</template>
@@ -415,10 +411,8 @@
label: t('orderManagement.table.orderType'),
width: 120,
formatter: (row: Order) => {
return h(
ElTag,
{ type: row.order_type === 'single_card' ? 'primary' : 'success' },
() => getOrderTypeText(row.order_type)
return h(ElTag, { type: row.order_type === 'single_card' ? 'primary' : 'success' }, () =>
getOrderTypeText(row.order_type)
)
}
},
@@ -427,10 +421,8 @@
label: t('orderManagement.table.buyerType'),
width: 120,
formatter: (row: Order) => {
return h(
ElTag,
{ type: row.buyer_type === 'personal' ? 'info' : 'warning' },
() => getBuyerTypeText(row.buyer_type)
return h(ElTag, { type: row.buyer_type === 'personal' ? 'info' : 'warning' }, () =>
getBuyerTypeText(row.buyer_type)
)
}
},
@@ -439,8 +431,10 @@
label: t('orderManagement.table.paymentStatus'),
width: 120,
formatter: (row: Order) => {
return h(ElTag, { type: getPaymentStatusType(row.payment_status) }, () =>
row.payment_status_text
return h(
ElTag,
{ type: getPaymentStatusType(row.payment_status) },
() => row.payment_status_text
)
}
},

View File

@@ -48,7 +48,12 @@
:close-on-click-modal="false"
@closed="handleCostPriceDialogClosed"
>
<ElForm ref="costPriceFormRef" :model="costPriceForm" :rules="costPriceRules" label-width="120px">
<ElForm
ref="costPriceFormRef"
:model="costPriceForm"
:rules="costPriceRules"
label-width="120px"
>
<ElFormItem label="套餐名称">
<ElInput v-model="costPriceForm.package_name" disabled />
</ElFormItem>
@@ -56,7 +61,12 @@
<ElInput v-model="costPriceForm.shop_name" disabled />
</ElFormItem>
<ElFormItem label="原成本价(分)">
<ElInputNumber v-model="costPriceForm.old_cost_price" disabled :controls="false" style="width: 100%" />
<ElInputNumber
v-model="costPriceForm.old_cost_price"
disabled
:controls="false"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="新成本价(分)" prop="cost_price">
<ElInputNumber
@@ -71,7 +81,11 @@
<template #footer>
<div class="dialog-footer">
<ElButton @click="costPriceDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleCostPriceSubmit(costPriceFormRef)" :loading="costPriceSubmitLoading">
<ElButton
type="primary"
@click="handleCostPriceSubmit(costPriceFormRef)"
:loading="costPriceSubmitLoading"
>
提交
</ElButton>
</div>
@@ -154,11 +168,7 @@
import { ShopPackageAllocationService, PackageManageService, ShopService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
ShopPackageAllocationResponse,
PackageResponse,
ShopResponse
} from '@/types/api'
import type { ShopPackageAllocationResponse, PackageResponse, ShopResponse } from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
@@ -281,7 +291,9 @@
if (value === undefined || value === null || value === '') {
callback(new Error('请输入成本价'))
} else if (form.package_base_price && value < form.package_base_price) {
callback(new Error(`成本价不能低于套餐价格 ¥${(form.package_base_price / 100).toFixed(2)}`))
callback(
new Error(`成本价不能低于套餐价格 ¥${(form.package_base_price / 100).toFixed(2)}`)
)
} else {
callback()
}
@@ -355,7 +367,8 @@
prop: 'calculated_cost_price',
label: '原计算成本价',
width: 120,
formatter: (row: ShopPackageAllocationResponse) => `¥${(row.calculated_cost_price / 100).toFixed(2)}`
formatter: (row: ShopPackageAllocationResponse) =>
`¥${(row.calculated_cost_price / 100).toFixed(2)}`
},
{
prop: 'status',
@@ -624,7 +637,7 @@
const handlePackageChange = (packageId: number | undefined) => {
if (packageId) {
// 从套餐选项中找到选中的套餐
const selectedPackage = packageOptions.value.find(pkg => pkg.id === packageId)
const selectedPackage = packageOptions.value.find((pkg) => pkg.id === packageId)
if (selectedPackage) {
// 将套餐的价格设置为成本价
form.cost_price = selectedPackage.price

View File

@@ -94,11 +94,7 @@
</ElSelect>
</ElFormItem>
<ElFormItem label="流量类型" prop="data_type">
<ElSelect
v-model="form.data_type"
placeholder="请选择流量类型"
style="width: 100%"
>
<ElSelect v-model="form.data_type" placeholder="请选择流量类型" style="width: 100%">
<ElOption
v-for="option in DATA_TYPE_OPTIONS"
:key="option.value"
@@ -116,7 +112,11 @@
placeholder="请输入真流量额度"
/>
</ElFormItem>
<ElFormItem label="虚流量额度(MB)" prop="virtual_data_mb" v-if="form.data_type === 'virtual'">
<ElFormItem
label="虚流量额度(MB)"
prop="virtual_data_mb"
v-if="form.data_type === 'virtual'"
>
<ElInputNumber
v-model="form.virtual_data_mb"
:min="0"
@@ -135,12 +135,7 @@
/>
</ElFormItem>
<ElFormItem label="价格(分)" prop="price">
<ElInputNumber
v-model="form.price"
:min="0"
:controls="false"
style="width: 100%"
/>
<ElInputNumber v-model="form.price" :min="0" :controls="false" style="width: 100%" />
</ElFormItem>
<ElFormItem label="套餐描述" prop="description">
<ElInput
@@ -387,10 +382,8 @@
label: '套餐类型',
width: 100,
formatter: (row: PackageResponse) => {
return h(
ElTag,
{ type: getPackageTypeTag(row.package_type), size: 'small' },
() => getPackageTypeLabel(row.package_type)
return h(ElTag, { type: getPackageTypeTag(row.package_type), size: 'small' }, () =>
getPackageTypeLabel(row.package_type)
)
}
},
@@ -399,10 +392,8 @@
label: '流量类型',
width: 100,
formatter: (row: PackageResponse) => {
return h(
ElTag,
{ type: getDataTypeTag(row.data_type), size: 'small' },
() => getDataTypeLabel(row.data_type)
return h(ElTag, { type: getDataTypeTag(row.data_type), size: 'small' }, () =>
getDataTypeLabel(row.data_type)
)
}
},

View File

@@ -176,7 +176,11 @@
</ElFormItem>
<ElFormItem
:label="form.one_time_commission_config.mode === 'fixed' ? '佣金金额(分)' : '佣金比例(千分比)'"
:label="
form.one_time_commission_config.mode === 'fixed'
? '佣金金额(分)'
: '佣金比例(千分比)'
"
prop="one_time_commission_config.value"
>
<ElInputNumber
@@ -197,8 +201,16 @@
<template v-if="form.one_time_commission_config.type === 'tiered'">
<ElFormItem label="梯度档位">
<div class="tier-list">
<div v-for="(tier, index) in form.one_time_commission_config.tiers" :key="index" class="tier-item">
<ElSelect v-model="tier.tier_type" placeholder="梯度类型" style="width: 120px">
<div
v-for="(tier, index) in form.one_time_commission_config.tiers"
:key="index"
class="tier-item"
>
<ElSelect
v-model="tier.tier_type"
placeholder="梯度类型"
style="width: 120px"
>
<ElOption label="销量" value="sales_count" />
<ElOption label="销售额" value="sales_amount" />
</ElSelect>
@@ -903,12 +915,14 @@
}
// 梯度类型配置
else if (form.one_time_commission_config.type === 'tiered') {
data.one_time_commission_config.tiers = form.one_time_commission_config.tiers.map((t: any) => ({
data.one_time_commission_config.tiers = form.one_time_commission_config.tiers.map(
(t: any) => ({
tier_type: t.tier_type,
threshold: t.threshold,
mode: t.mode,
value: t.value
}))
})
)
}
}
@@ -962,9 +976,9 @@
}
.form-tip {
margin-top: 4px;
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.tier-list {
@@ -981,26 +995,26 @@
.info-row {
display: flex;
gap: 20px;
margin-bottom: 18px;
padding: 12px;
margin-bottom: 18px;
border-radius: 4px;
}
.info-item {
flex: 1;
display: flex;
flex: 1;
align-items: center;
}
.info-label {
font-size: 14px;
margin-right: 8px;
font-size: 14px;
white-space: nowrap;
}
.info-value {
font-size: 14px;
color: var(--art-primary);
font-weight: 500;
color: var(--art-primary);
}
</style>

View File

@@ -455,7 +455,8 @@
activeText: getStatusText(CommonStatus.ENABLED),
inactiveText: getStatusText(CommonStatus.DISABLED),
inlinePrompt: true,
'onUpdate:modelValue': (val: string | number | boolean) => handleStatusChange(row, val as number)
'onUpdate:modelValue': (val: string | number | boolean) =>
handleStatusChange(row, val as number)
})
}
},

View File

@@ -48,7 +48,12 @@
<ElInput v-model="form.perm_name" placeholder="请输入权限名称" />
</ElFormItem>
<ElFormItem label="权限标识" prop="perm_code">
<ElInput v-model="form.perm_code" placeholder="请输入权限标识user:add" />
<ElInput
v-model="form.perm_code"
placeholder="例如:菜单权限:menu:role 按钮权限: role:add"
type="textarea"
:rows="2"
/>
</ElFormItem>
<ElFormItem label="权限类型" prop="perm_type">
<ElSelect v-model="form.perm_type" placeholder="请选择权限类型" style="width: 100%">

View File

@@ -18,7 +18,7 @@
@refresh="handleRefresh"
>
<template #left>
<ElButton @click="showDialog('add')">新增角色</ElButton>
<ElButton @click="showDialog('add')" v-permission="'role:add'">新增角色</ElButton>
</template>
</ArtTableHeader>
@@ -59,7 +59,12 @@
/>
</ElFormItem>
<ElFormItem label="角色类型" prop="role_type">
<ElSelect v-model="form.role_type" placeholder="请选择角色类型" style="width: 100%">
<ElSelect
v-model="form.role_type"
placeholder="请选择角色类型"
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<ElOption label="平台角色" :value="1" />
<ElOption label="客户角色" :value="2" />
</ElSelect>
@@ -226,13 +231,12 @@
},
{
prop: 'role_desc',
label: '角色描述',
minWidth: 150
label: '角色描述'
},
{
prop: 'role_type',
label: '角色类型',
width: 100,
minWidth: 120,
formatter: (row: any) => {
return h(ElTag, { type: row.role_type === 1 ? 'primary' : 'success' }, () =>
row.role_type === 1 ? '平台角色' : '客户角色'
@@ -242,7 +246,7 @@
{
prop: 'status',
label: '状态',
width: 100,
minWidth: 100,
formatter: (row: any) => {
return h(ElSwitch, {
modelValue: row.status,
@@ -259,13 +263,13 @@
{
prop: 'CreatedAt',
label: '创建时间',
width: 180,
minWidth: 180,
formatter: (row: any) => formatDateTime(row.CreatedAt)
},
{
prop: 'operation',
label: '操作',
width: 180,
width: 200,
fixed: 'right',
formatter: (row: any) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
@@ -481,17 +485,22 @@
if (valid) {
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
role_type: form.role_type,
status: form.status
}
if (dialogType.value === 'add') {
await RoleService.createRole(data)
ElMessage.success('新增成功')
} else {
// 更新角色时只发送允许的字段
const data = {
role_name: form.role_name,
role_desc: form.role_desc,
status: form.status
}
await RoleService.updateRole(form.id, data)
ElMessage.success('修改成功')
}
@@ -522,5 +531,4 @@
console.error(error)
}
}
</script>