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

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

View File

@@ -5,7 +5,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<!-- 引入小米字体 CSS 文件 --> <!-- 引入小米字体 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" /> <link rel="shortcut icon" type="image/x-icon" href="src/assets/img/logo.png" />
</head> </head>

View File

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

View File

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

View File

@@ -7,140 +7,140 @@
The system SHALL provide a searchable device list with multi-condition filtering, pagination, and batch operations. The system SHALL provide a searchable device list with multi-condition filtering, pagination, and batch operations.
#### Scenario: Search devices by multiple criteria #### 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 #### 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 #### 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 #### 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 #### 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 ### Requirement: Device Detail Viewing
The system SHALL display comprehensive device information including basic properties and bound SIM cards. The system SHALL display comprehensive device information including basic properties and bound SIM cards.
#### Scenario: View device basic information #### 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 #### 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 #### 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 ### Requirement: Device-Card Binding Management
The system SHALL allow administrators to bind and unbind SIM cards to specific device slots. The system SHALL allow administrators to bind and unbind SIM cards to specific device slots.
#### Scenario: Bind a card to a device slot #### 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 #### 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 #### 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 #### 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 ### Requirement: Batch Device Allocation
The system SHALL support batch allocation of devices to shops with result tracking. The system SHALL support batch allocation of devices to shops with result tracking.
#### Scenario: Allocate multiple devices to a shop #### 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 #### 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 #### 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 ### Requirement: Batch Device Recall
The system SHALL support batch recall of devices from shops with result tracking. The system SHALL support batch recall of devices from shops with result tracking.
#### Scenario: Recall multiple devices #### 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 #### 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 #### 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 ### Requirement: Device Import via Object Storage
The system SHALL support CSV-based device import using a three-step object storage upload process. The system SHALL support CSV-based device import using a three-step object storage upload process.
#### Scenario: Import devices with three-step 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 1. Get upload URL from StorageService.getUploadUrl
2. Upload file to object storage using StorageService.uploadFile 2. Upload file to object storage using StorageService.uploadFile
3. Call DeviceService.importDevices with the file_key 3. Call DeviceService.importDevices with the file_key **AND** display the task number for tracking
**AND** display the task number for tracking
#### Scenario: Validate CSV format #### 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 #### 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 #### 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 ### Requirement: Import Task Management
The system SHALL track device import tasks with detailed status and record information. The system SHALL track device import tasks with detailed status and record information.
#### Scenario: List import tasks with type filter #### 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 #### 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 #### 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 #### 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 #### 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 ## 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. **Previous behavior**: Task management only supported ICCID import tasks.
#### Scenario: Filter tasks by type #### 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 #### Scenario: Display task type badges
**WHEN** viewing the task list
**THEN** each task shall display a type badge indicating whether it's an ICCID import or Device import task **WHEN** viewing the task list **THEN** each task shall display a type badge indicating whether it's an ICCID import or Device import task

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,9 @@
// 按钮粗度 // 按钮粗度
--el-font-weight-primary: 400 !important; --el-font-weight-primary: 400 !important;
// Element Plus 全局字体 // 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; --el-component-custom-height: 36px !important;
@@ -182,7 +184,15 @@
// 修改el-button样式 // 修改el-button样式
.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 { &.el-button--text {
background-color: transparent !important; background-color: transparent !important;
@@ -202,7 +212,15 @@
border-radius: 6px !important; border-radius: 6px !important;
font-weight: bold; font-weight: bold;
transition: all 0s !important; 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 组件添加小米字体 // 为所有 Element Plus 组件添加小米字体
@@ -227,7 +245,15 @@
.el-upload, .el-upload,
.el-card, .el-card,
.el-divider { .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 { .el-checkbox-group {

View File

@@ -34,7 +34,15 @@ h5 {
body { body {
color: var(--art-text-gray-700); color: var(--art-text-gray-700);
text-align: left; 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 { select {

Binary file not shown.

View File

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

View File

@@ -1,17 +1,41 @@
import { router } from '@/router' import { App, Directive, DirectiveBinding } from 'vue'
import { App, Directive } 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> * <el-button v-auth="'add'">按钮</el-button>
*/ */
const authDirective: Directive = { const permissionDirective: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) { 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) { if (!hasPermission) {
el.parentNode?.removeChild(el) el.parentNode?.removeChild(el)
} }
@@ -19,5 +43,7 @@ const authDirective: Directive = {
} }
export function setupPermissionDirective(app: App) { export function setupPermissionDirective(app: App) {
app.directive('auth', authDirective) // 注册为 v-permission 和 v-auth (向后兼容)
app.directive('permission', permissionDirective)
app.directive('auth', permissionDirective)
} }

View File

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

View File

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

View File

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

View File

@@ -23,15 +23,45 @@ export const useUserStore = defineStore(
const searchHistory = ref<AppRouteRecord[]>([]) const searchHistory = ref<AppRouteRecord[]>([])
const accessToken = ref('') const accessToken = ref('')
const refreshToken = ref('') const refreshToken = ref('')
const permissions = ref<string[]>([])
const getUserInfo = computed(() => info.value) const getUserInfo = computed(() => info.value)
const getSettingState = computed(() => useSettingStore().$state) const getSettingState = computed(() => useSettingStore().$state)
const getWorktabState = computed(() => useWorktabStore().$state) const getWorktabState = computed(() => useWorktabStore().$state)
const getPermissions = computed(() => permissions.value)
const setUserInfo = (newInfo: UserInfo) => { const setUserInfo = (newInfo: UserInfo) => {
info.value = newInfo 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) => { const setLoginStatus = (status: boolean) => {
isLogin.value = status isLogin.value = status
} }
@@ -74,6 +104,7 @@ export const useUserStore = defineStore(
lockPassword.value = '' lockPassword.value = ''
accessToken.value = '' accessToken.value = ''
refreshToken.value = '' refreshToken.value = ''
permissions.value = []
useWorktabStore().opened = [] useWorktabStore().opened = []
sessionStorage.removeItem('iframeRoutes') sessionStorage.removeItem('iframeRoutes')
resetRouterState(router) resetRouterState(router)
@@ -90,10 +121,17 @@ export const useUserStore = defineStore(
searchHistory, searchHistory,
accessToken, accessToken,
refreshToken, refreshToken,
permissions,
getUserInfo, getUserInfo,
getSettingState, getSettingState,
getWorktabState, getWorktabState,
getPermissions,
isSuperAdmin,
setUserInfo, setUserInfo,
setPermissions,
hasPermission,
hasAnyPermission,
hasAllPermissions,
setLoginStatus, setLoginStatus,
setLanguage, setLanguage,
setSearchHistory, setSearchHistory,

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<ArtTableFullScreen> <ArtTableFullScreen>
<div class="enterprise-cards-page" id="table-full-screen"> <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> <template #header>
<div class="card-header"> <div class="card-header">
<span>企业信息</span> <span>企业信息</span>
@@ -10,8 +10,12 @@
</div> </div>
</template> </template>
<ElDescriptions :column="3" border v-if="enterpriseInfo"> <ElDescriptions :column="3" border v-if="enterpriseInfo">
<ElDescriptionsItem label="企业名称">{{ enterpriseInfo.enterprise_name }}</ElDescriptionsItem> <ElDescriptionsItem label="企业名称">{{
<ElDescriptionsItem label="企业编号">{{ enterpriseInfo.enterprise_code }}</ElDescriptionsItem> enterpriseInfo.enterprise_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{
enterpriseInfo.enterprise_code
}}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ enterpriseInfo.contact_name }}</ElDescriptionsItem> <ElDescriptionsItem label="联系人">{{ enterpriseInfo.contact_name }}</ElDescriptionsItem>
</ElDescriptions> </ElDescriptions>
</ElCard> </ElCard>
@@ -67,92 +71,51 @@
<ElDialog <ElDialog
v-model="allocateDialogVisible" v-model="allocateDialogVisible"
title="授权卡给企业" title="授权卡给企业"
width="700px" width="85%"
@close="handleAllocateDialogClose" @close="handleAllocateDialogClose"
> >
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px"> <!-- 搜索过滤条件 -->
<ElFormItem label="ICCID列表" prop="iccids"> <ArtSearchBar
<ElInput v-model:filter="cardSearchForm"
v-model="iccidsText" :items="cardSearchFormItems"
type="textarea" label-width="85"
:rows="6" @reset="handleCardSearchReset"
placeholder="请输入ICCID每行一个或用逗号分隔" @search="handleCardSearch"
@input="handleIccidsChange" ></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> </template>
</ElTableColumn> </ArtTable>
<ElTableColumn label="ICCID列表">
<template #default="{ row }">
{{ row.iccids?.join(', ') }}
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">取消</ElButton> <ElButton @click="allocateDialogVisible = false">取消</ElButton>
<ElButton @click="handlePreview" :loading="previewLoading">预检</ElButton>
<ElButton <ElButton
type="primary" type="primary"
@click="handleAllocate" @click="handleAllocate"
:loading="allocateLoading" :loading="allocateLoading"
:disabled="!previewData || previewData.summary.valid_cards === 0" :disabled="selectedAvailableCards.length === 0"
> >
确认授权 确认授权
</ElButton> </ElButton>
@@ -191,7 +154,12 @@
</ElDialog> </ElDialog>
<!-- 结果对话框 --> <!-- 结果对话框 -->
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px"> <ElDialog
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
@close="handleResultDialogClose"
>
<ElDescriptions :column="2" border> <ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数"> <ElDescriptionsItem label="成功数">
<ElTag type="success">{{ operationResult.success_count }}</ElTag> <ElTag type="success">{{ operationResult.success_count }}</ElTag>
@@ -203,7 +171,7 @@
<div <div
v-if="operationResult.failed_items && operationResult.failed_items.length > 0" v-if="operationResult.failed_items && operationResult.failed_items.length > 0"
style="margin-top: 20px" class="result-section"
> >
<ElDivider content-position="left">失败项详情</ElDivider> <ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="operationResult.failed_items" border max-height="300"> <ElTable :data="operationResult.failed_items" border max-height="300">
@@ -215,7 +183,7 @@
<!-- 显示授权的设备 --> <!-- 显示授权的设备 -->
<div <div
v-if="operationResult.allocated_devices && operationResult.allocated_devices.length > 0" v-if="operationResult.allocated_devices && operationResult.allocated_devices.length > 0"
style="margin-top: 20px" class="result-section"
> >
<ElDivider content-position="left">已授权设备</ElDivider> <ElDivider content-position="left">已授权设备</ElDivider>
<ElTable :data="operationResult.allocated_devices" border max-height="200"> <ElTable :data="operationResult.allocated_devices" border max-height="200">
@@ -242,21 +210,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue' import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { BgColorEnum } from '@/enums/appEnum'
import type { import type {
EnterpriseCardItem, EnterpriseCardItem,
AllocateCardsPreviewResponse,
AllocateCardsResponse, AllocateCardsResponse,
RecallCardsResponse, RecallCardsResponse
FailedItem
} from '@/types/api/enterpriseCard' } from '@/types/api/enterpriseCard'
import type { EnterpriseItem } from '@/types/api' import type { EnterpriseItem } from '@/types/api'
import type { StandaloneIotCard } from '@/types/api/card'
defineOptions({ name: 'EnterpriseCards' }) defineOptions({ name: 'EnterpriseCards' })
@@ -265,19 +233,15 @@
const loading = ref(false) const loading = ref(false)
const allocateDialogVisible = ref(false) const allocateDialogVisible = ref(false)
const allocateLoading = ref(false) const allocateLoading = ref(false)
const previewLoading = ref(false)
const recallDialogVisible = ref(false) const recallDialogVisible = ref(false)
const recallLoading = ref(false) const recallLoading = ref(false)
const resultDialogVisible = ref(false) const resultDialogVisible = ref(false)
const resultTitle = ref('') const resultTitle = ref('')
const tableRef = ref() const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>() const recallFormRef = ref<FormInstance>()
const selectedCards = ref<EnterpriseCardItem[]>([]) const selectedCards = ref<EnterpriseCardItem[]>([])
const enterpriseId = ref<number>(0) const enterpriseId = ref<number>(0)
const enterpriseInfo = ref<EnterpriseItem | null>(null) const enterpriseInfo = ref<EnterpriseItem | null>(null)
const iccidsText = ref('')
const previewData = ref<AllocateCardsPreviewResponse | null>(null)
const operationResult = ref<AllocateCardsResponse | RecallCardsResponse>({ const operationResult = ref<AllocateCardsResponse | RecallCardsResponse>({
success_count: 0, success_count: 0,
fail_count: 0, fail_count: 0,
@@ -285,40 +249,39 @@
allocated_devices: null 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 = { const initialSearchState = {
iccid: '', iccid: '',
msisdn: '', device_no: '',
status: undefined as number | undefined, carrier_id: undefined as number | undefined,
authorization_status: undefined as number | undefined status: undefined as number | undefined
} }
// 搜索表单 // 搜索表单
const searchForm = reactive({ ...initialSearchState }) 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({ const recallForm = reactive({
reason: '' reason: ''
@@ -346,16 +309,30 @@
} }
}, },
{ {
label: '手机号', label: '设备号',
prop: 'msisdn', prop: 'device_no',
type: 'input', type: 'input',
config: { config: {
clearable: true, 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', prop: 'status',
type: 'select', type: 'select',
config: { config: {
@@ -363,21 +340,74 @@
placeholder: '全部' placeholder: '全部'
}, },
options: () => [ options: () => [
{ label: '激活', value: 1 }, { label: '在库', value: 1 },
{ label: '停机', value: 2 } { label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
] ]
}, }
]
// 卡列表搜索表单配置
const cardSearchFormItems: SearchFormItem[] = [
{ {
label: '授权状态', label: '状态',
prop: 'authorization_status', prop: 'status',
type: 'select', type: 'select',
config: { config: {
clearable: true, clearable: true,
placeholder: '全部' placeholder: '全部'
}, },
options: () => [ options: () => [
{ label: '有效', value: 1 }, { label: '在库', value: 1 },
{ label: '已回收', value: 0 } { 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 = [ const columnOptions = [
{ label: 'ICCID', prop: 'iccid' }, { 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: 'carrier_name' },
{ label: '卡状态', prop: 'status' }, { label: '套餐名称', prop: 'package_name' },
{ label: '授权状态', prop: 'authorization_status' }, { label: '状态', prop: 'status' },
{ label: '授权时间', prop: 'authorized_at' }, { label: '状态名称', prop: 'status_name' },
{ label: '授权人', prop: 'authorizer_name' }, { label: '网络状态', prop: 'network_status' },
{ label: '网络状态名称', prop: 'network_status_name' },
{ label: '操作', prop: 'operation' } { label: '操作', prop: 'operation' }
] ]
@@ -398,22 +431,44 @@
// 获取卡状态标签类型 // 获取卡状态标签类型
const getCardStatusTag = (status: number) => { 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' return status === 1 ? 'success' : 'danger'
} }
// 获取状态文本 // 获取网络状态文本 - 使用API返回的network_status_name
const getCardStatusText = (status: number) => { const getNetworkStatusText = (status: number) => {
return status === 1 ? '激活' : '停机' return status === 1 ? '开机' : '停机'
}
// 获取授权状态标签类型
const getAuthStatusTag = (status: number) => {
return status === 1 ? 'success' : 'info'
}
// 获取授权状态文本
const getAuthStatusText = (status: number) => {
return status === 1 ? '有效' : '已回收'
} }
// 动态列配置 // 动态列配置
@@ -421,64 +476,77 @@
{ {
prop: 'iccid', prop: 'iccid',
label: 'ICCID', label: 'ICCID',
minWidth: 180 minWidth: 200
}, },
{ {
prop: 'msisdn', prop: 'msisdn',
label: '手机号', label: '卡接入号',
width: 120 width: 130
},
{
prop: 'device_no',
label: '设备号',
width: 150
},
{
prop: 'carrier_id',
label: '运营商ID',
width: 100
}, },
{ {
prop: 'carrier_name', prop: 'carrier_name',
label: '运营商', label: '运营商',
width: 100 width: 120
},
{
prop: 'package_name',
label: '套餐名称',
width: 150
}, },
{ {
prop: 'status', prop: 'status',
label: '状态', label: '状态',
width: 100, width: 100,
formatter: (row: EnterpriseCardItem) => { formatter: (row: EnterpriseCardItem) => {
return h(ElTag, { type: getCardStatusTag(row.status) }, () => getCardStatusText(row.status)) return h(ElTag, { type: getCardStatusTag(row.status) }, () => getCardStatusText(row.status))
} }
}, },
{ {
prop: 'authorization_status', prop: 'status_name',
label: '授权状态', label: '状态名称',
width: 100
},
{
prop: 'network_status',
label: '网络状态',
width: 100, width: 100,
formatter: (row: EnterpriseCardItem) => { formatter: (row: EnterpriseCardItem) => {
return h( return h(ElTag, { type: getNetworkStatusTag(row.network_status) }, () =>
ElTag, getNetworkStatusText(row.network_status)
{ type: getAuthStatusTag(row.authorization_status) },
() => getAuthStatusText(row.authorization_status)
) )
} }
}, },
{ {
prop: 'authorized_at', prop: 'network_status_name',
label: '授权时间', label: '网络状态名称',
width: 180, width: 130
formatter: (row: EnterpriseCardItem) =>
row.authorized_at ? formatDateTime(row.authorized_at) : '-'
},
{
prop: 'authorizer_name',
label: '授权人',
width: 120
}, },
{ {
prop: 'operation', prop: 'operation',
label: '操作', label: '操作',
width: 150, width: 100,
fixed: 'right', fixed: 'right',
formatter: (row: EnterpriseCardItem) => { formatter: (row: EnterpriseCardItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [ return h('div', { style: 'display: flex; gap: 8px;' }, [
row.status === 2 row.network_status === 0
? h(ArtButtonTable, { ? h(ArtButtonTable, {
text: '复机', text: '复机',
iconClass: BgColorEnum.SUCCESS,
onClick: () => handleResume(row) onClick: () => handleResume(row)
}) })
: h(ArtButtonTable, { : h(ArtButtonTable, {
text: '停机', text: '停机',
iconClass: BgColorEnum.ERROR,
onClick: () => handleSuspend(row) onClick: () => handleSuspend(row)
}) })
]) ])
@@ -522,9 +590,9 @@
page: pagination.page, page: pagination.page,
page_size: pagination.pageSize, page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined, iccid: searchForm.iccid || undefined,
msisdn: searchForm.msisdn || undefined, device_no: searchForm.device_no || undefined,
status: searchForm.status, carrier_id: searchForm.carrier_id,
authorization_status: searchForm.authorization_status status: searchForm.status
} }
// 清理空值 // 清理空值
@@ -586,84 +654,213 @@
router.back() router.back()
} }
// 显示授权对话框 // 获取可用卡状态类型
const showAllocateDialog = () => { const getAvailableCardStatusType = (status: number) => {
allocateDialogVisible.value = true switch (status) {
iccidsText.value = '' case 1:
allocateForm.iccids = [] return 'info'
allocateForm.confirm_device_bundles = false case 2:
previewData.value = null return 'warning'
if (allocateFormRef.value) { case 3:
allocateFormRef.value.resetFields() return 'success'
case 4:
return 'danger'
default:
return 'info'
} }
} }
// 处理ICCID输入变化 // 获取可用卡状态文本
const handleIccidsChange = () => { const getAvailableCardStatusText = (status: number) => {
// 解析输入的ICCID支持逗号、空格、换行分隔 switch (status) {
const iccids = iccidsText.value case 1:
.split(/[,\s\n]+/) return '在库'
.map((iccid) => iccid.trim()) case 2:
.filter((iccid) => iccid.length > 0) return '已分销'
allocateForm.iccids = iccids case 3:
// 清除预检结果 return '已激活'
previewData.value = null case 4:
return '已停用'
default:
return '未知'
}
} }
// 预检 // 可用卡列表列配置
const handlePreview = async () => { const availableCardColumns = computed(() => [
if (!allocateFormRef.value) return {
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) { const getAvailableCardsList = async () => {
previewLoading.value = true availableCardsLoading.value = true
try { try {
const res = await EnterpriseService.previewAllocateCards(enterpriseId.value, { const params: any = {
iccids: allocateForm.iccids 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) { if (res.code === 0) {
previewData.value = res.data availableCardsList.value = res.data.items || []
ElMessage.success('预检完成') cardPagination.total = res.data.total || 0
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
ElMessage.error('预检失败') ElMessage.error('获取卡列表失败')
} finally { } 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 () => { const handleAllocate = async () => {
if (!allocateFormRef.value) return if (selectedAvailableCards.value.length === 0) {
ElMessage.warning('请选择要授权的卡')
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('请确认设备绑定关系')
return return
} }
allocateLoading.value = true allocateLoading.value = true
try { try {
const iccids = selectedAvailableCards.value.map((card) => card.iccid)
const res = await EnterpriseService.allocateCards(enterpriseId.value, { const res = await EnterpriseService.allocateCards(enterpriseId.value, {
iccids: allocateForm.iccids, iccids
confirm_device_bundles: allocateForm.confirm_device_bundles || undefined
}) })
if (res.code === 0) { if (res.code === 0) {
@@ -683,10 +880,7 @@
// 关闭授权对话框 // 关闭授权对话框
const handleAllocateDialogClose = () => { const handleAllocateDialogClose = () => {
if (allocateFormRef.value) { selectedAvailableCards.value = []
allocateFormRef.value.resetFields()
}
previewData.value = null
} }
// 显示批量回收对话框 // 显示批量回收对话框
@@ -711,7 +905,7 @@
recallLoading.value = true recallLoading.value = true
try { try {
const res = await EnterpriseService.recallCards(enterpriseId.value, { 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) { if (res.code === 0) {
@@ -743,6 +937,11 @@
} }
} }
// 关闭结果对话框
const handleResultDialogClose = () => {
getTableData()
}
// 停机卡 // 停机卡
const handleSuspend = (row: EnterpriseCardItem) => { const handleSuspend = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', { ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', {
@@ -786,10 +985,34 @@
<style lang="scss" scoped> <style lang="scss" scoped>
.enterprise-cards-page { .enterprise-cards-page {
.enterprise-info-card {
margin-bottom: 16px;
}
.card-header { .card-header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; 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> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
<ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem> <ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem> <ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数"> <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 }} / {{ deviceInfo.max_sim_slots }}
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="所属店铺"> <ElDescriptionsItem label="所属店铺">
@@ -118,7 +118,11 @@
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position"> <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 <ElOption
v-for="slot in availableSlots" v-for="slot in availableSlots"
:key="slot" :key="slot"

View File

@@ -56,9 +56,14 @@
<!-- 批量分配对话框 --> <!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px"> <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="已选设备数"> <ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span> <span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem> </ElFormItem>
<ElFormItem label="目标店铺" prop="target_shop_id"> <ElFormItem label="目标店铺" prop="target_shop_id">
<ElSelect <ElSelect
@@ -93,7 +98,8 @@
style="margin-bottom: 10px" style="margin-bottom: 10px"
> >
<template #title> <template #title>
成功分配 {{ allocateResult.success_count }} 失败 {{ allocateResult.fail_count }} 成功分配 {{ allocateResult.success_count }} 失败
{{ allocateResult.fail_count }}
</template> </template>
</ElAlert> </ElAlert>
<div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0"> <div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0">
@@ -101,7 +107,7 @@
<div <div
v-for="item in allocateResult.failed_items" v-for="item in allocateResult.failed_items"
:key="item.device_id" :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 }} 设备号: {{ item.device_no }} - {{ item.reason }}
</div> </div>
@@ -129,7 +135,7 @@
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px"> <ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px"> <ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElFormItem label="已选设备数"> <ElFormItem label="已选设备数">
<span style="color: #e6a23c; font-weight: bold">{{ selectedDevices.length }}</span> <span style="font-weight: bold; color: #e6a23c">{{ selectedDevices.length }}</span>
</ElFormItem> </ElFormItem>
<ElFormItem label="备注"> <ElFormItem label="备注">
<ElInput <ElInput
@@ -157,7 +163,7 @@
<div <div
v-for="item in recallResult.failed_items" v-for="item in recallResult.failed_items"
:key="item.device_id" :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 }} 设备号: {{ item.device_no }} - {{ item.reason }}
</div> </div>
@@ -182,10 +188,19 @@
</ElDialog> </ElDialog>
<!-- 批量设置套餐系列绑定对话框 --> <!-- 批量设置套餐系列绑定对话框 -->
<ElDialog v-model="seriesBindingDialogVisible" title="批量设置设备套餐系列绑定" width="600px"> <ElDialog
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px"> v-model="seriesBindingDialogVisible"
title="批量设置设备套餐系列绑定"
width="600px"
>
<ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选设备数"> <ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span> <span style="font-weight: bold; color: #409eff">{{ selectedDevices.length }}</span>
</ElFormItem> </ElFormItem>
<ElFormItem label="套餐系列分配" prop="series_allocation_id"> <ElFormItem label="套餐系列分配" prop="series_allocation_id">
<ElSelect <ElSelect
@@ -214,15 +229,18 @@
style="margin-bottom: 10px" style="margin-bottom: 10px"
> >
<template #title> <template #title>
成功设置 {{ seriesBindingResult.success_count }} 失败 {{ seriesBindingResult.fail_count }} 成功设置 {{ seriesBindingResult.success_count }} 失败
{{ seriesBindingResult.fail_count }}
</template> </template>
</ElAlert> </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 style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div <div
v-for="item in seriesBindingResult.failed_items" v-for="item in seriesBindingResult.failed_items"
:key="item.device_id" :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 }} 设备号: {{ item.device_no }} - {{ item.reason }}
</div> </div>
@@ -245,6 +263,61 @@
</div> </div>
</template> </template>
</ElDialog> </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> </ElCard>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
@@ -255,7 +328,8 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { DeviceService, ShopService } from '@/api/modules' import { DeviceService, ShopService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation' 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 { FormInstance, FormRules } from 'element-plus'
import type { import type {
Device, Device,
@@ -301,6 +375,11 @@
}) })
const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null) const seriesBindingResult = ref<BatchSetDeviceSeriesBindingResponse | null>(null)
// 设备详情弹窗相关
const deviceDetailDialogVisible = ref(false)
const deviceDetailLoading = ref(false)
const currentDeviceDetail = ref<any>(null)
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState = { const initialSearchState = {
device_no: '', device_no: '',
@@ -424,6 +503,40 @@
remark: '' 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(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
@@ -434,7 +547,17 @@
{ {
prop: 'device_no', prop: 'device_no',
label: '设备号', 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', prop: 'device_name',

View File

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

View File

@@ -5,6 +5,7 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
:show-expand="false"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
></ArtSearchBar> ></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions" :columnList="columnOptions"
v-model:columns="columnChecks" v-model:columns="columnChecks"
@refresh="handleRefresh" @refresh="handleRefresh"
/> >
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入设备
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 --> <!-- 表格 -->
<ArtTable <ArtTable
@@ -36,25 +43,149 @@
</ArtTable> </ArtTable>
</ElCard> </ElCard>
</div> </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> </ArtTableFullScreen>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceService } from '@/api/modules' import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus' 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 type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue' import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus } from '@/types/api/device' import type { DeviceImportTask, DeviceImportTaskStatus } from '@/types/api/device'
defineOptions({ name: 'DeviceTask' }) defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const loading = ref(false) const loading = ref(false)
const tableRef = ref() 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 = { const initialSearchState = {
@@ -116,19 +247,21 @@
// 列配置 // 列配置
const columnOptions = [ const columnOptions = [
{ label: '任务编号', prop: 'task_no' }, { label: '任务编号', prop: 'task_no' },
{ label: '批次号', prop: 'batch_no' },
{ label: '文件名', prop: 'file_name' },
{ label: '任务状态', prop: 'status' }, { label: '任务状态', prop: 'status' },
{ label: '文件名', prop: 'file_name' },
{ label: '总数', prop: 'total_count' }, { label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' }, { label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'fail_count' }, { label: '失败数', prop: 'fail_count' },
{ label: '跳过数', prop: 'skip_count' }, { label: '跳过数', prop: 'skip_count' },
{ label: '创建时间', prop: 'created_at' }, { label: '开始时间', prop: 'started_at' },
{ label: '完成时间', prop: 'completed_at' }, { label: '完成时间', prop: 'completed_at' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' } { label: '操作', prop: 'operation' }
] ]
const taskList = ref<DeviceImportTask[]>([]) const taskList = ref<DeviceImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型 // 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => { const getStatusType = (status: DeviceImportTaskStatus) => {
@@ -147,14 +280,22 @@
} }
// 查看详情 // 查看详情
const viewDetail = (row: DeviceImportTask) => { const viewDetail = async (row: DeviceImportTask) => {
router.push({ try {
path: '/asset-management/task-detail', const res = await DeviceService.getImportTaskDetail(row.id)
query: { if (res.code === 0 && res.data) {
id: row.id, currentDetail.value = {
task_type: 'device' ...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', prop: 'task_no',
label: '任务编号', label: '任务编号',
width: 150 width: 180
},
{
prop: 'batch_no',
label: '批次号',
width: 120
},
{
prop: 'file_name',
label: '文件名',
minWidth: 200
}, },
{ {
prop: 'status', prop: 'status',
@@ -182,6 +313,12 @@
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text) return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
} }
}, },
{
prop: 'file_name',
label: '文件名',
minWidth: 250,
showOverflowTooltip: true
},
{ {
prop: 'total_count', prop: 'total_count',
label: '总数', label: '总数',
@@ -190,15 +327,17 @@
{ {
prop: 'success_count', prop: 'success_count',
label: '成功数', label: '成功数',
width: 80 width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
}, },
{ {
prop: 'fail_count', prop: 'fail_count',
label: '失败数', label: '失败数',
width: 80, width: 80,
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success' return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
} }
}, },
{ {
@@ -207,27 +346,59 @@
width: 80 width: 80
}, },
{ {
prop: 'created_at', prop: 'started_at',
label: '创建时间', label: '开始时间',
width: 160, width: 180,
formatter: (row: DeviceImportTask) => formatDateTime(row.created_at) formatter: (row: DeviceImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
}, },
{ {
prop: 'completed_at', prop: 'completed_at',
label: '完成时间', label: '完成时间',
width: 160, width: 180,
formatter: (row: DeviceImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-') 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', prop: 'operation',
label: '操作', label: '操作',
width: 100, width: 180,
fixed: 'right', fixed: 'right',
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) => {
return h(ArtButtonTable, { const buttons = []
type: 'view',
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row) 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) const res = await DeviceService.getImportTasks(params)
if (res.code === 0) { if (res.code === 0) {
taskList.value = res.data.list || [] taskList.value = res.data.items || []
pagination.total = res.data.total || 0 pagination.total = res.data.total || 0
} }
} catch (error) { } catch (error) {
@@ -301,10 +472,195 @@
pagination.page = newCurrentPage pagination.page = newCurrentPage
getTableData() 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.device-task-page { .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> </style>

View File

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

View File

@@ -19,13 +19,25 @@
> >
<template #left> <template #left>
<ElButton type="primary" @click="showImportDialog">导入ICCID</ElButton> <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>
<ElButton type="warning" :disabled="selectedCards.length === 0" @click="showRecallDialog"> <ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showRecallDialog"
>
批量回收 批量回收
</ElButton> </ElButton>
<ElButton type="info" :disabled="selectedCards.length === 0" @click="showSeriesBindingDialog"> <ElButton
type="info"
:disabled="selectedCards.length === 0"
@click="showSeriesBindingDialog"
>
批量设置套餐系列 批量设置套餐系列
</ElButton> </ElButton>
<ElButton type="primary" @click="cardDistribution">网卡分销</ElButton> <ElButton type="primary" @click="cardDistribution">网卡分销</ElButton>
@@ -65,7 +77,11 @@
> >
<ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="100px"> <ElForm ref="importFormRef" :model="importForm" :rules="importRules" label-width="100px">
<ElFormItem label="运营商" prop="carrier_id"> <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="1" />
<ElOption label="中国联通" :value="2" /> <ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" /> <ElOption label="中国电信" :value="3" />
@@ -91,7 +107,7 @@
<template #tip> <template #tip>
<div class="el-upload__tip"> <div class="el-upload__tip">
<div>只支持上传CSV文件且不超过10MB</div> <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两列逗号分隔每行一条记录 CSV格式ICCID,MSISDN两列逗号分隔每行一条记录
</div> </div>
</div> </div>
@@ -116,9 +132,18 @@
width="600px" width="600px"
@close="handleAllocateDialogClose" @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"> <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="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" /> <ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" /> <ElOption label="店铺C" :value="3" />
@@ -136,22 +161,40 @@
<div>已选择 {{ selectedCards.length }} 张卡</div> <div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem> </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" /> <ElInput v-model="allocateForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem> </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" /> <ElInput v-model="allocateForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem> </ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="运营商"> <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="1" />
<ElOption label="中国联通" :value="2" /> <ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" /> <ElOption label="中国电信" :value="3" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="allocateForm.selection_type === 'filter'" label="卡状态"> <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="1" />
<ElOption label="已分销" :value="2" /> <ElOption label="已分销" :value="2" />
<ElOption label="已激活" :value="3" /> <ElOption label="已激活" :value="3" />
@@ -163,7 +206,12 @@
</ElFormItem> </ElFormItem>
<ElFormItem label="备注"> <ElFormItem label="备注">
<ElInput v-model="allocateForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /> <ElInput
v-model="allocateForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<template #footer> <template #footer>
@@ -185,7 +233,11 @@
> >
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px"> <ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules" label-width="120px">
<ElFormItem label="来源店铺" prop="from_shop_id"> <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="店铺A" :value="1" />
<ElOption label="店铺B" :value="2" /> <ElOption label="店铺B" :value="2" />
<ElOption label="店铺C" :value="3" /> <ElOption label="店铺C" :value="3" />
@@ -203,15 +255,28 @@
<div>已选择 {{ selectedCards.length }} 张卡</div> <div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem> </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" /> <ElInput v-model="recallForm.iccid_start" placeholder="请输入起始ICCID" />
</ElFormItem> </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" /> <ElInput v-model="recallForm.iccid_end" placeholder="请输入结束ICCID" />
</ElFormItem> </ElFormItem>
<ElFormItem v-if="recallForm.selection_type === 'filter'" label="运营商"> <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="1" />
<ElOption label="中国联通" :value="2" /> <ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" /> <ElOption label="中国电信" :value="3" />
@@ -222,7 +287,12 @@
</ElFormItem> </ElFormItem>
<ElFormItem label="备注"> <ElFormItem label="备注">
<ElInput v-model="recallForm.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /> <ElInput
v-model="recallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<template #footer> <template #footer>
@@ -236,14 +306,14 @@
</ElDialog> </ElDialog>
<!-- 分配结果对话框 --> <!-- 分配结果对话框 -->
<ElDialog <ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
v-model="resultDialogVisible"
:title="resultTitle"
width="700px"
>
<ElDescriptions :column="2" border> <ElDescriptions :column="2" border>
<ElDescriptionsItem label="操作单号">{{ allocationResult.allocation_no }}</ElDescriptionsItem> <ElDescriptionsItem label="操作单号">{{
<ElDescriptionsItem label="待处理总数">{{ allocationResult.total_count }}</ElDescriptionsItem> allocationResult.allocation_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="待处理总数">{{
allocationResult.total_count
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数"> <ElDescriptionsItem label="成功数">
<ElTag type="success">{{ allocationResult.success_count }}</ElTag> <ElTag type="success">{{ allocationResult.success_count }}</ElTag>
</ElDescriptionsItem> </ElDescriptionsItem>
@@ -252,7 +322,10 @@
</ElDescriptionsItem> </ElDescriptionsItem>
</ElDescriptions> </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> <ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="allocationResult.failed_items" border max-height="300"> <ElTable :data="allocationResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" /> <ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -274,7 +347,12 @@
width="600px" width="600px"
@close="handleSeriesBindingDialogClose" @close="handleSeriesBindingDialogClose"
> >
<ElForm ref="seriesBindingFormRef" :model="seriesBindingForm" :rules="seriesBindingRules" label-width="120px"> <ElForm
ref="seriesBindingFormRef"
:model="seriesBindingForm"
:rules="seriesBindingRules"
label-width="120px"
>
<ElFormItem label="已选择卡数"> <ElFormItem label="已选择卡数">
<div>已选择 {{ selectedCards.length }} 张卡</div> <div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem> </ElFormItem>
@@ -307,11 +385,7 @@
</ElDialog> </ElDialog>
<!-- 套餐系列绑定结果对话框 --> <!-- 套餐系列绑定结果对话框 -->
<ElDialog <ElDialog v-model="seriesBindingResultDialogVisible" title="设置结果" width="700px">
v-model="seriesBindingResultDialogVisible"
title="设置结果"
width="700px"
>
<ElDescriptions :column="2" border> <ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数"> <ElDescriptionsItem label="成功数">
<ElTag type="success">{{ seriesBindingResult.success_count }}</ElTag> <ElTag type="success">{{ seriesBindingResult.success_count }}</ElTag>
@@ -321,7 +395,10 @@
</ElDescriptionsItem> </ElDescriptionsItem>
</ElDescriptions> </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> <ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="seriesBindingResult.failed_items" border max-height="300"> <ElTable :data="seriesBindingResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" /> <ElTableColumn prop="iccid" label="ICCID" width="180" />
@@ -331,11 +408,99 @@
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<ElButton type="primary" @click="seriesBindingResultDialogVisible = false">确定</ElButton> <ElButton type="primary" @click="seriesBindingResultDialogVisible = false"
>确定</ElButton
>
</div> </div>
</template> </template>
</ElDialog> </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> </ElCard>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
@@ -343,9 +508,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService, StorageService } from '@/api/modules' import { CardService, StorageService } from '@/api/modules'
import { ShopSeriesAllocationService } from '@/api/modules/shopSeriesAllocation' 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 { FormInstance, FormRules, UploadProps, UploadUserFile } from 'element-plus'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -362,6 +529,7 @@
defineOptions({ name: 'StandaloneCardList' }) defineOptions({ name: 'StandaloneCardList' })
const router = useRouter()
const loading = ref(false) const loading = ref(false)
const importDialogVisible = ref(false) const importDialogVisible = ref(false)
const importLoading = ref(false) const importLoading = ref(false)
@@ -405,13 +573,17 @@
failed_items: null failed_items: null
}) })
// 卡详情弹窗相关
const cardDetailDialogVisible = ref(false)
const cardDetailLoading = ref(false)
const currentCardDetail = ref<any>(null)
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState = { const initialSearchState = {
status: undefined, status: undefined,
carrier_id: undefined, carrier_id: undefined,
iccid: '', iccid: '',
msisdn: '', msisdn: '',
batch_no: '',
is_distributed: undefined is_distributed: undefined
} }
@@ -576,15 +748,6 @@
placeholder: '请输入卡接入号' placeholder: '请输入卡接入号'
} }
}, },
{
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{ {
label: '是否已分销', label: '是否已分销',
prop: 'is_distributed', 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(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
prop: 'iccid', prop: 'iccid',
label: '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', prop: 'msisdn',
@@ -754,7 +993,7 @@
{ {
prop: 'created_at', prop: 'created_at',
label: '创建时间', label: '创建时间',
width: 160, width: 180,
formatter: (row: StandaloneIotCard) => formatDateTime(row.created_at) formatter: (row: StandaloneIotCard) => formatDateTime(row.created_at)
} }
]) ])
@@ -897,7 +1136,9 @@
}) })
if (importRes.code === 0) { if (importRes.code === 0) {
ElMessage.success(importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度') ElMessage.success(
importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度'
)
importDialogVisible.value = false importDialogVisible.value = false
getTableData() getTableData()
} }
@@ -1167,7 +1408,9 @@
} else if (res.data.success_count === 0) { } else if (res.data.success_count === 0) {
ElMessage.error('套餐系列绑定设置失败') ElMessage.error('套餐系列绑定设置失败')
} else { } else {
ElMessage.warning(`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`) ElMessage.warning(
`部分设置成功:成功 ${res.data.success_count} 项,失败 ${res.data.fail_count}`
)
} }
} }
} catch (error) { } catch (error) {

View File

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

View File

@@ -5,6 +5,7 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
:show-expand="false"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
></ArtSearchBar> ></ArtSearchBar>
@@ -15,7 +16,13 @@
:columnList="columnOptions" :columnList="columnOptions"
v-model:columns="columnChecks" v-model:columns="columnChecks"
@refresh="handleRefresh" @refresh="handleRefresh"
/> >
<template #left>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入IoT卡
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 --> <!-- 表格 -->
<ArtTable <ArtTable
@@ -36,25 +43,152 @@
</ArtTable> </ArtTable>
</ElCard> </ElCard>
</div> </div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入IoT卡" width="700px" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 CSV 模板文件按照模板格式填写IoT卡信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段iccidICCIDmsisdnMSISDN/手机号</p>
<p>5. 必须选择运营商</p>
</div>
</template>
</ElAlert>
<div style="margin-bottom: 20px">
<ElButton type="primary" :icon="Download" @click="downloadTemplate">
下载导入模板
</ElButton>
</div>
<ElFormItem label="运营商" required style="margin-bottom: 20px">
<ElSelect v-model="selectedCarrierId" placeholder="请选择运营商" style="width: 100%">
<ElOption label="中国移动" :value="1" />
<ElOption label="中国联通" :value="2" />
<ElOption label="中国电信" :value="3" />
</ElSelect>
</ElFormItem>
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template>
</ElUpload>
<template #footer>
<ElButton @click="handleCancelImport">取消</ElButton>
<ElButton
type="primary"
:loading="uploading"
:disabled="!fileList.length || !selectedCarrierId"
@click="submitUpload"
>
开始导入
</ElButton>
</template>
</ElDialog>
<!-- 任务详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="IoT卡导入任务详情" width="900px" align-center>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.task_no
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(currentDetail.status)">{{ currentDetail.status_text }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ currentDetail.carrier_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名" :span="2">{{
currentDetail.file_name
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.total_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.success_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="跳过数">{{ currentDetail.skip_count }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.fail_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="警告数">
<span style="color: var(--el-color-warning)">{{ currentDetail.warning_count }}</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{ currentDetail.started_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{ currentDetail.completed_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.created_at }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.error_message
}}</ElDescriptionsItem>
</ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider>
<div
v-if="currentDetail.failed_items && currentDetail.failed_items.length"
style="max-height: 300px; overflow-y: auto"
>
<ElTable :data="currentDetail.failed_items" border size="small">
<ElTableColumn label="行号" type="index" width="80" :index="(index) => index + 1" />
<ElTableColumn label="ICCID" prop="iccid" width="200" />
<ElTableColumn label="MSISDN" prop="msisdn" width="150" />
<ElTableColumn label="失败原因" prop="reason" min-width="200">
<template #default="{ row }">
{{ row.reason || row.error || '未知错误' }}
</template>
</ElTableColumn>
</ElTable>
</div>
<ElEmpty v-else description="无失败记录" />
<template #footer>
<ElButton @click="detailDialogVisible = false">关闭</ElButton>
<ElButton
v-if="currentDetail.fail_count > 0"
type="primary"
:icon="Download"
@click="downloadFailData"
>
下载失败数据
</ElButton>
</template>
</ElDialog>
</ArtTableFullScreen> </ArtTableFullScreen>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router'
import { CardService } from '@/api/modules' 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 type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue' import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { StorageService } from '@/api/modules/storage'
import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card' import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
defineOptions({ name: 'IotCardTask' }) defineOptions({ name: 'IotCardTask' })
const router = useRouter()
const loading = ref(false) const loading = ref(false)
const tableRef = ref() 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 = { const initialSearchState = {
@@ -145,6 +279,7 @@
] ]
const taskList = ref<IotCardImportTask[]>([]) const taskList = ref<IotCardImportTask[]>([])
const currentDetail = ref<any>({})
// 获取状态标签类型 // 获取状态标签类型
const getStatusType = (status: IotCardImportTaskStatus) => { const getStatusType = (status: IotCardImportTaskStatus) => {
@@ -163,14 +298,24 @@
} }
// 查看详情 // 查看详情
const viewDetail = (row: IotCardImportTask) => { const viewDetail = async (row: IotCardImportTask) => {
router.push({ try {
path: '/asset-management/task-detail', const res = await CardService.getIotCardImportTaskDetail(row.id)
query: { if (res.code === 0 && res.data) {
id: row.id, currentDetail.value = {
task_type: 'card' ...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', prop: 'file_name',
label: '文件名', label: '文件名',
minWidth: 250 minWidth: 250,
showOverflowTooltip: true
}, },
{ {
prop: 'total_count', prop: 'total_count',
@@ -206,15 +352,17 @@
{ {
prop: 'success_count', prop: 'success_count',
label: '成功数', label: '成功数',
width: 80 width: 80,
formatter: (row: IotCardImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
}, },
{ {
prop: 'fail_count', prop: 'fail_count',
label: '失败数', label: '失败数',
width: 80, width: 80,
formatter: (row: IotCardImportTask) => { formatter: (row: IotCardImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success' return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
return h(ElTag, { type, size: 'small' }, () => row.fail_count)
} }
}, },
{ {
@@ -225,37 +373,57 @@
{ {
prop: 'started_at', prop: 'started_at',
label: '开始时间', label: '开始时间',
width: 160, width: 180,
formatter: (row: IotCardImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-') formatter: (row: IotCardImportTask) => (row.started_at ? formatDateTime(row.started_at) : '-')
}, },
{ {
prop: 'completed_at', prop: 'completed_at',
label: '完成时间', label: '完成时间',
width: 160, width: 180,
formatter: (row: IotCardImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-') formatter: (row: IotCardImportTask) =>
row.completed_at ? formatDateTime(row.completed_at) : '-'
}, },
{ {
prop: 'error_message', prop: 'error_message',
label: '错误信息', label: '错误信息',
minWidth: 200, minWidth: 200,
showOverflowTooltip: true,
formatter: (row: IotCardImportTask) => row.error_message || '-' formatter: (row: IotCardImportTask) => row.error_message || '-'
}, },
{ {
prop: 'created_at', prop: 'created_at',
label: '创建时间', label: '创建时间',
width: 160, width: 180,
formatter: (row: IotCardImportTask) => formatDateTime(row.created_at) formatter: (row: IotCardImportTask) => formatDateTime(row.created_at)
}, },
{ {
prop: 'operation', prop: 'operation',
label: '操作', label: '操作',
width: 120, width: 180,
fixed: 'right', fixed: 'right',
formatter: (row: IotCardImportTask) => { formatter: (row: IotCardImportTask) => {
return h(ArtButtonTable, { const buttons = []
text: '查看详情',
// 显示"查看详情"按钮
buttons.push(
h(ArtButtonTable, {
text: '详情',
onClick: () => viewDetail(row) 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 pagination.page = newCurrentPage
getTableData() 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.iot-card-task-page { .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> </style>

View File

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

View File

@@ -15,8 +15,14 @@
<p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p> <p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 CSV 格式.csv单次最多导入 1000 </p> <p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. CSV 文件编码UTF-8推荐 GBK</p> <p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. 必填字段device_no设备号device_name设备名称device_model设备型号</p> <p
<p>5. 可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p> >4.
必填字段device_no设备号device_name设备名称device_model设备型号</p
>
<p
>5.
可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p
>
<p>6. 设备号重复将自动跳过导入后可在任务管理中查看详情</p> <p>6. 设备号重复将自动跳过导入后可在任务管理中查看详情</p>
</div> </div>
</template> </template>
@@ -59,52 +65,6 @@
</ElRow> </ElRow>
</ElCard> </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"> <ElCard shadow="never" style="margin-top: 20px">
<template #header> <template #header>
@@ -117,71 +77,27 @@
style="width: 120px; margin-right: 12px" style="width: 120px; margin-right: 12px"
clearable clearable
> >
<ElOption label="全部" value="" /> <ElOption label="全部" :value="null" />
<ElOption label="处理" value="processing" /> <ElOption label="处理" :value="1" />
<ElOption label="完成" value="success" /> <ElOption label="处理中" :value="2" />
<ElOption label="失败" value="failed" /> <ElOption label="已完成" :value="3" />
<ElOption label="失败" :value="4" />
</ElSelect> </ElSelect>
<ElButton @click="refreshList">刷新</ElButton> <ElButton @click="refreshList">刷新</ElButton>
</div> </div>
</div> </div>
</template> </template>
<ArtTable :data="filteredRecords" index> <ArtTable
<template #default> rowKey="id"
<ElTableColumn label="导入批次号" prop="batchNo" width="180" /> ref="tableRef"
<ElTableColumn label="文件名" prop="fileName" min-width="200" show-overflow-tooltip /> :loading="loading"
<ElTableColumn label="设备总数" prop="totalCount" width="100" /> :data="filteredRecords"
<ElTableColumn label="成功数" prop="successCount" width="100"> :marginTop="10"
<template #default="scope"> :stripe="false"
<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)"
> >
失败数据 <template #default>
</el-button> <ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ElTableColumn>
</template> </template>
</ArtTable> </ArtTable>
</ElCard> </ElCard>
@@ -189,20 +105,31 @@
<!-- 导入详情对话框 --> <!-- 导入详情对话框 -->
<ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center> <ElDialog v-model="detailDialogVisible" title="设备导入详情" width="900px" align-center>
<ElDescriptions :column="2" border> <ElDescriptions :column="2" border>
<ElDescriptionsItem label="任务编号" :span="2">{{
currentDetail.taskNo
}}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">{{ currentDetail.statusText }}</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem> <ElDescriptionsItem label="批次号">{{ currentDetail.batchNo }}</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ currentDetail.fileName }}</ElDescriptionsItem> <ElDescriptionsItem label="文件名" :span="2">{{
<ElDescriptionsItem label="设备总数">{{ currentDetail.totalCount }}</ElDescriptionsItem> currentDetail.fileName
<ElDescriptionsItem label="成功导入"> }}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ currentDetail.totalCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">
<span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span> <span style="color: var(--el-color-success)">{{ currentDetail.successCount }}</span>
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="导入失败"> <ElDescriptionsItem label="跳过数">{{ currentDetail.skipCount }}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span> <span style="color: var(--el-color-danger)">{{ currentDetail.failCount }}</span>
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="已绑定ICCID"> <ElDescriptionsItem label="警告数">
<ElTag type="success">{{ currentDetail.bindCount }}</ElTag> <span style="color: var(--el-color-warning)">{{ currentDetail.warningCount }}</span>
</ElDescriptionsItem> </ElDescriptionsItem>
<ElDescriptionsItem label="导入时间">{{ currentDetail.importTime }}</ElDescriptionsItem> <ElDescriptionsItem label="开始时间">{{ currentDetail.startedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="操作人">{{ currentDetail.operator }}</ElDescriptionsItem> <ElDescriptionsItem label="完成时间">{{ currentDetail.completedAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ currentDetail.createdAt }}</ElDescriptionsItem>
<ElDescriptionsItem label="错误信息" :span="2">{{
currentDetail.errorMessage
}}</ElDescriptionsItem>
</ElDescriptions> </ElDescriptions>
<ElDivider content-position="left">失败明细</ElDivider> <ElDivider content-position="left">失败明细</ElDivider>
@@ -235,22 +162,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { h, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElTag, ElProgress, ElIcon, ElButton } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import { Download, UploadFilled, View, Loading } from '@element-plus/icons-vue'
Download,
UploadFilled,
View,
Loading,
Upload,
SuccessFilled,
CircleCloseFilled,
TrendCharts
} from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus' import type { UploadInstance } from 'element-plus'
import { StorageService } from '@/api/modules/storage' import { StorageService } from '@/api/modules/storage'
import { DeviceService } from '@/api/modules' 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' }) defineOptions({ name: 'DeviceImport' })
@@ -265,107 +186,213 @@
interface ImportRecord { interface ImportRecord {
id: string id: string
taskNo: string
status: number
statusText: string
batchNo: string batchNo: string
fileName: string fileName: string
totalCount: number totalCount: number
successCount: number successCount: number
skipCount: number
failCount: number failCount: number
bindCount: number warningCount: number
status: 'pending' | 'processing' | 'success' | 'failed' startedAt: string
progress: number completedAt: string
importTime: string errorMessage: string
operator: string createdAt: string
failReasons?: FailReason[] failReasons?: FailReason[]
} }
const uploadRef = ref<UploadInstance>() const uploadRef = ref<UploadInstance>()
const tableRef = ref()
const fileList = ref<File[]>([]) const fileList = ref<File[]>([])
const uploading = ref(false) const uploading = ref(false)
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
const statusFilter = ref('') const statusFilter = ref<number | null>(null)
const loading = ref(false)
const importRecords = ref<ImportRecord[]>([ 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 currentDetail = ref<ImportRecord>({ const currentDetail = ref<ImportRecord>({
id: '', id: '',
taskNo: '',
status: 1,
statusText: '',
batchNo: '', batchNo: '',
fileName: '', fileName: '',
totalCount: 0, totalCount: 0,
successCount: 0, successCount: 0,
skipCount: 0,
failCount: 0, failCount: 0,
bindCount: 0, warningCount: 0,
status: 'pending', startedAt: '',
progress: 0, completedAt: '',
importTime: '', errorMessage: '',
operator: '' createdAt: ''
}) })
const filteredRecords = computed(() => { 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) 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 = () => { const downloadTemplate = () => {
ElMessage.success('模板下载中...') // CSV模板内容 - 包含表头和示例数据
setTimeout(() => { 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('设备导入模板下载成功') ElMessage.success('设备导入模板下载成功')
}, 1000)
} }
const handleFileChange = (uploadFile: any) => { const handleFileChange = (uploadFile: any) => {
@@ -444,17 +471,15 @@
// 清空文件列表 // 清空文件列表
clearFiles() clearFiles()
// 显示成功消息并提供跳转链接 // 刷新任务列表
await fetchImportTasks()
// 显示成功消息
ElMessage.success({ ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`, message: `导入任务已创建!任务编号:${taskNo}`,
duration: 5000, duration: 3000,
showClose: true showClose: true
}) })
// 3秒后跳转到任务管理页面
setTimeout(() => {
router.push('/asset-management/task-management')
}, 3000)
} catch (error: any) { } catch (error: any) {
console.error('设备导入失败:', error) console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败') 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 detailDialogVisible.value = true
} }
} catch (error) {
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
}
}
const downloadFailData = (row: ImportRecord) => { const downloadFailData = (row: ImportRecord) => {
ElMessage.info(`正在下载批次 ${row.batchNo} 的失败数据...`) if (!row.failReasons || row.failReasons.length === 0) {
setTimeout(() => { ElMessage.warning('没有失败数据可下载')
ElMessage.success('失败数据下载完成') return
}, 1000)
} }
// 生成失败数据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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,12 @@
<ElInput v-model="form.perm_name" placeholder="请输入权限名称" /> <ElInput v-model="form.perm_name" placeholder="请输入权限名称" />
</ElFormItem> </ElFormItem>
<ElFormItem label="权限标识" prop="perm_code"> <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>
<ElFormItem label="权限类型" prop="perm_type"> <ElFormItem label="权限类型" prop="perm_type">
<ElSelect v-model="form.perm_type" placeholder="请选择权限类型" style="width: 100%"> <ElSelect v-model="form.perm_type" placeholder="请选择权限类型" style="width: 100%">

View File

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