fetch(modify):修改原来的bug
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m53s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m53s
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (店铺选择)
|
||||
- 设备与卡的关联管理
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 添加中英文翻译
|
||||
- 包含菜单、表单、表格、对话框、提示消息等所有文案
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. **样式调整**
|
||||
|
||||
- 确保页面样式与系统其他页面一致
|
||||
- 响应式布局适配
|
||||
- 对话框尺寸和布局优化
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:启用`。
|
||||
|
||||
**决策**:
|
||||
|
||||
- **在常量配置中定义套餐专用的状态枚举**
|
||||
- **前端页面使用项目统一的 CommonStatus(0/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**: 待确认,当前设计是创建时计算一次,不自动更新
|
||||
|
||||
@@ -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 依赖**: 需确保后端接口已实现并联调
|
||||
- **权限控制**: 需配置对应的菜单和按钮权限
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
## 2. API 服务层实现
|
||||
|
||||
### 2.1 套餐系列 API(packageSeries.ts)
|
||||
|
||||
- [ ] 2.1.1 实现 getPackageSeries(套餐系列列表)
|
||||
- [ ] 2.1.2 实现 createPackageSeries(创建套餐系列)
|
||||
- [ ] 2.1.3 实现 getPackageSeriesDetail(获取套餐系列详情)
|
||||
@@ -17,6 +18,7 @@
|
||||
- [ ] 2.1.6 实现 updatePackageSeriesStatus(更新套餐系列状态)
|
||||
|
||||
### 2.2 套餐管理 API(package.ts)
|
||||
|
||||
- [ ] 2.2.1 实现 getPackages(套餐列表)
|
||||
- [ ] 2.2.2 实现 createPackage(创建套餐)
|
||||
- [ ] 2.2.3 实现 getPackageDetail(获取套餐详情)
|
||||
@@ -26,11 +28,13 @@
|
||||
- [ ] 2.2.7 实现 updatePackageShelfStatus(更新套餐上架状态)
|
||||
|
||||
### 2.3 代理可售套餐 API(myPackage.ts)
|
||||
|
||||
- [ ] 2.3.1 实现 getMyPackages(我的可售套餐列表)
|
||||
- [ ] 2.3.2 实现 getMyPackageDetail(获取可售套餐详情)
|
||||
- [ ] 2.3.3 实现 getMySeriesAllocations(我的被分配系列列表)
|
||||
|
||||
### 2.4 单套餐分配 API(shopPackageAllocation.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 测试所有页面的错误处理(网络错误、业务错误)
|
||||
|
||||
@@ -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**: 旧数据如何映射到新模型?
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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: 梯度返佣配置
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,5 +87,4 @@ export class PackageManageService extends BaseService {
|
||||
const data: UpdatePackageShelfStatusRequest = { shelf_status }
|
||||
return this.patch<BaseResponse>(`/api/admin/packages/${id}/shelf`, data)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
src/composables/usePermission.ts
Normal file
BIN
src/composables/usePermission.ts
Normal file
Binary file not shown.
@@ -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' }>
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -890,24 +890,6 @@ export const asyncRoutes: AppRouteRecord[] = [
|
||||
icon: ''
|
||||
},
|
||||
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: ''
|
||||
},
|
||||
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
|
||||
// }
|
||||
// }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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' // 分佣模板
|
||||
}
|
||||
|
||||
// 主页路由
|
||||
|
||||
@@ -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,
|
||||
|
||||
607
src/types/auto-imports.d.ts
vendored
607
src/types/auto-imports.d.ts
vendored
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '',
|
||||
text: '编辑',
|
||||
iconClass: BgColorEnum.SECONDARY,
|
||||
onClick: () => showDialog('edit', row)
|
||||
}),
|
||||
h(ArtButtonTable, {
|
||||
text: '卡授权',
|
||||
iconClass: BgColorEnum.PRIMARY,
|
||||
onClick: () => manageCards(row)
|
||||
}),
|
||||
h(ArtButtonTable, {
|
||||
icon: '',
|
||||
text: '修改密码',
|
||||
iconClass: BgColorEnum.WARNING,
|
||||
onClick: () => showPasswordDialog(row)
|
||||
}),
|
||||
h(ArtButtonTable, {
|
||||
type: 'edit',
|
||||
onClick: () => showDialog('edit', row)
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. 必填字段:iccid(ICCID)、msisdn(MSISDN/手机号)</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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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%">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user