fetch(add): 新增
Some checks failed
构建并部署前端到测试环境 / build-and-deploy (push) Failing after 6s

This commit is contained in:
sexygoat
2026-01-27 09:18:45 +08:00
parent 0eed8244e5
commit 5c6312c407
33 changed files with 4897 additions and 374 deletions

View File

@@ -0,0 +1,278 @@
# Device Management Design
## Context
The system currently has a basic device-import page but lacks comprehensive device management capabilities. Devices are critical assets that need to be tracked throughout their lifecycle - from import, allocation to shops, binding with SIM cards, to eventual recall. This change adds full device management to complement the existing card management features.
Key integration points:
- **Existing Card System**: Devices must bind with cards from the existing card-list module
- **Shop System**: Devices are allocated to shops using the existing ShopService
- **Object Storage**: Imports use the existing StorageService for large file uploads
- **Task System**: Import tasks must integrate with the existing task-management infrastructure
## Goals
1. **Complete Lifecycle Management**: Support all device operations from import to deletion
2. **Seamless Integration**: Reuse existing UI patterns, services, and components
3. **Scalable Import**: Handle large CSV imports efficiently using object storage
4. **Operation Traceability**: Track all device operations through task management
5. **Consistent UX**: Match the look and feel of existing card-list and enterprise-customer pages
## Key Decisions
### Decision 1: Three-Step Object Storage Import
**Choice**: Use StorageService.getUploadUrl → uploadFile → DeviceService.importDevices
**Rationale**:
- Handles large files (thousands of devices) without timeout issues
- Decouples upload from processing (backend can process asynchronously)
- Consistent with modern cloud architecture patterns
- Allows progress tracking through task management
**Alternatives Considered**:
- Direct multipart upload to backend (rejected: not scalable for large files)
- Two-step process without pre-signed URL (rejected: less secure, more backend load)
**Implementation Notes**:
- Frontend only handles upload to object storage, not file parsing
- Backend processes the file asynchronously and creates task records
- Task management provides visibility into import progress
### Decision 2: Device-Card Binding Model
**Choice**: Store binding with explicit slot_position (1-4) in device_cards table
**Rationale**:
- IoT devices have physical SIM slots that need explicit identification
- Each device can have 1-4 slots (max_sim_slots)
- One card can only bind to one device slot (enforced by backend)
- Slot position is critical for physical device configuration
**Alternatives Considered**:
- Auto-assign slot positions (rejected: operators need to know physical slot numbers)
- Allow one card to bind to multiple devices (rejected: not realistic for physical SIMs)
**Implementation Notes**:
- Device detail page shows a 4-slot grid (empty slots show "Bind Card" button)
- Binding dialog requires explicit slot selection
- Unbinding updates bound_card_count and frees the slot
### Decision 3: Unified Task Management
**Choice**: Extend existing task-management to support device import tasks
**Rationale**:
- Reuses existing task infrastructure
- Provides consistent UX for all import operations
- Avoids duplicate task tracking logic
- Allows unified search/filter across task types
**Alternatives Considered**:
- Separate device task management page (rejected: creates UX fragmentation)
- Embed task tracking only in device-import page (rejected: limited visibility)
**Implementation Notes**:
- Add task_type field to distinguish ICCID vs Device imports
- Task detail page renders different content based on task_type
- Device tasks show device_no and bound ICCIDs instead of just ICCID
### Decision 4: Batch Operation Result Display
**Choice**: Show detailed results in dialog after batch allocation/recall
**Rationale**:
- Operations may partially succeed (some devices succeed, others fail)
- Operators need to know exactly which devices failed and why
- Allows retry of failed operations without re-selecting all devices
**Alternatives Considered**:
- Just show toast notification (rejected: insufficient detail for partial failures)
- Navigate to separate results page (rejected: disrupts workflow)
**Implementation Notes**:
- Dialog shows summary: total, success count, failure count
- Expandable table shows failed devices with error messages
- Success closes dialog, partial failure keeps it open for review
### Decision 5: Component Reuse Strategy
**Choice**: Use existing Art* components (ArtTableFullScreen, ArtSearchBar, ArtTable, ArtButtonTable)
**Rationale**:
- Maintains UI consistency across the application
- Reduces development time
- Leverages tested, stable components
- Easier for users familiar with other pages
**Reference Implementation**:
- Device-list follows role/index.vue pattern
- Device-detail follows card-list detail modal pattern
- Search form follows enterprise-customer search pattern
**Implementation Notes**:
- Use CommonStatus for status values (ENABLED/DISABLED)
- Use ElSwitch for status toggles
- Use ArtButtonTable for action buttons
- Follow ElDescriptions layout for detail pages
## Architecture Diagrams
### Device Import Flow
```
┌─────────┐ 1. Select CSV ┌──────────────┐
│ Admin │ ──────────────────> │ device-import│
│ │ │ (Vue) │
└─────────┘ └──────┬───────┘
│ 2. getUploadUrl()
v
┌──────────────┐
│ Storage │
│ Service │
└──────┬───────┘
│ 3. Upload to OSS
v
┌──────────────┐
│ Object │
│ Storage │
└──────────────┘
│ 4. importDevices(file_key)
v
┌──────────────┐
│ Device │
│ Service │
└──────┬───────┘
│ 5. Create Task
v
┌──────────────┐
│ Task │
│ Management │
└──────────────┘
```
### Device-Card Binding
```
┌─────────┐ ┌──────────────┐
│ Device │ ───── bound to ────> │ Card │
│ │ (1 to N) │ │
└─────────┘ └──────────────┘
│ │
│ has_many bindings │ has_one binding
v v
┌─────────────────────────────────────────────┐
│ DeviceCardBinding │
│ - device_id │
│ - card_id │
│ - slot_position (1-4) │
│ - bound_at │
└─────────────────────────────────────────────┘
```
## Data Flow
### Device List Page Load
1. User navigates to /asset-management/device-list
2. Vue component mounts, calls DeviceService.getDevices(params)
3. Backend returns paginated device list with bound_card_count
4. Table renders with status switches and action buttons
### Batch Allocation Flow
1. User selects devices, clicks "Batch Allocate"
2. Dialog opens with shop selector
3. User selects shop, adds remarks, confirms
4. Call DeviceService.allocateDevices({ device_ids, shop_id, remarks })
5. Backend processes each device, returns results array
6. Dialog shows summary and failed device details
### Card Binding Flow
1. User opens device detail page
2. Clicks "Bind Card" for an empty slot
3. Dialog shows card search and slot selection
4. User selects card and slot (e.g., slot 3)
5. Call DeviceService.bindCard(device_id, { card_id, slot_position: 3 })
6. Backend validates slot is empty, creates binding
7. Refresh device detail to show new binding
## Migration Plan
### Updating Existing Device Import Page
**Current State** (`src/views/batch/device-import/index.vue`):
- Uses ElUpload with drag-and-drop
- Mock data for import records
- No real API integration
**Migration Steps**:
1. Replace ElUpload with three-step upload logic
- Add getUploadUrl call
- Add uploadFile to object storage
- Add importDevices call with file_key
2. Remove mock import records
3. Link to task-management for tracking
4. Update CSV format instructions
**Backward Compatibility**:
- This is a new feature area with no existing production data
- No API breaking changes
- UI changes are additive (improve existing page)
### Extending Task Management
**Current State**:
- Only handles ICCID import tasks
- Single task type rendering
**Migration Steps**:
1. Add task_type filter dropdown (default: show all)
2. Add task_type badge in task list
3. Task detail page: switch rendering based on task_type
4. Add device-specific fields to task detail view
**Backward Compatibility**:
- Existing ICCID tasks continue to work unchanged
- Filter defaults to showing all types
- No database schema changes required (task_type already exists)
## Testing Strategy
### Unit Testing
- DeviceService API methods with mock responses
- Form validation logic
- Utility functions (formatters, validators)
### Integration Testing
- Device list search and pagination
- Batch allocation with partial failures
- Card binding/unbinding workflow
- Import task creation and status tracking
### E2E Testing Scenarios
1. Import devices via CSV → verify task created → check task detail
2. Search devices → select multiple → allocate to shop → verify shop assignment
3. View device detail → bind card to slot 2 → unbind → verify empty slot
4. Batch recall devices → verify shop cleared → check operation history
### Performance Considerations
- Device list pagination (default 20 per page)
- Debounced search input (300ms delay)
- Batch operation result pagination (if >100 results)
- Large CSV imports handled asynchronously (no frontend timeout)
## Security Considerations
1. **Authorization**: All device operations require admin role
2. **Input Validation**:
- Device number format validation
- Slot position range (1 to max_sim_slots)
- CSV file size limits
3. **Object Storage Security**: Pre-signed URLs expire after 15 minutes
4. **Batch Operation Limits**: Backend enforces max 1000 devices per batch
5. **XSS Prevention**: All user inputs sanitized before rendering
## Open Questions
None at this time. All major design decisions have been made based on existing system patterns and API specifications.

View File

@@ -0,0 +1,60 @@
# Change: 添加设备管理功能
## Why
当前系统只有设备导入页面,缺少完整的设备管理能力。需要提供设备的全生命周期管理,包括:
- 查看和搜索设备列表
- 查看设备详情和绑定的卡信息
- 管理设备与卡的绑定关系
- 批量分配和回收设备
- 完善设备导入流程和任务追踪
这是物联网卡管理系统的核心资产管理功能之一。
## What Changes
### 新增功能
- **设备列表页面**: 支持多条件搜索、分页、列筛选、批量操作(分配、回收、删除)
- **设备详情页面**: 展示设备基本信息、绑定的卡列表、操作历史
- **卡绑定管理**: 在设备详情页绑定/解绑卡
- **批量分配对话框**: 选择设备批量分配给店铺
- **批量回收对话框**: 从店铺批量回收设备
- **设备导入改进**: 基于对象存储的三步导入流程
- **导入任务管理**: 任务列表和详情查看
### API 集成
- DeviceService: 11个API接口
- 类型定义: Device, DeviceBinding, ImportTask 等
### UI 组件
- 遵循现有组件模式 (ArtTableFullScreen, ArtSearchBar等)
- 复用 CommonStatus 统一状态变量
- 使用 ElDescriptions、ElTag、ElSwitch 等组件
## Impact
### 影响的功能模块
- **新增**: 资产管理 / 设备列表(主列表页)
- **新增**: 资产管理 / 设备详情
- **改进**: 批量操作 / 设备导入(改为对象存储模式)
- **新增**: 批量操作 / 导入任务列表(独立页面)
### 影响的代码
- `src/api/modules/device.ts` (新增)
- `src/types/api/device.ts` (新增)
- `src/views/asset-management/device-list/index.vue` (新增)
- `src/views/asset-management/device-detail/index.vue` (新增)
- `src/views/asset-management/task-management/index.vue` (修改:支持设备导入任务)
- `src/views/asset-management/task-detail/index.vue` (修改:支持设备导入任务详情)
- `src/views/batch/device-import/index.vue` (修改:使用对象存储)
- `src/router/routes/asyncRoutes.ts` (新增路由)
- `src/router/routesAlias.ts` (新增路由别名)
- `src/locales/langs/zh.json` (新增翻译)
- `src/api/modules/index.ts` (导出 DeviceService)
- `src/types/api/index.ts` (导出 Device 类型)
### 依赖关系
- 依赖现有的 StorageService (对象存储)
- 依赖现有的 ShopService (店铺选择)
- 设备与卡的关联管理
- 任务管理页面需要支持多种任务类型ICCID导入 + 设备导入)

View File

@@ -0,0 +1,159 @@
# Device Management Specification
## ADDED Requirements
### Requirement: Device List Management
The system SHALL provide a searchable device list with multi-condition filtering, pagination, and batch operations.
#### Scenario: Search devices by multiple criteria
**WHEN** an administrator enters search criteria (device number, device name, status, shop, batch number, device type, manufacturer, creation time range)
**THEN** the system shall display only devices matching all specified criteria with pagination support
#### 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
#### 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
#### 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
#### 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
### Requirement: Device Detail Viewing
The system SHALL display comprehensive device information including basic properties and bound SIM cards.
#### Scenario: View device basic information
**WHEN** an administrator navigates to a device detail page
**THEN** the system shall display all device properties in a descriptive layout: device number, name, model, type, manufacturer, max SIM slots, status, shop, batch number, activation time, creation time
#### 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
#### 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
### Requirement: Device-Card Binding Management
The system SHALL allow administrators to bind and unbind SIM cards to specific device slots.
#### Scenario: Bind a card to a device slot
**WHEN** an administrator selects a card and slot position (1-4) in the binding dialog
**THEN** the system shall create the binding, update the bound card count, and refresh the card list
#### 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
#### 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
#### 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
### Requirement: Batch Device Allocation
The system SHALL support batch allocation of devices to shops with result tracking.
#### Scenario: Allocate multiple devices to a shop
**WHEN** an administrator selects multiple devices, chooses a target shop, adds optional remarks, and confirms allocation
**THEN** the system shall allocate all selected devices to the shop and display success/failure results for each device
#### 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
#### 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
### Requirement: Batch Device Recall
The system SHALL support batch recall of devices from shops with result tracking.
#### Scenario: Recall multiple devices
**WHEN** an administrator selects multiple devices, adds optional remarks, and confirms recall
**THEN** the system shall recall all selected devices from their shops and display success/failure results
#### 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
#### 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
### Requirement: Device Import via Object Storage
The system SHALL support CSV-based device import using a three-step object storage upload process.
#### Scenario: Import devices with three-step process
**WHEN** an administrator uploads a CSV file
**THEN** the system shall:
1. Get upload URL from StorageService.getUploadUrl
2. Upload file to object storage using StorageService.uploadFile
3. Call DeviceService.importDevices with the file_key
**AND** display the task number for tracking
#### 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
#### 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
#### 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
### Requirement: Import Task Management
The system SHALL track device import tasks with detailed status and record information.
#### Scenario: List import tasks with type filter
**WHEN** an administrator views the task management page
**THEN** the system shall display both ICCID import tasks and Device import tasks with a type filter dropdown
#### 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
#### 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
#### 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
#### 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
## MODIFIED Requirements
### Requirement: Task Type Support
Task management SHALL support multiple task types including ICCID import and Device import with type-specific detail views.
**Previous behavior**: Task management only supported ICCID import tasks.
#### Scenario: Filter tasks by type
**WHEN** an administrator selects a task type filter (ICCID Import / Device Import)
**THEN** the system shall display only tasks of the selected type
#### 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

View File

@@ -0,0 +1,126 @@
# Implementation Tasks
## 1. 类型定义和API服务 (Foundation)
- [ ] 1.1 创建 `src/types/api/device.ts` 定义所有设备相关类型
- DeviceItem, DeviceQueryParams, DevicePageResult
- DeviceCardBinding, DeviceCardBindingList
- AllocateDevicesRequest/Response, RecallDevicesRequest/Response
- BindCardRequest/Response, UnbindCardResponse
- ImportDeviceRequest/Response, DeviceImportTask相关类型
- [ ] 1.2 创建 `src/api/modules/device.ts` 实现 DeviceService
- getDevices (列表)
- getDeviceById (详情)
- deleteDevice (删除)
- getDeviceCards (绑定的卡列表)
- bindCard (绑定卡)
- unbindCard (解绑卡)
- allocateDevices (批量分配)
- recallDevices (批量回收)
- importDevices (批量导入)
- getImportTasks (导入任务列表)
- getImportTaskDetail (导入任务详情)
- [ ] 1.3 在 `src/api/modules/index.ts` 导出 DeviceService
- [ ] 1.4 在 `src/types/api/index.ts` 导出 Device 相关类型
## 2. 设备列表页面 (Core UI)
- [ ] 2.1 创建 `src/views/asset-management/device-list/index.vue`
- 搜索栏:设备号、设备名称、状态、店铺、批次号、设备类型、制造商、创建时间范围
- 表格:展示设备信息,支持勾选、列筛选
- 表格列ID、设备号、设备名称、设备型号、设备类型、制造商、最大插槽数、已绑定卡数、状态、店铺、批次号、激活时间、创建时间
- 状态使用 CommonStatus + ElSwitch 显示
- 操作列:查看详情、删除按钮(使用 ArtButtonTable
- [ ] 2.2 实现批量操作按钮
- 批量分配:弹出对话框选择目标店铺
- 批量回收:弹出对话框确认回收
- 导入设备:跳转到设备导入页面
- [ ] 2.3 实现批量分配对话框
- 选择目标店铺(下拉搜索)
- 显示已选设备数量
- 备注输入
- 展示分配结果(成功/失败)
- [ ] 2.4 实现批量回收对话框
- 确认回收设备列表
- 备注输入
- 展示回收结果(成功/失败)
## 3. 设备详情页面 (Detail View)
- [ ] 3.1 创建 `src/views/asset-management/device-detail/index.vue`
- 设备基本信息卡片ElDescriptions
- 绑定的卡列表表格展示4个插槽位置
- 返回按钮
- [ ] 3.2 实现卡绑定管理
- 绑定卡按钮:弹出对话框选择卡和插槽位置
- 解绑按钮:每个绑定记录旁边
- 显示插槽位置1-4
- 展示卡的基本信息ICCID、手机号、运营商、状态
## 4. 任务管理改进 (Task Management)
- [ ] 4.1 修改 `src/views/asset-management/task-management/index.vue`
- 添加任务类型筛选ICCID导入 / 设备导入
- 支持展示设备导入任务
- 任务列表显示任务类型标签
- [ ] 4.2 修改 `src/views/asset-management/task-detail/index.vue`
- 根据任务类型展示不同的详情内容
- 设备导入任务显示设备号、绑定的ICCID、失败原因
- 失败和跳过记录展示
## 5. 设备导入改进 (Import Enhancement)
- [ ] 5.1 修改 `src/views/batch/device-import/index.vue`
- 改用对象存储三步上传流程
1. 获取上传URL (StorageService.getUploadUrl)
2. 上传文件到对象存储 (StorageService.uploadFile)
3. 调用导入接口 (DeviceService.importDevices)
- CSV格式说明device_no, device_name, device_model, device_type, max_sim_slots, manufacturer, iccid_1~iccid_4, batch_no
- 导入成功后显示任务编号,引导去任务管理查看
- [ ] 5.2 优化导入记录展示
- 移除mock数据改为真实API调用
- 点击"查看详情"跳转到任务详情页
## 6. 路由和导航 (Routing)
- [ ] 6.1 在 `src/router/routes/asyncRoutes.ts` 添加路由
- /asset-management/device-list (设备列表)
- /asset-management/device-detail (设备详情带query参数id)
- [ ] 6.2 在 `src/router/routesAlias.ts` 添加路由别名
- DeviceList = '/asset-management/device-list'
- DeviceDetail = '/asset-management/device-detail'
- [ ] 6.3 在 `src/locales/langs/zh.json` 添加翻译
- assetManagement.deviceList: "设备列表"
- assetManagement.deviceDetail: "设备详情"
## 7. 测试和优化 (Testing & Polish)
- [ ] 7.1 功能测试
- 设备列表的搜索、分页、排序
- 批量分配和回收流程
- 设备与卡的绑定/解绑
- 设备导入完整流程
- 任务状态查看
- [ ] 7.2 边界情况处理
- 空列表状态
- 加载失败提示
- 删除确认提示
- 表单验证
- [ ] 7.3 性能优化
- 列表数据懒加载
- 防抖搜索
- 批量操作结果分页展示
- [ ] 7.4 代码审查
- TypeScript类型完整性
- 错误处理
- 代码复用性
- 注释完整性
## Dependencies
- 1.x 必须先完成才能开始 2.x-6.x
- 2.1-2.2 可以并行开发
- 3.x 依赖 1.x可与 2.x 并行
- 4.x 和 5.x 依赖 1.x可与其他任务并行
- 6.x 可在对应页面开发完成后立即进行
- 7.x 在所有功能开发完成后进行

653
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "vue-ts-vite-demo", "name": "internet-of-things-admin",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vue-ts-vite-demo", "name": "internet-of-things-admin",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
@@ -13,6 +13,7 @@
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "next", "@wangeditor/editor-for-vue": "next",
"aws-sdk": "^2.1693.0",
"axios": "^1.7.5", "axios": "^1.7.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.4.0", "echarts": "^5.4.0",
@@ -43,18 +44,18 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/compiler-sfc": "^3.0.5", "@vue/compiler-sfc": "^3.0.5",
"commitizen": "^4.3.0", "commitizen": "^4.3.0",
"cz-git": "^1.9.4", "cz-git": "^1.11.1",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.27.0", "eslint-plugin-vue": "^9.27.0",
"globals": "^15.9.0", "globals": "^15.9.0",
"husky": "^9.1.5", "husky": "^9.1.5",
"lint-staged": "^15.2.10", "lint-staged": "^15.5.2",
"prettier": "^3.3.3", "prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.81.0", "sass": "^1.81.0",
"stylelint": "^16.8.2", "stylelint": "^16.20.0",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.6.0", "stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended-scss": "^14.1.0", "stylelint-config-recommended-scss": "^14.1.0",
@@ -146,6 +147,67 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cacheable/memory": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.0.7.tgz",
"integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.3.3",
"@keyv/bigmap": "^1.3.0",
"hookified": "^1.14.0",
"keyv": "^5.5.5"
}
},
"node_modules/@cacheable/memory/node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/memory/node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/@cacheable/utils": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.3.3.tgz",
"integrity": "sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"hashery": "^1.3.0",
"keyv": "^5.5.5"
}
},
"node_modules/@cacheable/utils/node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/@codemirror/autocomplete": { "node_modules/@codemirror/autocomplete": {
"version": "6.18.6", "version": "6.18.6",
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
@@ -762,6 +824,23 @@
"@csstools/css-tokenizer": "^3.0.4" "@csstools/css-tokenizer": "^3.0.4"
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.26",
"resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz",
"integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0"
},
"node_modules/@csstools/css-tokenizer": { "node_modules/@csstools/css-tokenizer": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
@@ -813,13 +892,14 @@
} }
}, },
"node_modules/@dual-bundle/import-meta-resolve": { "node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.1.0", "version": "4.2.1",
"resolved": "https://registry.npmmirror.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "resolved": "https://registry.npmmirror.com/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
"integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==", "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
"dev": true, "dev": true,
"license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/JounQin"
} }
}, },
"node_modules/@element-plus/icons-vue": { "node_modules/@element-plus/icons-vue": {
@@ -1618,37 +1698,11 @@
} }
}, },
"node_modules/@keyv/serialize": { "node_modules/@keyv/serialize": {
"version": "1.0.3", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.0.3.tgz", "resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"dev": true, "dev": true,
"dependencies": { "license": "MIT"
"buffer": "^6.0.3"
}
},
"node_modules/@keyv/serialize/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
}, },
"node_modules/@lezer/common": { "node_modules/@lezer/common": {
"version": "1.2.3", "version": "1.2.3",
@@ -3645,6 +3699,69 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"license": "MIT",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-sdk": {
"version": "2.1693.0",
"resolved": "https://registry.npmmirror.com/aws-sdk/-/aws-sdk-2.1693.0.tgz",
"integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
"ieee754": "1.1.13",
"jmespath": "0.16.0",
"querystring": "0.2.0",
"sax": "1.2.1",
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
"xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-sdk/node_modules/buffer": {
"version": "4.9.2",
"resolved": "https://registry.npmmirror.com/buffer/-/buffer-4.9.2.tgz",
"integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4",
"isarray": "^1.0.0"
}
},
"node_modules/aws-sdk/node_modules/ieee754": {
"version": "1.1.13",
"resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.1.13.tgz",
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==",
"license": "BSD-3-Clause"
},
"node_modules/aws-sdk/node_modules/uuid": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.0.0.tgz",
"integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/axios": { "node_modules/axios": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz", "resolved": "https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz",
@@ -3665,7 +3782,6 @@
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4424,13 +4540,17 @@
} }
}, },
"node_modules/cacheable": { "node_modules/cacheable": {
"version": "1.9.0", "version": "2.3.2",
"resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-1.9.0.tgz", "resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-2.3.2.tgz",
"integrity": "sha512-8D5htMCxPDUULux9gFzv30f04Xo3wCnik0oOxKoRTPIBoqA7HtOcJ87uBhQTs3jCfZZTrUBGsYIZOgE0ZRgMAg==", "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"hookified": "^1.8.2", "@cacheable/memory": "^2.0.7",
"keyv": "^5.3.3" "@cacheable/utils": "^2.3.3",
"hookified": "^1.15.0",
"keyv": "^5.5.5",
"qified": "^0.6.0"
} }
}, },
"node_modules/cacheable-request": { "node_modules/cacheable-request": {
@@ -4482,12 +4602,13 @@
} }
}, },
"node_modules/cacheable/node_modules/keyv": { "node_modules/cacheable/node_modules/keyv": {
"version": "5.3.3", "version": "5.6.0",
"resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.3.3.tgz", "resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-Rwu4+nXI9fqcxiEHtbkvoes2X+QfkTRo1TMkPfwzipGsJlJO/z69vqB4FNl9xJ3xCpAcbkvmEabZfPzrwN3+gQ==", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@keyv/serialize": "^1.0.3" "@keyv/serialize": "^1.1.1"
} }
}, },
"node_modules/cachedir": { "node_modules/cachedir": {
@@ -4499,6 +4620,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -4511,6 +4650,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
@@ -5405,10 +5560,11 @@
"dev": true "dev": true
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -5634,6 +5790,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": { "node_modules/define-lazy-prop": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
@@ -6939,6 +7112,15 @@
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"dev": true "dev": true
}, },
"node_modules/events": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/events/-/events-1.1.1.tgz",
"integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==",
"license": "MIT",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/exec-buffer": { "node_modules/exec-buffer": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmmirror.com/exec-buffer/-/exec-buffer-3.2.0.tgz", "resolved": "https://registry.npmmirror.com/exec-buffer/-/exec-buffer-3.2.0.tgz",
@@ -7485,6 +7667,21 @@
} }
} }
}, },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"license": "MIT",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz", "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz",
@@ -7596,6 +7793,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -8108,6 +8314,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbol-support-x": { "node_modules/has-symbol-support-x": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmmirror.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", "resolved": "https://registry.npmmirror.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
@@ -8154,6 +8372,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hashery": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/hashery/-/hashery-1.4.0.tgz",
"integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^1.14.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
@@ -8200,10 +8431,11 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
}, },
"node_modules/hookified": { "node_modules/hookified": {
"version": "1.9.0", "version": "1.15.0",
"resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.9.0.tgz", "resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.15.0.tgz",
"integrity": "sha512-2yEEGqphImtKIe1NXWEhu6yD3hlFR4Mxk4Mtp3XEyScpSt4pQ4ymmXA1zzxZpj99QkFK+nN0nzjeb2+RUi/6CQ==", "integrity": "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "2.8.9", "version": "2.8.9",
@@ -8323,9 +8555,10 @@
] ]
}, },
"node_modules/ignore": { "node_modules/ignore": {
"version": "7.0.4", "version": "7.0.5",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.4.tgz", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
} }
@@ -8970,8 +9203,7 @@
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
"dev": true
}, },
"node_modules/ini": { "node_modules/ini": {
"version": "4.1.1", "version": "4.1.1",
@@ -9070,6 +9302,22 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -9088,6 +9336,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -9169,6 +9429,25 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-gif": { "node_modules/is-gif": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-gif/-/is-gif-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/is-gif/-/is-gif-3.0.0.tgz",
@@ -9284,6 +9563,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-retry-allowed": { "node_modules/is-retry-allowed": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", "resolved": "https://registry.npmmirror.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
@@ -9332,6 +9629,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"license": "MIT",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": { "node_modules/is-unicode-supported": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -9390,8 +9702,7 @@
"node_modules/isarray": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
"dev": true
}, },
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
@@ -9420,6 +9731,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jmespath": {
"version": "0.16.0",
"resolved": "https://registry.npmmirror.com/jmespath/-/jmespath-0.16.0.tgz",
"integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/jpegtran-bin": { "node_modules/jpegtran-bin": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmmirror.com/jpegtran-bin/-/jpegtran-bin-6.0.1.tgz", "resolved": "https://registry.npmmirror.com/jpegtran-bin/-/jpegtran-bin-6.0.1.tgz",
@@ -11485,10 +11805,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.6",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -11503,8 +11832,9 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.8", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -11730,6 +12060,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qified": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/qified/-/qified-0.6.0.tgz",
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hookified": "^1.14.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qrcode.vue": { "node_modules/qrcode.vue": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.6.0.tgz", "resolved": "https://registry.npmmirror.com/qrcode.vue/-/qrcode.vue-3.6.0.tgz",
@@ -11767,6 +12110,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -12213,6 +12565,23 @@
} }
] ]
}, },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -12239,6 +12608,12 @@
"@parcel/watcher": "^2.4.1" "@parcel/watcher": "^2.4.1"
} }
}, },
"node_modules/sax": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/sax/-/sax-1.2.1.tgz",
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
"license": "ISC"
},
"node_modules/scroll-into-view-if-needed": { "node_modules/scroll-into-view-if-needed": {
"version": "2.2.31", "version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
@@ -12312,6 +12687,23 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -12799,9 +13191,9 @@
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw=="
}, },
"node_modules/stylelint": { "node_modules/stylelint": {
"version": "16.19.1", "version": "16.26.1",
"resolved": "https://registry.npmmirror.com/stylelint/-/stylelint-16.19.1.tgz", "resolved": "https://registry.npmmirror.com/stylelint/-/stylelint-16.26.1.tgz",
"integrity": "sha512-C1SlPZNMKl+d/C867ZdCRthrS+6KuZ3AoGW113RZCOL0M8xOGpgx7G70wq7lFvqvm4dcfdGFVLB/mNaLFChRKw==", "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -12813,35 +13205,37 @@
"url": "https://github.com/sponsors/stylelint" "url": "https://github.com/sponsors/stylelint"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.3", "@csstools/css-syntax-patches-for-csstree": "^1.0.19",
"@csstools/media-query-list-parser": "^4.0.2", "@csstools/css-tokenizer": "^3.0.4",
"@csstools/media-query-list-parser": "^4.0.3",
"@csstools/selector-specificity": "^5.0.0", "@csstools/selector-specificity": "^5.0.0",
"@dual-bundle/import-meta-resolve": "^4.1.0", "@dual-bundle/import-meta-resolve": "^4.2.1",
"balanced-match": "^2.0.0", "balanced-match": "^2.0.0",
"colord": "^2.9.3", "colord": "^2.9.3",
"cosmiconfig": "^9.0.0", "cosmiconfig": "^9.0.0",
"css-functions-list": "^3.2.3", "css-functions-list": "^3.2.3",
"css-tree": "^3.1.0", "css-tree": "^3.1.0",
"debug": "^4.3.7", "debug": "^4.4.3",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^10.0.8", "file-entry-cache": "^11.1.1",
"global-modules": "^2.0.0", "global-modules": "^2.0.0",
"globby": "^11.1.0", "globby": "^11.1.0",
"globjoin": "^0.1.4", "globjoin": "^0.1.4",
"html-tags": "^3.3.1", "html-tags": "^3.3.1",
"ignore": "^7.0.3", "ignore": "^7.0.5",
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"known-css-properties": "^0.36.0", "known-css-properties": "^0.37.0",
"mathml-tag-names": "^2.1.3", "mathml-tag-names": "^2.1.3",
"meow": "^13.2.0", "meow": "^13.2.0",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"postcss-resolve-nested-selector": "^0.1.6", "postcss-resolve-nested-selector": "^0.1.6",
"postcss-safe-parser": "^7.0.1", "postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.0", "postcss-selector-parser": "^7.1.0",
@@ -13062,23 +13456,25 @@
"dev": true "dev": true
}, },
"node_modules/stylelint/node_modules/file-entry-cache": { "node_modules/stylelint/node_modules/file-entry-cache": {
"version": "10.1.0", "version": "11.1.2",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-10.1.0.tgz", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.2.tgz",
"integrity": "sha512-Et/ex6smi3wOOB+n5mek+Grf7P2AxZR5ueqRUvAAn4qkyatXi3cUC1cuQXVkX0VlzBVsN4BkWJFmY/fYiRTdww==", "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"flat-cache": "^6.1.9" "flat-cache": "^6.1.20"
} }
}, },
"node_modules/stylelint/node_modules/flat-cache": { "node_modules/stylelint/node_modules/flat-cache": {
"version": "6.1.9", "version": "6.1.20",
"resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.9.tgz", "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.20.tgz",
"integrity": "sha512-DUqiKkTlAfhtl7g78IuwqYM+YqvT+as0mY+EVk6mfimy19U79pJCzDZQsnqk3Ou/T6hFXWLGbwbADzD/c8Tydg==", "integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"cacheable": "^1.9.0", "cacheable": "^2.3.2",
"flatted": "^3.3.3", "flatted": "^3.3.3",
"hookified": "^1.8.2" "hookified": "^1.15.0"
} }
}, },
"node_modules/stylelint/node_modules/global-modules": { "node_modules/stylelint/node_modules/global-modules": {
@@ -13113,6 +13509,13 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"dev": true "dev": true
}, },
"node_modules/stylelint/node_modules/known-css-properties": {
"version": "0.37.0",
"resolved": "https://registry.npmmirror.com/known-css-properties/-/known-css-properties-0.37.0.tgz",
"integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stylelint/node_modules/meow": { "node_modules/stylelint/node_modules/meow": {
"version": "13.2.0", "version": "13.2.0",
"resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", "resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz",
@@ -14081,6 +14484,16 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url": {
"version": "0.10.3",
"resolved": "https://registry.npmmirror.com/url/-/url-0.10.3.tgz",
"integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==",
"license": "MIT",
"dependencies": {
"punycode": "1.3.2",
"querystring": "0.2.0"
}
},
"node_modules/url-parse-lax": { "node_modules/url-parse-lax": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
@@ -14102,6 +14515,25 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/url/node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
"license": "MIT"
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmmirror.com/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -14706,6 +15138,27 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
"integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wildcard": { "node_modules/wildcard": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
@@ -14870,6 +15323,28 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xss": { "node_modules/xss": {
"version": "1.0.15", "version": "1.0.15",
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", "resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",

View File

@@ -53,6 +53,7 @@
"@vueuse/core": "^11.0.0", "@vueuse/core": "^11.0.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "next", "@wangeditor/editor-for-vue": "next",
"aws-sdk": "^2.1693.0",
"axios": "^1.7.5", "axios": "^1.7.5",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"echarts": "^5.4.0", "echarts": "^5.4.0",

View File

@@ -170,16 +170,16 @@ export class BaseService {
if (params) { if (params) {
Object.keys(params).forEach((key) => { Object.keys(params).forEach((key) => {
formData.append(key, params[key]) if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
formData.append(key, String(params[key]))
}
}) })
} }
// 不要手动设置 Content-Type,让浏览器自动设置(包含正确的 boundary)
return request.post<BaseResponse<T>>({ return request.post<BaseResponse<T>>({
url, url,
data: formData, data: formData
headers: {
'Content-Type': 'multipart/form-data'
}
}) })
} }

View File

@@ -0,0 +1,46 @@
/**
* 授权记录相关 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse, PaginationResponse } from '@/types/api'
import type {
AuthorizationItem,
AuthorizationListParams,
UpdateAuthorizationRemarkRequest
} from '@/types/api/authorization'
export class AuthorizationService extends BaseService {
/**
* 获取授权记录列表
* @param params 查询参数
*/
static getAuthorizations(
params?: AuthorizationListParams
): Promise<PaginationResponse<AuthorizationItem>> {
return this.getPage<AuthorizationItem>('/api/admin/authorizations', params)
}
/**
* 获取授权记录详情
* @param id 授权记录ID
*/
static getAuthorizationDetail(id: number): Promise<BaseResponse<AuthorizationItem>> {
return this.getOne<AuthorizationItem>(`/api/admin/authorizations/${id}`)
}
/**
* 修改授权备注
* @param id 授权记录ID
* @param data 备注数据
*/
static updateAuthorizationRemark(
id: number,
data: UpdateAuthorizationRemarkRequest
): Promise<BaseResponse<AuthorizationItem>> {
return this.put<BaseResponse<AuthorizationItem>>(
`/api/admin/authorizations/${id}/remark`,
data
)
}
}

View File

@@ -273,23 +273,18 @@ export class CardService extends BaseService {
// ========== ICCID批量导入相关 ========== // ========== ICCID批量导入相关 ==========
/** /**
* 批量导入ICCID * 批量导入ICCID(新版:使用 JSON 格式)
* @param file Excel文件 * @param data 导入请求参数
* @param carrier_id 运营商ID
* @param batch_no 批次号(可选)
*/ */
static importIotCards( static importIotCards(data: {
file: File, carrier_id: number
carrier_id: number, file_key: string
batch_no?: string batch_no?: string
): Promise<BaseResponse> { }): Promise<BaseResponse<{ task_id: number; task_no: string; message: string }>> {
const formData = new FormData() return this.post<BaseResponse<{ task_id: number; task_no: string; message: string }>>(
formData.append('file', file) '/api/admin/iot-cards/import',
formData.append('carrier_id', carrier_id.toString()) data
if (batch_no) { )
formData.append('batch_no', batch_no)
}
return this.upload('/api/admin/iot-cards/import', file, { carrier_id, batch_no })
} }
/** /**

149
src/api/modules/device.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* 设备管理相关 API
*/
import { BaseService } from '../BaseService'
import type {
Device,
DeviceQueryParams,
DeviceListResponse,
DeviceCardsResponse,
BindCardToDeviceRequest,
BindCardToDeviceResponse,
UnbindCardFromDeviceResponse,
AllocateDevicesRequest,
AllocateDevicesResponse,
RecallDevicesRequest,
RecallDevicesResponse,
ImportDeviceRequest,
ImportDeviceResponse,
DeviceImportTaskQueryParams,
DeviceImportTaskListResponse,
DeviceImportTaskDetail,
BaseResponse
} from '@/types/api'
export class DeviceService extends BaseService {
// ========== 设备基础管理 ==========
/**
* 获取设备列表
* @param params 查询参数
*/
static getDevices(params?: DeviceQueryParams): Promise<BaseResponse<DeviceListResponse>> {
return this.get<BaseResponse<DeviceListResponse>>('/api/admin/devices', params)
}
/**
* 获取设备详情
* @param id 设备ID
*/
static getDeviceById(id: number): Promise<BaseResponse<Device>> {
return this.getOne<Device>(`/api/admin/devices/${id}`)
}
/**
* 删除设备
* @param id 设备ID
*/
static deleteDevice(id: number): Promise<BaseResponse> {
return this.remove(`/api/admin/devices/${id}`)
}
// ========== 设备卡绑定管理 ==========
/**
* 获取设备绑定的卡列表
* @param id 设备ID
*/
static getDeviceCards(id: number): Promise<BaseResponse<DeviceCardsResponse>> {
return this.getOne<DeviceCardsResponse>(`/api/admin/devices/${id}/cards`)
}
/**
* 绑定卡到设备
* @param id 设备ID
* @param data 绑定参数
*/
static bindCard(
id: number,
data: BindCardToDeviceRequest
): Promise<BaseResponse<BindCardToDeviceResponse>> {
return this.post<BaseResponse<BindCardToDeviceResponse>>(
`/api/admin/devices/${id}/cards`,
data
)
}
/**
* 解绑设备上的卡
* @param deviceId 设备ID
* @param cardId IoT卡ID
*/
static unbindCard(
deviceId: number,
cardId: number
): Promise<BaseResponse<UnbindCardFromDeviceResponse>> {
return this.delete<BaseResponse<UnbindCardFromDeviceResponse>>(
`/api/admin/devices/${deviceId}/cards/${cardId}`
)
}
// ========== 批量分配和回收 ==========
/**
* 批量分配设备
* @param data 分配参数
*/
static allocateDevices(
data: AllocateDevicesRequest
): Promise<BaseResponse<AllocateDevicesResponse>> {
return this.post<BaseResponse<AllocateDevicesResponse>>(
'/api/admin/devices/allocate',
data
)
}
/**
* 批量回收设备
* @param data 回收参数
*/
static recallDevices(
data: RecallDevicesRequest
): Promise<BaseResponse<RecallDevicesResponse>> {
return this.post<BaseResponse<RecallDevicesResponse>>('/api/admin/devices/recall', data)
}
// ========== 设备导入 ==========
/**
* 批量导入设备
* @param data 导入参数
*/
static importDevices(
data: ImportDeviceRequest
): Promise<BaseResponse<ImportDeviceResponse>> {
return this.post<BaseResponse<ImportDeviceResponse>>('/api/admin/devices/import', data)
}
/**
* 获取导入任务列表
* @param params 查询参数
*/
static getImportTasks(
params?: DeviceImportTaskQueryParams
): Promise<BaseResponse<DeviceImportTaskListResponse>> {
return this.get<BaseResponse<DeviceImportTaskListResponse>>(
'/api/admin/devices/import/tasks',
params
)
}
/**
* 获取导入任务详情
* @param id 任务ID
*/
static getImportTaskDetail(id: number): Promise<BaseResponse<DeviceImportTaskDetail>> {
return this.getOne<DeviceImportTaskDetail>(`/api/admin/devices/import/tasks/${id}`)
}
}

View File

@@ -14,6 +14,16 @@ import type {
UpdateEnterpriseStatusParams, UpdateEnterpriseStatusParams,
CreateEnterpriseResponse CreateEnterpriseResponse
} from '@/types/api/enterprise' } from '@/types/api/enterprise'
import type {
AllocateCardsRequest,
AllocateCardsResponse,
AllocateCardsPreviewRequest,
AllocateCardsPreviewResponse,
EnterpriseCardListParams,
EnterpriseCardPageResult,
RecallCardsRequest,
RecallCardsResponse
} from '@/types/api/enterpriseCard'
export class EnterpriseService extends BaseService { export class EnterpriseService extends BaseService {
/** /**
@@ -63,4 +73,88 @@ export class EnterpriseService extends BaseService {
): Promise<BaseResponse> { ): Promise<BaseResponse> {
return this.put<BaseResponse>(`/api/admin/enterprises/${id}/status`, data) return this.put<BaseResponse>(`/api/admin/enterprises/${id}/status`, data)
} }
// ========== 企业卡授权相关 ==========
/**
* 授权卡给企业
* @param enterpriseId 企业ID
* @param data 授权请求数据
*/
static allocateCards(
enterpriseId: number,
data: AllocateCardsRequest
): Promise<BaseResponse<AllocateCardsResponse>> {
return this.post<BaseResponse<AllocateCardsResponse>>(
`/api/admin/enterprises/${enterpriseId}/allocate-cards`,
data
)
}
/**
* 卡授权预检
* @param enterpriseId 企业ID
* @param data 预检请求数据
*/
static previewAllocateCards(
enterpriseId: number,
data: AllocateCardsPreviewRequest
): Promise<BaseResponse<AllocateCardsPreviewResponse>> {
return this.post<BaseResponse<AllocateCardsPreviewResponse>>(
`/api/admin/enterprises/${enterpriseId}/allocate-cards/preview`,
data
)
}
/**
* 获取企业卡列表
* @param enterpriseId 企业ID
* @param params 查询参数
*/
static getEnterpriseCards(
enterpriseId: number,
params?: EnterpriseCardListParams
): Promise<BaseResponse<EnterpriseCardPageResult>> {
return this.get<BaseResponse<EnterpriseCardPageResult>>(
`/api/admin/enterprises/${enterpriseId}/cards`,
params
)
}
/**
* 复机卡
* @param enterpriseId 企业ID
* @param cardId 卡ID
*/
static resumeCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/resume`
)
}
/**
* 停机卡
* @param enterpriseId 企业ID
* @param cardId 卡ID
*/
static suspendCard(enterpriseId: number, cardId: number): Promise<BaseResponse> {
return this.post<BaseResponse>(
`/api/admin/enterprises/${enterpriseId}/cards/${cardId}/suspend`
)
}
/**
* 回收卡授权
* @param enterpriseId 企业ID
* @param data 回收请求数据
*/
static recallCards(
enterpriseId: number,
data: RecallCardsRequest
): Promise<BaseResponse<RecallCardsResponse>> {
return this.post<BaseResponse<RecallCardsResponse>>(
`/api/admin/enterprises/${enterpriseId}/recall-cards`,
data
)
}
} }

View File

@@ -17,8 +17,10 @@ export { CardService } from './card'
export { CommissionService } from './commission' export { CommissionService } from './commission'
export { EnterpriseService } from './enterprise' export { EnterpriseService } from './enterprise'
export { CustomerAccountService } from './customerAccount' export { CustomerAccountService } from './customerAccount'
export { StorageService } from './storage'
export { AuthorizationService } from './authorization'
export { DeviceService } from './device'
// TODO: 按需添加其他业务模块 // TODO: 按需添加其他业务模块
// export { PackageService } from './package' // export { PackageService } from './package'
// export { DeviceService } from './device'
// export { SettingService } from './setting' // export { SettingService } from './setting'

104
src/api/modules/storage.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* 对象存储相关 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
/**
* 文件用途枚举
*/
export type FilePurpose = 'iot_import' | 'export' | 'attachment'
/**
* 获取上传 URL 请求参数
*/
export interface GetUploadUrlRequest {
/** 文件名cards.csv */
file_name: string
/** 文件 MIME 类型text/csv留空则自动推断 */
content_type?: string
/** 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件) */
purpose: FilePurpose
}
/**
* 获取上传 URL 响应
*/
export interface GetUploadUrlResponse {
/** 预签名上传 URL使用 PUT 方法上传文件 */
upload_url: string
/** 文件路径标识,上传成功后用于调用业务接口 */
file_key: string
/** URL 有效期(秒) */
expires_in: number
}
export class StorageService extends BaseService {
/**
* 获取文件上传预签名 URL
*
* ## 完整上传流程
* 1. 调用本接口获取预签名 URL 和 file_key
* 2. 使用预签名 URL 上传文件(发起 PUT 请求直接上传到对象存储)
* 3. 使用 file_key 调用相关业务接口
*
* @param data 请求参数
*/
static getUploadUrl(data: GetUploadUrlRequest): Promise<BaseResponse<GetUploadUrlResponse>> {
return this.post<BaseResponse<GetUploadUrlResponse>>('/api/admin/storage/upload-url', data)
}
/**
* 使用预签名 URL 上传文件到对象存储
*
* 注意事项:
* - 预签名 URL 有效期 15 分钟,请及时使用
* - 上传时 Content-Type 需与请求时一致
* - file_key 在上传成功后永久有效
*
* @param uploadUrl 预签名 URL
* @param file 文件
* @param contentType 文件类型(需与 getUploadUrl 请求时保持一致)
*/
static async uploadFile(uploadUrl: string, file: File, contentType?: string): Promise<void> {
try {
// 在开发环境下,使用代理路径避免 CORS 问题
let finalUrl = uploadUrl
if (import.meta.env.DEV) {
// 将对象存储的域名替换为代理路径
// 例如http://obs-helf.cucloud.cn/cmp/... -> /obs-proxy/cmp/...
finalUrl = uploadUrl.replace(/^https?:\/\/obs-helf\.cucloud\.cn/, '/obs-proxy')
}
const headers: Record<string, string> = {}
// 只有在明确指定 contentType 时才设置,否则让浏览器自动处理
if (contentType) {
headers['Content-Type'] = contentType
}
const response = await fetch(finalUrl, {
method: 'PUT',
body: file,
headers,
mode: 'cors' // 明确指定 CORS 模式
})
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText)
throw new Error(`上传文件失败 (${response.status}): ${errorText}`)
}
} catch (error: any) {
// 增强错误信息
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
throw new Error(
'CORS 错误: 无法上传文件到对象存储。' +
'这通常是因为对象存储服务器未正确配置 CORS 策略。' +
'请联系后端开发人员检查对象存储的 CORS 配置。'
)
}
throw error
}
}
}

View File

@@ -417,6 +417,7 @@
"agent": "代理商管理", "agent": "代理商管理",
"customerAccount": "客户账号", "customerAccount": "客户账号",
"enterpriseCustomer": "企业客户", "enterpriseCustomer": "企业客户",
"enterpriseCards": "企业卡管理",
"customerCommission": "客户账号佣金" "customerCommission": "客户账号佣金"
}, },
"deviceManagement": { "deviceManagement": {
@@ -439,8 +440,11 @@
"taskManagement": "任务管理", "taskManagement": "任务管理",
"taskDetail": "任务详情", "taskDetail": "任务详情",
"devices": "设备管理", "devices": "设备管理",
"deviceDetail": "设备详情",
"assetAssign": "分配记录", "assetAssign": "分配记录",
"cardReplacementRequest": "换卡申请" "cardReplacementRequest": "换卡申请",
"authorizationRecords": "授权记录",
"authorizationDetail": "授权记录详情"
}, },
"account": { "account": {
"title": "账户管理", "title": "账户管理",

View File

@@ -756,6 +756,16 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.accountManagement.customerAccount', title: 'menus.accountManagement.customerAccount',
keepAlive: true keepAlive: true
} }
},
{
path: 'enterprise-cards',
name: 'EnterpriseCards',
component: RoutesAlias.EnterpriseCards,
meta: {
title: 'menus.accountManagement.enterpriseCards',
isHide: true,
keepAlive: false
}
} }
] ]
}, },
@@ -879,6 +889,16 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
{
path: 'device-detail',
name: 'DeviceDetail',
component: RoutesAlias.DeviceDetail,
meta: {
title: 'menus.assetManagement.deviceDetail',
isHide: true,
keepAlive: false
}
},
{ {
path: 'asset-assign', path: 'asset-assign',
name: 'AssetAssign', name: 'AssetAssign',
@@ -906,6 +926,25 @@ export const asyncRoutes: AppRouteRecord[] = [
title: 'menus.assetManagement.cardReplacementRequest', title: 'menus.assetManagement.cardReplacementRequest',
keepAlive: true keepAlive: true
} }
},
{
path: 'authorization-records',
name: 'AuthorizationRecords',
component: RoutesAlias.AuthorizationRecords,
meta: {
title: 'menus.assetManagement.authorizationRecords',
keepAlive: true
}
},
{
path: 'authorization-detail',
name: 'AuthorizationDetail',
component: RoutesAlias.AuthorizationDetail,
meta: {
title: 'menus.assetManagement.authorizationDetail',
isHide: true,
keepAlive: false
}
} }
] ]
}, },
@@ -997,7 +1036,7 @@ export const asyncRoutes: AppRouteRecord[] = [
] ]
} }
] ]
} },
// { // {
// path: '/settings', // path: '/settings',
// name: 'Settings', // name: 'Settings',
@@ -1036,51 +1075,51 @@ export const asyncRoutes: AppRouteRecord[] = [
// } // }
// ] // ]
// }, // },
// { {
// path: '/batch', path: '/batch',
// name: 'Batch', name: 'Batch',
// component: RoutesAlias.Home, component: RoutesAlias.Home,
// meta: { meta: {
// title: 'menus.batch.title', title: 'menus.batch.title',
// icon: '&#xe820;' icon: '&#xe820;'
// }, },
// children: [ children: [
// { {
// path: 'sim-import', path: 'sim-import',
// name: 'SimImport', name: 'SimImport',
// component: RoutesAlias.SimImport, component: RoutesAlias.SimImport,
// meta: { meta: {
// title: 'menus.batch.simImport', title: 'menus.batch.simImport',
// keepAlive: true keepAlive: true
// } }
// }, },
// { {
// path: 'device-import', path: 'device-import',
// name: 'DeviceImport', name: 'DeviceImport',
// component: RoutesAlias.DeviceImport, component: RoutesAlias.DeviceImport,
// meta: { meta: {
// title: 'menus.batch.deviceImport', title: 'menus.batch.deviceImport',
// keepAlive: true keepAlive: true
// } }
// }, },
// { {
// path: 'offline-batch-recharge', path: 'offline-batch-recharge',
// name: 'OfflineBatchRecharge', name: 'OfflineBatchRecharge',
// component: RoutesAlias.OfflineBatchRecharge, component: RoutesAlias.OfflineBatchRecharge,
// meta: { meta: {
// title: 'menus.batch.offlineBatchRecharge', title: 'menus.batch.offlineBatchRecharge',
// keepAlive: true keepAlive: true
// } }
// }, },
// { {
// path: 'card-change-notice', path: 'card-change-notice',
// name: 'CardChangeNotice', name: 'CardChangeNotice',
// component: RoutesAlias.CardChangeNotice, component: RoutesAlias.CardChangeNotice,
// meta: { meta: {
// title: 'menus.batch.cardChangeNotice', title: 'menus.batch.cardChangeNotice',
// keepAlive: true keepAlive: true
// } }
// } }
// ] ]
// } }
] ]

View File

@@ -82,11 +82,9 @@ export enum RoutesAlias {
CustomerAccount = '/account-management/customer-account', // 客户账号管理 CustomerAccount = '/account-management/customer-account', // 客户账号管理
ShopAccount = '/account-management/shop-account', // 代理账号管理 ShopAccount = '/account-management/shop-account', // 代理账号管理
EnterpriseCustomer = '/account-management/enterprise-customer', // 企业客户管理 EnterpriseCustomer = '/account-management/enterprise-customer', // 企业客户管理
EnterpriseCards = '/account-management/enterprise-cards', // 企业卡管理
CustomerCommission = '/account-management/customer-commission', // 客户账号佣金 CustomerCommission = '/account-management/customer-commission', // 客户账号佣金
// 设备管理
DeviceList = '/device-management/devices', // 设备管理
// 产品管理 // 产品管理
SimCardManagement = '/product/sim-card', // 网卡产品管理 SimCardManagement = '/product/sim-card', // 网卡产品管理
SimCardAssign = '/product/sim-card-assign', // 号卡分配 SimCardAssign = '/product/sim-card-assign', // 号卡分配
@@ -95,9 +93,13 @@ export enum RoutesAlias {
StandaloneCardList = '/asset-management/card-list', // 单卡列表(未绑定设备) StandaloneCardList = '/asset-management/card-list', // 单卡列表(未绑定设备)
TaskManagement = '/asset-management/task-management', // 任务管理 TaskManagement = '/asset-management/task-management', // 任务管理
TaskDetail = '/asset-management/task-detail', // 任务详情 TaskDetail = '/asset-management/task-detail', // 任务详情
DeviceList = '/asset-management/device-list', // 设备列表
DeviceDetail = '/asset-management/device-detail', // 设备详情
AssetAssign = '/asset-management/asset-assign', // 资产分配(分配记录) AssetAssign = '/asset-management/asset-assign', // 资产分配(分配记录)
AllocationRecordDetail = '/asset-management/allocation-record-detail', // 分配记录详情 AllocationRecordDetail = '/asset-management/allocation-record-detail', // 分配记录详情
CardReplacementRequest = '/asset-management/card-replacement-request', // 换卡申请 CardReplacementRequest = '/asset-management/card-replacement-request', // 换卡申请
AuthorizationRecords = '/asset-management/authorization-records', // 授权记录
AuthorizationDetail = '/asset-management/authorization-detail', // 授权记录详情
// 账户管理 // 账户管理
CustomerAccountList = '/finance/customer-account', // 客户账号 CustomerAccountList = '/finance/customer-account', // 客户账号

View File

@@ -0,0 +1,99 @@
/**
* 授权记录相关类型定义
*/
/**
* 授权人类型
*/
export enum AuthorizerType {
PLATFORM = 2, // 平台
AGENT = 3 // 代理
}
/**
* 授权状态
*/
export enum AuthorizationStatus {
REVOKED = 0, // 已回收
ACTIVE = 1 // 有效
}
/**
* 授权记录项
*/
export interface AuthorizationItem {
/** 授权记录ID */
id: number
/** 卡ID */
card_id: number
/** ICCID */
iccid: string
/** 手机号 */
msisdn: string
/** 企业ID */
enterprise_id: number
/** 企业名称 */
enterprise_name: string
/** 授权人ID */
authorized_by: number
/** 授权人名称 */
authorizer_name: string
/** 授权人类型2=平台3=代理 */
authorizer_type: AuthorizerType
/** 授权时间 */
authorized_at: string
/** 回收人ID */
revoked_by?: number | null
/** 回收人名称 */
revoker_name?: string
/** 回收时间 */
revoked_at?: string | null
/** 状态1=有效0=已回收 */
status: AuthorizationStatus
/** 备注 */
remark?: string
}
/**
* 授权记录列表查询参数
*/
export interface AuthorizationListParams {
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
/** 按企业ID筛选 */
enterprise_id?: number
/** 按ICCID模糊查询 */
iccid?: string
/** 授权人类型2=平台3=代理 */
authorizer_type?: AuthorizerType
/** 状态0=已回收1=有效 */
status?: AuthorizationStatus
/** 授权时间起格式2006-01-02 */
start_time?: string
/** 授权时间止格式2006-01-02 */
end_time?: string
}
/**
* 授权记录列表响应
*/
export interface AuthorizationListResponse {
/** 授权记录列表 */
items: AuthorizationItem[]
/** 当前页码 */
page: number
/** 每页数量 */
size: number
/** 总记录数 */
total: number
}
/**
* 修改授权备注请求
*/
export interface UpdateAuthorizationRemarkRequest {
/** 备注最多500字 */
remark: string
}

View File

@@ -1,143 +1,204 @@
/** /**
* 设备相关类型定义 * 设备管理相关类型定义
*/ */
import { PaginationParams, ImportTask } from './common' import { PaginationParams } from './common'
// 设备状态 // ========== 设备状态枚举 ==========
// 设备状态枚举
export enum DeviceStatus { export enum DeviceStatus {
ONLINE = 'online', // 在线 IN_STOCK = 1, // 在
OFFLINE = 'offline', // 离线 DISTRIBUTED = 2, // 已分销
FAULT = 'fault', // 故障 ACTIVATED = 3, // 已激活
MAINTENANCE = 'maintenance' // 维护中 DEACTIVATED = 4 // 已停用
} }
// 设备操作类型 // ========== 设备基础类型 ==========
export enum DeviceOperationType {
RESTART = 'restart', // 重启
RESET = 'reset', // 重置
UPGRADE = 'upgrade', // 升级
CONFIG = 'config', // 配置
BIND_CARD = 'bindCard', // 绑定网卡
UNBIND_CARD = 'unbindCard' // 解绑网卡
}
// 设备实体 // 设备信息
export interface Device { export interface Device {
id: string | number id: number // 设备ID
deviceCode: string // 设备编码 device_no: string // 设备
deviceName?: string // 设备名称 device_name: string // 设备名称
deviceType?: string // 设备 device_model: string // 设备型
model?: string // 设备型 device_type: string // 设备
manufacturer?: string // 制造商 manufacturer: string // 制造商
// 网卡信息 max_sim_slots: number // 最大插槽数
currentIccid?: string // 当前绑定的ICCID bound_card_count: number // 已绑定卡数量
currentOperator?: string // 当前运营商 status: DeviceStatus // 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)
currentImei?: string // IMEI status_name: string // 状态名称
// 卡槽信息(双卡设备) shop_id: number | null // 店铺ID
card1Iccid?: string shop_name: string // 店铺名称
card1Operator?: string batch_no: string // 批次号
card2Iccid?: string activated_at: string | null // 激活时间
card2Operator?: string created_at: string // 创建时间
// 状态信息 updated_at: string // 更新时间
status: DeviceStatus
onlineStatus: boolean // 在线状态
lastOnlineTime?: string // 最后在线时间
// 所属信息
agentId?: string | number
agentName?: string
// 位置信息
location?: string
latitude?: number
longitude?: number
// 其他信息
firmwareVersion?: string // 固件版本
hardwareVersion?: string // 硬件版本
activateTime?: string // 激活时间
createTime: string
updateTime?: string
} }
// 设备查询参数 // 设备查询参数
export interface DeviceQueryParams extends PaginationParams { export interface DeviceQueryParams extends PaginationParams {
keyword?: string // 设备编码/名称/ICCID device_no?: string // 设备号(模糊查询)
deviceType?: string device_name?: string // 设备名称(模糊查询)
status?: DeviceStatus status?: DeviceStatus // 状态
onlineStatus?: boolean shop_id?: number | null // 店铺ID (NULL表示平台库存)
agentId?: string | number batch_no?: string // 批次号
hasCard?: boolean // 是否绑定网卡 device_type?: string // 设备类型
operator?: string manufacturer?: string // 制造商(模糊查询)
created_at_start?: string // 创建时间起始
created_at_end?: string // 创建时间结束
} }
// 设备操作参数 // 设备列表响应
export interface DeviceOperationParams { export interface DeviceListResponse {
deviceId: string | number list: Device[] | null // 设备列表
operation: DeviceOperationType page: number // 当前页码
iccid?: string // 绑定/解绑网卡时需要 page_size: number // 每页数量
config?: Record<string, any> // 配置参 total: number //
remark?: string total_pages: number // 总页数
} }
// 设备卡信息 // ========== 设备卡绑定相关 ==========
export interface DeviceCardInfo {
deviceId: string | number // 设备卡绑定信息
deviceCode: string export interface DeviceCardBinding {
// 主卡信息 id: number // 绑定记录ID
mainCard?: { iot_card_id: number // IoT卡ID
iccid: string iccid: string // ICCID
operator: string msisdn: string // 接入号
imei?: string carrier_name: string // 运营商名称
status: string slot_position: number // 插槽位置 (1-4)
signal?: number // 信号强度 status: number // 卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)
flow?: { bind_time: string | null // 绑定时间
total: number
used: number
remain: number
}
}
// 副卡信息(双卡设备)
viceCard?: {
iccid: string
operator: string
imei?: string
status: string
signal?: number
flow?: {
total: number
used: number
remain: number
}
}
} }
// 修改设备卡信息参数 // 设备绑定的卡列表响应
export interface UpdateDeviceCardParams { export interface DeviceCardsResponse {
deviceId: string | number bindings: DeviceCardBinding[] | null // 绑定列表
mainCardIccid?: string
viceCardIccid?: string
} }
// 设备批量分配参数 // 绑定卡到设备请求参数
export interface DeviceBatchAssignParams { export interface BindCardToDeviceRequest {
deviceIds: (string | number)[] iot_card_id: number // IoT卡ID
targetAgentId: string | number slot_position: number // 插槽位置 (1-4)
includeCards: boolean // 是否连同网卡一起分配
} }
// 设备导入任务 // 绑定卡到设备响应
export interface DeviceImportTask extends ImportTask { export interface BindCardToDeviceResponse {
agentId?: string | number binding_id: number // 绑定记录ID
agentName?: string message: string // 提示信息
operatorName?: string
} }
// 设备导入数据项 // 解绑设备上的卡响应
export interface DeviceImportItem { export interface UnbindCardFromDeviceResponse {
deviceCode: string message: string // 提示信息
deviceName?: string }
deviceType?: string
iccid?: string // 绑定的ICCID // ========== 批量分配和回收相关 ==========
card1Iccid?: string
card2Iccid?: string // 批量分配设备请求参数
location?: string export interface AllocateDevicesRequest {
device_ids: number[] // 设备ID列表
target_shop_id: number // 目标店铺ID
remark?: string // 备注
}
// 分配失败项
export interface AllocationDeviceFailedItem {
device_id: number // 设备ID
device_no: string // 设备号
reason: string // 失败原因
}
// 批量分配设备响应
export interface AllocateDevicesResponse {
success_count: number // 成功数量
fail_count: number // 失败数量
failed_items: AllocationDeviceFailedItem[] | null // 失败详情列表
}
// 批量回收设备请求参数
export interface RecallDevicesRequest {
device_ids: number[] // 设备ID列表
remark?: string // 备注
}
// 批量回收设备响应
export interface RecallDevicesResponse {
success_count: number // 成功数量
fail_count: number // 失败数量
failed_items: AllocationDeviceFailedItem[] | null // 失败详情列表
}
// ========== 设备导入相关 ==========
// 批量导入设备请求参数
export interface ImportDeviceRequest {
file_key: string // 对象存储文件路径(通过 /storage/upload-url 获取)
batch_no?: string // 批次号
}
// 批量导入设备响应
export interface ImportDeviceResponse {
message: string // 提示信息
task_id: number // 导入任务ID
task_no: string // 任务编号
}
// ========== 导入任务相关 ==========
// 导入任务状态枚举
export enum DeviceImportTaskStatus {
PENDING = 1, // 待处理
PROCESSING = 2, // 处理中
COMPLETED = 3, // 已完成
FAILED = 4 // 失败
}
// 导入任务查询参数
export interface DeviceImportTaskQueryParams extends PaginationParams {
status?: DeviceImportTaskStatus // 任务状态
batch_no?: string // 批次号(模糊查询)
start_time?: string // 创建时间起始
end_time?: string // 创建时间结束
}
// 导入任务信息
export interface DeviceImportTask {
id: number // 任务ID
task_no: string // 任务编号
batch_no: string // 批次号
file_name: string // 文件名
status: DeviceImportTaskStatus // 任务状态
status_text: string // 任务状态文本
total_count: number // 总数
success_count: number // 成功数
fail_count: number // 失败数
skip_count: number // 跳过数
error_message: string // 错误信息
created_at: string // 创建时间
started_at: string | null // 开始处理时间
completed_at: string | null // 完成时间
}
// 导入任务列表响应
export interface DeviceImportTaskListResponse {
list: DeviceImportTask[] | null // 任务列表
page: number // 当前页码
page_size: number // 每页数量
total: number // 总数
total_pages: number // 总页数
}
// 导入结果详细项
export interface DeviceImportResultItem {
line: number // 行号
device_no: string // 设备号
reason: string // 原因
}
// 导入任务详情
export interface DeviceImportTaskDetail extends DeviceImportTask {
failed_items: DeviceImportResultItem[] | null // 失败记录详情
skipped_items: DeviceImportResultItem[] | null // 跳过记录详情
} }

View File

@@ -0,0 +1,231 @@
/**
* 企业卡授权相关类型定义
*/
/**
* 失败项
*/
export interface FailedItem {
/** ICCID */
iccid: string
/** 失败原因 */
reason: string
}
/**
* 分配的设备
*/
export interface AllocatedDevice {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 卡数量 */
card_count: number
/** 卡ICCID列表 */
iccids: string[]
}
/**
* 授权卡给企业请求
*/
export interface AllocateCardsRequest {
/** 需要授权的 ICCID 列表 */
iccids: string[]
/** 确认整体授权设备下所有卡 */
confirm_device_bundles?: boolean
}
/**
* 授权卡给企业响应
*/
export interface AllocateCardsResponse {
/** 成功数量 */
success_count: number
/** 失败数量 */
fail_count: number
/** 失败详情 */
failed_items: FailedItem[] | null
/** 连带授权的设备列表 */
allocated_devices: AllocatedDevice[] | null
}
/**
* 卡授权预检请求
*/
export interface AllocateCardsPreviewRequest {
/** 需要授权的 ICCID 列表最多1000个 */
iccids: string[]
}
/**
* 独立卡信息
*/
export interface StandaloneCard {
/** 卡ID */
iot_card_id: number
/** ICCID */
iccid: string
/** 手机号 */
msisdn: string
/** 运营商ID */
carrier_id: number
/** 状态名称 */
status_name: string
}
/**
* 设备捆绑卡
*/
export interface DeviceBundleCard {
/** 卡ID */
iot_card_id: number
/** ICCID */
iccid: string
/** 手机号 */
msisdn: string
}
/**
* 设备包
*/
export interface DeviceBundle {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 触发卡(用户选择的卡) */
trigger_card: DeviceBundleCard
/** 连带卡(同设备的其他卡) */
bundle_cards: DeviceBundleCard[]
}
/**
* 授权预检汇总
*/
export interface AllocatePreviewSummary {
/** 总卡数量 */
total_card_count: number
/** 独立卡数量 */
standalone_card_count: number
/** 设备数量 */
device_count: number
/** 设备卡数量 */
device_card_count: number
/** 失败数量 */
failed_count: number
}
/**
* 卡授权预检响应
*/
export interface AllocateCardsPreviewResponse {
/** 汇总信息 */
summary: AllocatePreviewSummary
/** 可直接授权的卡(未绑定设备) */
standalone_cards: StandaloneCard[] | null
/** 需要整体授权的设备包 */
device_bundles: DeviceBundle[] | null
/** 失败的卡 */
failed_items: FailedItem[] | null
}
/**
* 企业卡项
*/
export interface EnterpriseCardItem {
/** 卡ID */
id: number
/** ICCID */
iccid: string
/** 手机号 */
msisdn: string
/** 运营商ID */
carrier_id: number
/** 运营商名称 */
carrier_name: string
/** 状态 */
status: number
/** 状态名称 */
status_name: string
/** 网络状态 */
network_status: number
/** 网络状态名称 */
network_status_name: string
/** 套餐ID */
package_id?: number | null
/** 套餐名称 */
package_name?: string
/** 设备ID */
device_id?: number | null
/** 设备号 */
device_no?: string
}
/**
* 企业卡列表查询参数
*/
export interface EnterpriseCardListParams {
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
/** 卡状态 */
status?: number
/** 运营商ID */
carrier_id?: number
/** ICCID模糊查询 */
iccid?: string
/** 设备号(模糊查询) */
device_no?: string
}
/**
* 企业卡列表响应
*/
export interface EnterpriseCardPageResult {
/** 卡列表 */
items: EnterpriseCardItem[]
/** 当前页码 */
page: number
/** 每页数量 */
size: number
/** 总记录数 */
total: number
}
/**
* 回收的设备
*/
export interface RecalledDevice {
/** 设备ID */
device_id: number
/** 设备号 */
device_no: string
/** 卡数量 */
card_count: number
/** 卡ICCID列表 */
iccids: string[]
}
/**
* 回收卡授权请求
*/
export interface RecallCardsRequest {
/** 需要回收授权的 ICCID 列表 */
iccids: string[]
}
/**
* 回收卡授权响应
*/
export interface RecallCardsResponse {
/** 成功数量 */
success_count: number
/** 失败数量 */
fail_count: number
/** 失败详情 */
failed_items: FailedItem[] | null
/** 连带回收的设备列表 */
recalled_devices: RecalledDevice[] | null
}

View File

@@ -46,3 +46,9 @@ export * from './customerAccount'
// 设置相关 // 设置相关
export * from './setting' export * from './setting'
// 授权记录相关
export * from './authorization'
// 企业卡授权相关
export * from './enterpriseCard'

View File

@@ -8,7 +8,16 @@ const axiosInstance = axios.create({
timeout: 15000, // 请求超时时间(毫秒) timeout: 15000, // 请求超时时间(毫秒)
baseURL: import.meta.env.MODE === 'development' ? '' : import.meta.env.VITE_API_URL, // API地址开发环境使用代理生产环境使用完整URL baseURL: import.meta.env.MODE === 'development' ? '' : import.meta.env.VITE_API_URL, // API地址开发环境使用代理生产环境使用完整URL
withCredentials: true, // 异步请求携带cookie withCredentials: true, // 异步请求携带cookie
transformRequest: [(data) => JSON.stringify(data)], // 请求数据转换为 JSON 字符串 transformRequest: [
(data, headers) => {
// 如果是 FormData,不进行转换
if (data instanceof FormData) {
return data
}
// 其他数据转换为 JSON 字符串
return JSON.stringify(data)
}
],
validateStatus: (status) => status >= 200 && status < 300, // 只接受 2xx 的状态码 validateStatus: (status) => status >= 200 && status < 300, // 只接受 2xx 的状态码
headers: { headers: {
get: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' }, get: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
@@ -36,10 +45,17 @@ axiosInstance.interceptors.request.use(
// 如果 token 存在,则设置请求头 // 如果 token 存在,则设置请求头
if (accessToken) { if (accessToken) {
request.headers.set({ // 如果是 FormData,不要覆盖 Content-Type (让浏览器自动设置 boundary)
'Content-Type': 'application/json', if (request.data instanceof FormData) {
Authorization: `Bearer ${accessToken}` request.headers.set('Authorization', `Bearer ${accessToken}`)
}) // 删除 Content-Type,让浏览器自动设置
delete request.headers['Content-Type']
} else {
request.headers.set({
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`
})
}
} }
return request // 返回修改后的配置 return request // 返回修改后的配置

View File

@@ -0,0 +1,795 @@
<template>
<ArtTableFullScreen>
<div class="enterprise-cards-page" id="table-full-screen">
<!-- 企业信息卡片 -->
<ElCard shadow="never" style="margin-bottom: 16px">
<template #header>
<div class="card-header">
<span>企业信息</span>
<ElButton @click="goBack">返回</ElButton>
</div>
</template>
<ElDescriptions :column="3" border v-if="enterpriseInfo">
<ElDescriptionsItem label="企业名称">{{ enterpriseInfo.enterprise_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业编号">{{ enterpriseInfo.enterprise_code }}</ElDescriptionsItem>
<ElDescriptionsItem label="联系人">{{ enterpriseInfo.contact_name }}</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showAllocateDialog">授权卡</ElButton>
<ElButton
type="warning"
:disabled="selectedCards.length === 0"
@click="showRecallDialog"
>
批量回收
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="cardList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 授权卡对话框 -->
<ElDialog
v-model="allocateDialogVisible"
title="授权卡给企业"
width="700px"
@close="handleAllocateDialogClose"
>
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElFormItem label="ICCID列表" prop="iccids">
<ElInput
v-model="iccidsText"
type="textarea"
:rows="6"
placeholder="请输入ICCID每行一个或用逗号分隔"
@input="handleIccidsChange"
/>
<div style="color: var(--el-color-info); margin-top: 4px; font-size: 12px">
已输入 {{ allocateForm.iccids?.length || 0 }} 个ICCID
</div>
</ElFormItem>
<ElFormItem label="确认设备绑定">
<ElCheckbox v-model="allocateForm.confirm_device_bundles">
我确认已了解设备绑定关系同意一起授权
</ElCheckbox>
</ElFormItem>
</ElForm>
<!-- 预检结果 -->
<div v-if="previewData" style="margin-top: 20px">
<ElDivider content-position="left">预检结果</ElDivider>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="待授权卡数">
{{ previewData.summary.total_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="可授权卡数">
<ElTag type="success">{{ previewData.summary.valid_cards }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="独立卡数">
{{ previewData.summary.standalone_cards }}
</ElDescriptionsItem>
<ElDescriptionsItem label="设备绑定数">
<ElTag type="warning">{{ previewData.summary.device_bundles }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数" :span="2">
<ElTag type="danger">{{ previewData.summary.failed_cards }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<!-- 失败项详情 -->
<div v-if="previewData.failed_items && previewData.failed_items.length > 0" style="margin-top: 16px">
<ElAlert title="以下ICCID无法授权" type="error" :closable="false" style="margin-bottom: 8px" />
<ElTable :data="previewData.failed_items" border max-height="200">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<!-- 设备绑定详情 -->
<div v-if="previewData.device_bundles && previewData.device_bundles.length > 0" style="margin-top: 16px">
<ElAlert
title="以下ICCID与设备绑定授权后设备也将一起授权给企业"
type="warning"
:closable="false"
style="margin-bottom: 8px"
/>
<ElTable :data="previewData.device_bundles" border max-height="200">
<ElTableColumn prop="device_imei" label="设备IMEI" width="180" />
<ElTableColumn label="绑定卡数">
<template #default="{ row }">
{{ row.iccids?.length || 0 }}
</template>
</ElTableColumn>
<ElTableColumn label="ICCID列表">
<template #default="{ row }">
{{ row.iccids?.join(', ') }}
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="allocateDialogVisible = false">取消</ElButton>
<ElButton @click="handlePreview" :loading="previewLoading">预检</ElButton>
<ElButton
type="primary"
@click="handleAllocate"
:loading="allocateLoading"
:disabled="!previewData || previewData.summary.valid_cards === 0"
>
确认授权
</ElButton>
</div>
</template>
</ElDialog>
<!-- 批量回收对话框 -->
<ElDialog
v-model="recallDialogVisible"
title="批量回收卡授权"
width="600px"
@close="handleRecallDialogClose"
>
<ElForm ref="recallFormRef" :model="recallForm" :rules="recallRules">
<ElFormItem label="回收卡数">
<div>已选择 {{ selectedCards.length }} 张卡</div>
</ElFormItem>
<ElFormItem label="回收原因">
<ElInput
v-model="recallForm.reason"
type="textarea"
:rows="3"
placeholder="请输入回收原因(可选)"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="recallDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleRecall" :loading="recallLoading">
确认回收
</ElButton>
</div>
</template>
</ElDialog>
<!-- 结果对话框 -->
<ElDialog v-model="resultDialogVisible" :title="resultTitle" width="700px">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="成功数">
<ElTag type="success">{{ operationResult.success_count }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">
<ElTag type="danger">{{ operationResult.fail_count }}</ElTag>
</ElDescriptionsItem>
</ElDescriptions>
<div
v-if="operationResult.failed_items && operationResult.failed_items.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">失败项详情</ElDivider>
<ElTable :data="operationResult.failed_items" border max-height="300">
<ElTableColumn prop="iccid" label="ICCID" width="180" />
<ElTableColumn prop="reason" label="失败原因" />
</ElTable>
</div>
<!-- 显示授权的设备 -->
<div
v-if="operationResult.allocated_devices && operationResult.allocated_devices.length > 0"
style="margin-top: 20px"
>
<ElDivider content-position="left">已授权设备</ElDivider>
<ElTable :data="operationResult.allocated_devices" border max-height="200">
<ElTableColumn prop="device_imei" label="设备IMEI" width="180" />
<ElTableColumn label="绑定卡数">
<template #default="{ row }">
{{ row.iccids?.length || 0 }}
</template>
</ElTableColumn>
</ElTable>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton type="primary" @click="resultDialogVisible = false">确定</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { EnterpriseService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import type {
EnterpriseCardItem,
AllocateCardsPreviewResponse,
AllocateCardsResponse,
RecallCardsResponse,
FailedItem
} from '@/types/api/enterpriseCard'
import type { EnterpriseItem } from '@/types/api'
defineOptions({ name: 'EnterpriseCards' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const allocateDialogVisible = ref(false)
const allocateLoading = ref(false)
const previewLoading = ref(false)
const recallDialogVisible = ref(false)
const recallLoading = ref(false)
const resultDialogVisible = ref(false)
const resultTitle = ref('')
const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const selectedCards = ref<EnterpriseCardItem[]>([])
const enterpriseId = ref<number>(0)
const enterpriseInfo = ref<EnterpriseItem | null>(null)
const iccidsText = ref('')
const previewData = ref<AllocateCardsPreviewResponse | null>(null)
const operationResult = ref<AllocateCardsResponse | RecallCardsResponse>({
success_count: 0,
fail_count: 0,
failed_items: null,
allocated_devices: null
})
// 搜索表单初始值
const initialSearchState = {
iccid: '',
msisdn: '',
status: undefined as number | undefined,
authorization_status: undefined as number | undefined
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 授权表单
const allocateForm = reactive({
iccids: [] as string[],
confirm_device_bundles: false
})
// 授权表单验证规则
const allocateRules = reactive<FormRules>({
iccids: [
{
required: true,
validator: (rule, value, callback) => {
if (!value || value.length === 0) {
callback(new Error('请输入至少一个ICCID'))
} else {
callback()
}
},
trigger: 'change'
}
]
})
// 回收表单
const recallForm = reactive({
reason: ''
})
// 回收表单验证规则
const recallRules = reactive<FormRules>({})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: 'ICCID',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID'
}
},
{
label: '手机号',
prop: 'msisdn',
type: 'input',
config: {
clearable: true,
placeholder: '请输入手机号'
}
},
{
label: '卡状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '激活', value: 1 },
{ label: '停机', value: 2 }
]
},
{
label: '授权状态',
prop: 'authorization_status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '有效', value: 1 },
{ label: '已回收', value: 0 }
]
}
]
// 列配置
const columnOptions = [
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn' },
{ label: '运营商', prop: 'carrier_name' },
{ label: '卡状态', prop: 'status' },
{ label: '授权状态', prop: 'authorization_status' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '授权人', prop: 'authorizer_name' },
{ label: '操作', prop: 'operation' }
]
const cardList = ref<EnterpriseCardItem[]>([])
// 获取卡状态标签类型
const getCardStatusTag = (status: number) => {
return status === 1 ? 'success' : 'danger'
}
// 获取卡状态文本
const getCardStatusText = (status: number) => {
return status === 1 ? '激活' : '停机'
}
// 获取授权状态标签类型
const getAuthStatusTag = (status: number) => {
return status === 1 ? 'success' : 'info'
}
// 获取授权状态文本
const getAuthStatusText = (status: number) => {
return status === 1 ? '有效' : '已回收'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
},
{
prop: 'msisdn',
label: '手机号',
width: 120
},
{
prop: 'carrier_name',
label: '运营商',
width: 100
},
{
prop: 'status',
label: '卡状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(ElTag, { type: getCardStatusTag(row.status) }, () => getCardStatusText(row.status))
}
},
{
prop: 'authorization_status',
label: '授权状态',
width: 100,
formatter: (row: EnterpriseCardItem) => {
return h(
ElTag,
{ type: getAuthStatusTag(row.authorization_status) },
() => getAuthStatusText(row.authorization_status)
)
}
},
{
prop: 'authorized_at',
label: '授权时间',
width: 180,
formatter: (row: EnterpriseCardItem) =>
row.authorized_at ? formatDateTime(row.authorized_at) : '-'
},
{
prop: 'authorizer_name',
label: '授权人',
width: 120
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: EnterpriseCardItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
row.status === 2
? h(ArtButtonTable, {
text: '复机',
onClick: () => handleResume(row)
})
: h(ArtButtonTable, {
text: '停机',
onClick: () => handleSuspend(row)
})
])
}
}
])
onMounted(() => {
const id = route.query.id
if (id) {
enterpriseId.value = Number(id)
getEnterpriseInfo()
getTableData()
} else {
ElMessage.error('缺少企业ID')
goBack()
}
})
// 获取企业信息
const getEnterpriseInfo = async () => {
try {
const res = await EnterpriseService.getEnterprises({
page: 1,
page_size: 1,
id: enterpriseId.value
})
if (res.code === 0 && res.data.items && res.data.items.length > 0) {
enterpriseInfo.value = res.data.items[0]
}
} catch (error) {
console.error('获取企业信息失败:', error)
}
}
// 获取企业卡列表
const getTableData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined,
msisdn: searchForm.msisdn || undefined,
status: searchForm.status,
authorization_status: searchForm.authorization_status
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await EnterpriseService.getEnterpriseCards(enterpriseId.value, params)
if (res.code === 0) {
cardList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error('获取企业卡列表失败')
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
// 表格选择变化
const handleSelectionChange = (selection: EnterpriseCardItem[]) => {
selectedCards.value = selection
}
// 返回
const goBack = () => {
router.back()
}
// 显示授权对话框
const showAllocateDialog = () => {
allocateDialogVisible.value = true
iccidsText.value = ''
allocateForm.iccids = []
allocateForm.confirm_device_bundles = false
previewData.value = null
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
}
// 处理ICCID输入变化
const handleIccidsChange = () => {
// 解析输入的ICCID支持逗号、空格、换行分隔
const iccids = iccidsText.value
.split(/[,\s\n]+/)
.map((iccid) => iccid.trim())
.filter((iccid) => iccid.length > 0)
allocateForm.iccids = iccids
// 清除预检结果
previewData.value = null
}
// 预检
const handlePreview = async () => {
if (!allocateFormRef.value) return
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
previewLoading.value = true
try {
const res = await EnterpriseService.previewAllocateCards(enterpriseId.value, {
iccids: allocateForm.iccids
})
if (res.code === 0) {
previewData.value = res.data
ElMessage.success('预检完成')
}
} catch (error) {
console.error(error)
ElMessage.error('预检失败')
} finally {
previewLoading.value = false
}
}
})
}
// 执行授权
const handleAllocate = async () => {
if (!allocateFormRef.value) return
if (!previewData.value) {
ElMessage.warning('请先进行预检')
return
}
if (previewData.value.summary.valid_cards === 0) {
ElMessage.warning('没有可授权的卡')
return
}
// 如果有设备绑定且未确认,提示用户
if (
previewData.value.device_bundles &&
previewData.value.device_bundles.length > 0 &&
!allocateForm.confirm_device_bundles
) {
ElMessage.warning('请确认设备绑定关系')
return
}
allocateLoading.value = true
try {
const res = await EnterpriseService.allocateCards(enterpriseId.value, {
iccids: allocateForm.iccids,
confirm_device_bundles: allocateForm.confirm_device_bundles || undefined
})
if (res.code === 0) {
operationResult.value = res.data
resultTitle.value = '授权结果'
allocateDialogVisible.value = false
resultDialogVisible.value = true
getTableData()
}
} catch (error) {
console.error(error)
ElMessage.error('授权失败')
} finally {
allocateLoading.value = false
}
}
// 关闭授权对话框
const handleAllocateDialogClose = () => {
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
previewData.value = null
}
// 显示批量回收对话框
const showRecallDialog = () => {
if (selectedCards.value.length === 0) {
ElMessage.warning('请先选择要回收的卡')
return
}
recallDialogVisible.value = true
recallForm.reason = ''
if (recallFormRef.value) {
recallFormRef.value.resetFields()
}
}
// 执行批量回收
const handleRecall = async () => {
if (!recallFormRef.value) return
await recallFormRef.value.validate(async (valid) => {
if (valid) {
recallLoading.value = true
try {
const res = await EnterpriseService.recallCards(enterpriseId.value, {
card_ids: selectedCards.value.map((card) => card.id)
})
if (res.code === 0) {
operationResult.value = res.data
resultTitle.value = '回收结果'
recallDialogVisible.value = false
resultDialogVisible.value = true
// 清空选择
if (tableRef.value) {
tableRef.value.clearSelection()
}
selectedCards.value = []
getTableData()
}
} catch (error) {
console.error(error)
ElMessage.error('回收失败')
} finally {
recallLoading.value = false
}
}
})
}
// 关闭批量回收对话框
const handleRecallDialogClose = () => {
if (recallFormRef.value) {
recallFormRef.value.resetFields()
}
}
// 停机卡
const handleSuspend = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要停机该卡吗?', '停机卡', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
await EnterpriseService.suspendCard(enterpriseId.value, row.id)
ElMessage.success('停机成功')
getTableData()
} catch (error) {
console.error(error)
ElMessage.error('停机失败')
}
})
.catch(() => {})
}
// 复机卡
const handleResume = (row: EnterpriseCardItem) => {
ElMessageBox.confirm('确定要复机该卡吗?', '复机卡', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
})
.then(async () => {
try {
await EnterpriseService.resumeCard(enterpriseId.value, row.id)
ElMessage.success('复机成功')
getTableData()
} catch (error) {
console.error(error)
ElMessage.error('复机失败')
}
})
.catch(() => {})
}
</script>
<style lang="scss" scoped>
.enterprise-cards-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
</style>

View File

@@ -200,6 +200,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router'
import { EnterpriseService, ShopService } from '@/api/modules' import { EnterpriseService, ShopService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus' import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
@@ -211,6 +212,8 @@
defineOptions({ name: 'EnterpriseCustomer' }) defineOptions({ name: 'EnterpriseCustomer' })
const router = useRouter()
const dialogVisible = ref(false) const dialogVisible = ref(false)
const passwordDialogVisible = ref(false) const passwordDialogVisible = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -433,10 +436,14 @@
{ {
prop: 'operation', prop: 'operation',
label: '操作', label: '操作',
width: 160, width: 230,
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, {
icon: '&#xe679;',
onClick: () => manageCards(row)
}),
h(ArtButtonTable, { h(ArtButtonTable, {
icon: '&#xe72b;', icon: '&#xe72b;',
onClick: () => showPasswordDialog(row) onClick: () => showPasswordDialog(row)
@@ -696,6 +703,14 @@
console.error(error) console.error(error)
} }
} }
// 卡管理
const manageCards = (row: EnterpriseItem) => {
router.push({
path: '/account-management/enterprise-cards',
query: { id: row.id }
})
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="formFilters" v-model:filter="formFilters"
:items="formItems" :items="formItems"
label-width="90"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
></ArtSearchBar> ></ArtSearchBar>
@@ -142,7 +143,7 @@
clearable: true, clearable: true,
startPlaceholder: '开始时间', startPlaceholder: '开始时间',
endPlaceholder: '结束时间', endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss' valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
} }
} }
] ]

View File

@@ -0,0 +1,116 @@
<template>
<div class="authorization-detail-page">
<ElCard shadow="never" v-loading="loading">
<template #header>
<div class="card-header">
<span>授权记录详情</span>
<ElButton @click="goBack">返回</ElButton>
</div>
</template>
<ElDescriptions v-if="authorizationDetail" :column="2" border>
<ElDescriptionsItem label="授权记录ID">{{ authorizationDetail.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="authorizationDetail.status === 1 ? 'success' : 'info'">
{{ authorizationDetail.status === 1 ? '有效' : '已回收' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="ICCID">{{ authorizationDetail.iccid }}</ElDescriptionsItem>
<ElDescriptionsItem label="手机号">{{ authorizationDetail.msisdn }}</ElDescriptionsItem>
<ElDescriptionsItem label="企业名称">
{{ authorizationDetail.enterprise_name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="企业ID">
{{ authorizationDetail.enterprise_id }}
</ElDescriptionsItem>
<ElDescriptionsItem label="授权人">
{{ authorizationDetail.authorizer_name }}
</ElDescriptionsItem>
<ElDescriptionsItem label="授权人类型">
<ElTag :type="authorizationDetail.authorizer_type === 2 ? 'primary' : 'success'">
{{ authorizationDetail.authorizer_type === 2 ? '平台' : '代理' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="授权时间">
{{ formatDateTime(authorizationDetail.authorized_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="授权人ID">
{{ authorizationDetail.authorized_by }}
</ElDescriptionsItem>
<ElDescriptionsItem label="回收人">
{{ authorizationDetail.revoker_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="回收时间">
{{ authorizationDetail.revoked_at ? formatDateTime(authorizationDetail.revoked_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="备注" :span="2">
{{ authorizationDetail.remark || '-' }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { AuthorizationService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { formatDateTime } from '@/utils/business/format'
import type { AuthorizationItem } from '@/types/api/authorization'
defineOptions({ name: 'AuthorizationDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const authorizationDetail = ref<AuthorizationItem | null>(null)
onMounted(() => {
const id = route.query.id
if (id) {
getAuthorizationDetail(Number(id))
} else {
ElMessage.error('缺少授权记录ID')
goBack()
}
})
// 获取授权记录详情
const getAuthorizationDetail = async (id: number) => {
loading.value = true
try {
const res = await AuthorizationService.getAuthorizationDetail(id)
if (res.code === 0) {
authorizationDetail.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('获取授权记录详情失败')
} finally {
loading.value = false
}
}
// 返回
const goBack = () => {
router.back()
}
</script>
<style lang="scss" scoped>
.authorization-detail-page {
padding: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,418 @@
<template>
<ArtTableFullScreen>
<div class="authorization-records-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="85"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="authorizationList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 修改备注对话框 -->
<ElDialog v-model="remarkDialogVisible" title="修改备注" width="500px">
<ElForm ref="remarkFormRef" :model="remarkForm" :rules="remarkRules" label-width="80px">
<ElFormItem label="备注" prop="remark">
<ElInput
v-model="remarkForm.remark"
type="textarea"
:rows="4"
placeholder="请输入备注最多500字"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="remarkDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmitRemark" :loading="remarkLoading">
提交
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { AuthorizationService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import type {
AuthorizationItem,
AuthorizationStatus,
AuthorizerType
} from '@/types/api/authorization'
import { CommonStatus } from '@/config/constants'
defineOptions({ name: 'AuthorizationRecords' })
const router = useRouter()
const loading = ref(false)
const remarkDialogVisible = ref(false)
const remarkLoading = ref(false)
const tableRef = ref()
const remarkFormRef = ref<FormInstance>()
const currentRecordId = ref<number>(0)
// 搜索表单初始值
const initialSearchState = {
enterprise_id: undefined as number | undefined,
iccid: '',
authorizer_type: undefined as AuthorizerType | undefined,
status: undefined as AuthorizationStatus | undefined,
dateRange: [] as string[]
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 备注表单
const remarkForm = reactive({
remark: ''
})
// 备注表单验证规则
const remarkRules = reactive<FormRules>({
remark: [{ max: 500, message: '备注不能超过500个字符', trigger: 'blur' }]
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: 'ICCID',
prop: 'iccid',
type: 'input',
config: {
clearable: true,
placeholder: '请输入ICCID'
}
},
{
label: '授权人类型',
prop: 'authorizer_type',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '平台', value: 2 },
{ label: '代理', value: 3 }
]
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: '有效', value: 1 },
{ label: '已回收', value: 0 }
]
},
{
label: '授权时间',
prop: 'dateRange',
type: 'daterange',
config: {
type: 'daterange',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD'
}
}
]
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'id' },
{ label: 'ICCID', prop: 'iccid' },
{ label: '手机号', prop: 'msisdn' },
{ label: '企业名称', prop: 'enterprise_name' },
{ label: '授权人', prop: 'authorizer_name' },
{ label: '授权人类型', prop: 'authorizer_type' },
{ label: '授权时间', prop: 'authorized_at' },
{ label: '状态', prop: 'status' },
{ label: '回收人', prop: 'revoker_name' },
{ label: '回收时间', prop: 'revoked_at' },
{ label: '备注', prop: 'remark' },
{ label: '操作', prop: 'operation' }
]
const authorizationList = ref<AuthorizationItem[]>([])
// 获取授权人类型标签类型
const getAuthorizerTypeTag = (type: AuthorizerType) => {
return type === 2 ? 'primary' : 'success'
}
// 获取授权人类型文本
const getAuthorizerTypeText = (type: AuthorizerType) => {
return type === 2 ? '平台' : '代理'
}
// 获取状态标签类型
const getStatusTag = (status: AuthorizationStatus) => {
return status === 1 ? 'success' : 'info'
}
// 获取状态文本
const getStatusText = (status: AuthorizationStatus) => {
return status === 1 ? '有效' : '已回收'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'iccid',
label: 'ICCID',
minWidth: 180
},
{
prop: 'msisdn',
label: '手机号',
width: 120
},
{
prop: 'enterprise_name',
label: '企业名称',
minWidth: 150
},
{
prop: 'authorizer_name',
label: '授权人',
width: 120
},
{
prop: 'authorizer_type',
label: '授权人类型',
width: 100,
formatter: (row: AuthorizationItem) => {
return h(
ElTag,
{ type: getAuthorizerTypeTag(row.authorizer_type) },
() => getAuthorizerTypeText(row.authorizer_type)
)
}
},
{
prop: 'authorized_at',
label: '授权时间',
width: 180,
formatter: (row: AuthorizationItem) => formatDateTime(row.authorized_at)
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: AuthorizationItem) => {
return h(ElTag, { type: getStatusTag(row.status) }, () => getStatusText(row.status))
}
},
{
prop: 'revoker_name',
label: '回收人',
width: 120,
formatter: (row: AuthorizationItem) => row.revoker_name || '-'
},
{
prop: 'revoked_at',
label: '回收时间',
width: 180,
formatter: (row: AuthorizationItem) => (row.revoked_at ? formatDateTime(row.revoked_at) : '-')
},
{
prop: 'remark',
label: '备注',
minWidth: 150,
showOverflowTooltip: true,
formatter: (row: AuthorizationItem) => row.remark || '-'
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: AuthorizationItem) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
type: 'view',
onClick: () => viewDetail(row)
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => showRemarkDialog(row)
})
])
}
}
])
onMounted(() => {
getTableData()
})
// 获取授权记录列表
const getTableData = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
iccid: searchForm.iccid || undefined,
authorizer_type: searchForm.authorizer_type,
status: searchForm.status
}
// 处理日期范围
if (searchForm.dateRange && searchForm.dateRange.length === 2) {
params.start_time = searchForm.dateRange[0]
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await AuthorizationService.getAuthorizations(params)
if (res.code === 0) {
authorizationList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error('获取授权记录列表失败')
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
// 查看详情
const viewDetail = (row: AuthorizationItem) => {
router.push({
path: '/asset-management/authorization-detail',
query: { id: row.id }
})
}
// 显示修改备注对话框
const showRemarkDialog = (row: AuthorizationItem) => {
currentRecordId.value = row.id
remarkForm.remark = row.remark || ''
remarkDialogVisible.value = true
}
// 提交备注修改
const handleSubmitRemark = async () => {
if (!remarkFormRef.value) return
await remarkFormRef.value.validate(async (valid) => {
if (valid) {
remarkLoading.value = true
try {
await AuthorizationService.updateAuthorizationRemark(currentRecordId.value, {
remark: remarkForm.remark
})
ElMessage.success('备注修改成功')
remarkDialogVisible.value = false
getTableData()
} catch (error) {
console.error(error)
ElMessage.error('备注修改失败')
} finally {
remarkLoading.value = false
}
}
})
}
</script>
<style lang="scss" scoped>
.authorization-records-page {
// Authorization records page styles
}
</style>

View File

@@ -5,6 +5,7 @@
<ArtSearchBar <ArtSearchBar
v-model:filter="formFilters" v-model:filter="formFilters"
:items="formItems" :items="formItems"
label-width="90"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
></ArtSearchBar> ></ArtSearchBar>
@@ -74,13 +75,18 @@
:on-change="handleFileChange" :on-change="handleFileChange"
:on-exceed="handleExceed" :on-exceed="handleExceed"
:file-list="fileList" :file-list="fileList"
accept=".xlsx,.xls" accept=".csv"
> >
<template #trigger> <template #trigger>
<ElButton type="primary">选择文件</ElButton> <ElButton type="primary">选择文件</ElButton>
</template> </template>
<template #tip> <template #tip>
<div class="el-upload__tip">只能上传xlsx/xls文件且不超过10MB</div> <div class="el-upload__tip">
<div>只支持上传CSV文件且不超过10MB</div>
<div style="color: var(--el-color-info); margin-top: 4px">
CSV格式ICCID,MSISDN两列逗号分隔每行一条记录
</div>
</div>
</template> </template>
</ElUpload> </ElUpload>
</ElFormItem> </ElFormItem>
@@ -252,6 +258,7 @@
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
</ElCard> </ElCard>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
@@ -259,7 +266,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { CardService } from '@/api/modules' import { CardService, StorageService } from '@/api/modules'
import { ElMessage, ElTag, ElUpload } from 'element-plus' import { ElMessage, ElTag, ElUpload } from 'element-plus'
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'
@@ -714,14 +721,37 @@
importLoading.value = true importLoading.value = true
try { try {
const res = await CardService.importIotCards( // 确保 Content-Type 在获取 URL 和上传时完全一致
const contentType = importForm.file.type || 'text/csv'
// 1. 获取上传 URL
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: importForm.file.name,
content_type: contentType,
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error('获取上传地址失败')
return
}
// 2. 上传文件到对象存储
await StorageService.uploadFile(
uploadUrlRes.data.upload_url,
importForm.file, importForm.file,
importForm.carrier_id!, contentType
importForm.batch_no || undefined
) )
if (res.code === 0) { // 3. 调用导入接口
ElMessage.success('导入任务已创建,请到任务管理页面查看导入进度') const importRes = await CardService.importIotCards({
carrier_id: importForm.carrier_id!,
file_key: uploadUrlRes.data.file_key,
batch_no: importForm.batch_no || undefined
})
if (importRes.code === 0) {
ElMessage.success(importRes.data.message || '导入任务已创建,请到任务管理页面查看导入进度')
importDialogVisible.value = false importDialogVisible.value = false
getTableData() getTableData()
} }

View File

@@ -0,0 +1,348 @@
<template>
<div class="device-detail-page">
<ElPageHeader @back="handleBack" title="设备详情" />
<ElCard shadow="never" style="margin-top: 20px" v-loading="loading">
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between">
<span style="font-weight: bold">基本信息</span>
<ElTag :type="statusTypeMap[deviceInfo?.status || 1]">
{{ deviceInfo?.status_name || '-' }}
</ElTag>
</div>
</template>
<ElDescriptions :column="3" border v-if="deviceInfo">
<ElDescriptionsItem label="设备ID">{{ deviceInfo.id }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备号">{{ deviceInfo.device_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备名称">{{ deviceInfo.device_name }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备型号">{{ deviceInfo.device_model }}</ElDescriptionsItem>
<ElDescriptionsItem label="设备类型">{{ deviceInfo.device_type }}</ElDescriptionsItem>
<ElDescriptionsItem label="制造商">{{ deviceInfo.manufacturer }}</ElDescriptionsItem>
<ElDescriptionsItem label="最大插槽数">{{ deviceInfo.max_sim_slots }}</ElDescriptionsItem>
<ElDescriptionsItem label="已绑定卡数">
<span style="color: #67c23a; font-weight: bold">{{ deviceInfo.bound_card_count }}</span>
/ {{ deviceInfo.max_sim_slots }}
</ElDescriptionsItem>
<ElDescriptionsItem label="所属店铺">
{{ deviceInfo.shop_name || '平台库存' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">
{{ deviceInfo.batch_no || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="激活时间">
{{ deviceInfo.activated_at ? formatDateTime(deviceInfo.activated_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(deviceInfo.created_at) }}
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
<ElCard shadow="never" style="margin-top: 20px" v-loading="cardsLoading">
<template #header>
<div style="display: flex; align-items: center; justify-content: space-between">
<span style="font-weight: bold">绑定的卡列表</span>
<ElButton
type="primary"
size="small"
@click="showBindDialog"
:disabled="!deviceInfo || deviceInfo.bound_card_count >= deviceInfo.max_sim_slots"
>
绑定新卡
</ElButton>
</div>
</template>
<ElTable :data="cardList" border>
<ElTableColumn prop="slot_position" label="插槽位置" width="100" align="center">
<template #default="{ row }">
<ElTag type="info" size="small">插槽 {{ row.slot_position }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="iccid" label="ICCID" minWidth="180" />
<ElTableColumn prop="msisdn" label="接入号" width="150">
<template #default="{ row }">
{{ row.msisdn || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="carrier_name" label="运营商" width="120" />
<ElTableColumn prop="status" label="卡状态" width="100">
<template #default="{ row }">
<ElTag :type="cardStatusTypeMap[row.status]" size="small">
{{ cardStatusTextMap[row.status] || '未知' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="bind_time" label="绑定时间" width="180">
<template #default="{ row }">
{{ row.bind_time ? formatDateTime(row.bind_time) : '-' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<ElButton type="danger" text size="small" @click="handleUnbindCard(row)">
解绑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElEmpty v-if="!cardList.length" description="暂无绑定的卡" />
</ElCard>
<!-- 绑定卡对话框 -->
<ElDialog v-model="bindDialogVisible" title="绑定卡到设备" width="500px">
<ElForm ref="bindFormRef" :model="bindForm" :rules="bindRules" label-width="100px">
<ElFormItem label="选择卡" prop="iot_card_id">
<ElSelect
v-model="bindForm.iot_card_id"
placeholder="请搜索并选择卡"
filterable
remote
:remote-method="searchCards"
:loading="searchCardsLoading"
style="width: 100%"
>
<ElOption
v-for="card in availableCards"
:key="card.id"
:label="`${card.iccid} - ${card.carrier_name}`"
:value="card.id"
>
<div style="display: flex; justify-content: space-between">
<span>{{ card.iccid }}</span>
<ElTag size="small" type="info">{{ card.carrier_name }}</ElTag>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="插槽位置" prop="slot_position">
<ElSelect v-model="bindForm.slot_position" placeholder="请选择插槽位置" style="width: 100%">
<ElOption
v-for="slot in availableSlots"
:key="slot"
:label="`插槽 ${slot}`"
:value="slot"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="bindDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleConfirmBind" :loading="bindLoading">
确认绑定
</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { DeviceService, CardService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { Device, DeviceCardBinding } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'DeviceDetail' })
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const cardsLoading = ref(false)
const bindLoading = ref(false)
const searchCardsLoading = ref(false)
const bindDialogVisible = ref(false)
const bindFormRef = ref<FormInstance>()
const deviceInfo = ref<Device | null>(null)
const cardList = ref<DeviceCardBinding[]>([])
const availableCards = ref<any[]>([])
// 状态类型映射
const statusTypeMap: Record<number, any> = {
1: 'info',
2: 'primary',
3: 'success',
4: 'danger'
}
// 卡状态类型映射
const cardStatusTypeMap: Record<number, any> = {
1: 'info',
2: 'primary',
3: 'success',
4: 'danger'
}
// 卡状态文本映射
const cardStatusTextMap: Record<number, string> = {
1: '在库',
2: '已分销',
3: '已激活',
4: '已停用'
}
// 绑定表单
const bindForm = reactive({
iot_card_id: undefined as number | undefined,
slot_position: undefined as number | undefined
})
// 绑定表单验证规则
const bindRules = reactive<FormRules>({
iot_card_id: [{ required: true, message: '请选择卡', trigger: 'change' }],
slot_position: [{ required: true, message: '请选择插槽位置', trigger: 'change' }]
})
// 可用插槽
const availableSlots = computed(() => {
if (!deviceInfo.value) return []
const occupiedSlots = cardList.value.map((card) => card.slot_position)
const allSlots = Array.from({ length: deviceInfo.value.max_sim_slots }, (_, i) => i + 1)
return allSlots.filter((slot) => !occupiedSlots.includes(slot))
})
onMounted(() => {
const deviceId = route.query.id
if (deviceId) {
loadDeviceInfo(Number(deviceId))
loadDeviceCards(Number(deviceId))
} else {
ElMessage.error('设备ID不存在')
handleBack()
}
})
// 加载设备信息
const loadDeviceInfo = async (id: number) => {
loading.value = true
try {
const res = await DeviceService.getDeviceById(id)
if (res.code === 0) {
deviceInfo.value = res.data
}
} catch (error) {
console.error(error)
ElMessage.error('获取设备信息失败')
} finally {
loading.value = false
}
}
// 加载设备绑定的卡列表
const loadDeviceCards = async (id: number) => {
cardsLoading.value = true
try {
const res = await DeviceService.getDeviceCards(id)
if (res.code === 0) {
cardList.value = res.data.bindings || []
}
} catch (error) {
console.error(error)
ElMessage.error('获取绑定卡列表失败')
} finally {
cardsLoading.value = false
}
}
// 搜索可用的卡
const searchCards = async (query: string) => {
if (!query) {
availableCards.value = []
return
}
searchCardsLoading.value = true
try {
const res = await CardService.getStandaloneIotCards({
iccid: query,
page: 1,
page_size: 20
})
if (res.code === 0) {
availableCards.value = res.data.list || []
}
} catch (error) {
console.error(error)
} finally {
searchCardsLoading.value = false
}
}
// 显示绑定对话框
const showBindDialog = () => {
bindForm.iot_card_id = undefined
bindForm.slot_position = undefined
availableCards.value = []
bindDialogVisible.value = true
}
// 确认绑定
const handleConfirmBind = async () => {
if (!bindFormRef.value || !deviceInfo.value) return
await bindFormRef.value.validate(async (valid) => {
if (valid) {
bindLoading.value = true
try {
const data = {
iot_card_id: bindForm.iot_card_id!,
slot_position: bindForm.slot_position!
}
const res = await DeviceService.bindCard(deviceInfo.value!.id, data)
if (res.code === 0) {
ElMessage.success('绑定成功')
bindDialogVisible.value = false
await loadDeviceInfo(deviceInfo.value!.id)
await loadDeviceCards(deviceInfo.value!.id)
if (bindFormRef.value) {
bindFormRef.value.resetFields()
}
}
} catch (error) {
console.error(error)
ElMessage.error('绑定失败')
} finally {
bindLoading.value = false
}
}
})
}
// 解绑卡
const handleUnbindCard = (card: DeviceCardBinding) => {
ElMessageBox.confirm(`确定解绑卡 ${card.iccid} 吗?`, '解绑确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
try {
await DeviceService.unbindCard(deviceInfo.value!.id, card.iot_card_id)
ElMessage.success('解绑成功')
await loadDeviceInfo(deviceInfo.value!.id)
await loadDeviceCards(deviceInfo.value!.id)
} catch (error) {
console.error(error)
ElMessage.error('解绑失败')
}
})
.catch(() => {
// 用户取消
})
}
// 返回
const handleBack = () => {
router.back()
}
</script>
<style scoped lang="scss">
.device-detail-page {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,674 @@
<template>
<ArtTableFullScreen>
<div class="device-list-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
type="primary"
@click="handleBatchAllocate"
:disabled="!selectedDevices.length"
>
批量分配
</ElButton>
<ElButton @click="handleBatchRecall" :disabled="!selectedDevices.length">
批量回收
</ElButton>
<ElButton @click="handleImportDevice">导入设备</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="deviceList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@selection-change="handleSelectionChange"
>
<template #default>
<ElTableColumn type="selection" width="55" />
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 批量分配对话框 -->
<ElDialog v-model="allocateDialogVisible" title="批量分配设备" width="600px">
<ElForm ref="allocateFormRef" :model="allocateForm" :rules="allocateRules" label-width="120px">
<ElFormItem label="已选设备数">
<span style="color: #409eff; font-weight: bold">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="目标店铺" prop="target_shop_id">
<ElSelect
v-model="allocateForm.target_shop_id"
placeholder="请选择目标店铺"
filterable
style="width: 100%"
>
<ElOption
v-for="shop in shopList"
:key="shop.id"
:label="shop.name"
:value="shop.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="allocateForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息(选填)"
/>
</ElFormItem>
</ElForm>
<!-- 分配结果 -->
<div v-if="allocateResult" style="margin-top: 20px">
<ElAlert
:type="allocateResult.fail_count === 0 ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 10px"
>
<template #title>
成功分配 {{ allocateResult.success_count }} 失败 {{ allocateResult.fail_count }}
</template>
</ElAlert>
<div v-if="allocateResult.failed_items && allocateResult.failed_items.length > 0">
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in allocateResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCloseAllocateDialog">
{{ allocateResult ? '关闭' : '取消' }}
</ElButton>
<ElButton
v-if="!allocateResult"
type="primary"
@click="handleConfirmAllocate"
:loading="allocateLoading"
>
确认分配
</ElButton>
</div>
</template>
</ElDialog>
<!-- 批量回收对话框 -->
<ElDialog v-model="recallDialogVisible" title="批量回收设备" width="600px">
<ElForm ref="recallFormRef" :model="recallForm" label-width="120px">
<ElFormItem label="已选设备数">
<span style="color: #e6a23c; font-weight: bold">{{ selectedDevices.length }}</span>
</ElFormItem>
<ElFormItem label="备注">
<ElInput
v-model="recallForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息(选填)"
/>
</ElFormItem>
</ElForm>
<!-- 回收结果 -->
<div v-if="recallResult" style="margin-top: 20px">
<ElAlert
:type="recallResult.fail_count === 0 ? 'success' : 'warning'"
:closable="false"
style="margin-bottom: 10px"
>
<template #title>
成功回收 {{ recallResult.success_count }} 失败 {{ recallResult.fail_count }}
</template>
</ElAlert>
<div v-if="recallResult.failed_items && recallResult.failed_items.length > 0">
<div style="margin-bottom: 10px; font-weight: bold">失败详情</div>
<div
v-for="item in recallResult.failed_items"
:key="item.device_id"
style="margin-bottom: 8px; color: #f56c6c; font-size: 12px"
>
设备号: {{ item.device_no }} - {{ item.reason }}
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleCloseRecallDialog">
{{ recallResult ? '关闭' : '取消' }}
</ElButton>
<ElButton
v-if="!recallResult"
type="primary"
@click="handleConfirmRecall"
:loading="recallLoading"
>
确认回收
</ElButton>
</div>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceService, ShopService } from '@/api/modules'
import { ElMessage, ElMessageBox, ElTag, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Device,
DeviceStatus,
AllocateDevicesResponse,
RecallDevicesResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import ArtButtonTable from '@/components/core/forms/ArtButtonTable.vue'
import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText } from '@/config/constants'
defineOptions({ name: 'DeviceList' })
const router = useRouter()
const loading = ref(false)
const allocateLoading = ref(false)
const recallLoading = ref(false)
const tableRef = ref()
const allocateFormRef = ref<FormInstance>()
const recallFormRef = ref<FormInstance>()
const allocateDialogVisible = ref(false)
const recallDialogVisible = ref(false)
const selectedDevices = ref<Device[]>([])
const shopList = ref<any[]>([])
const allocateResult = ref<AllocateDevicesResponse | null>(null)
const recallResult = ref<RecallDevicesResponse | null>(null)
// 搜索表单初始值
const initialSearchState = {
device_no: '',
device_name: '',
status: undefined as DeviceStatus | undefined,
shop_id: undefined as number | undefined,
batch_no: '',
device_type: '',
manufacturer: '',
created_at_start: '',
created_at_end: ''
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '设备号',
prop: 'device_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备号'
}
},
{
label: '设备名称',
prop: 'device_name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备名称'
}
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态',
options: [
{ label: '在库', value: 1 },
{ label: '已分销', value: 2 },
{ label: '已激活', value: 3 },
{ label: '已停用', value: 4 }
]
}
},
{
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
},
{
label: '设备类型',
prop: 'device_type',
type: 'input',
config: {
clearable: true,
placeholder: '请输入设备类型'
}
},
{
label: '制造商',
prop: 'manufacturer',
type: 'input',
config: {
clearable: true,
placeholder: '请输入制造商'
}
}
]
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 列配置
const columnOptions = [
{ label: 'ID', prop: 'id' },
{ label: '设备号', prop: 'device_no' },
{ label: '设备名称', prop: 'device_name' },
{ label: '设备型号', prop: 'device_model' },
{ label: '设备类型', prop: 'device_type' },
{ label: '制造商', prop: 'manufacturer' },
{ label: '最大插槽数', prop: 'max_sim_slots' },
{ label: '已绑定卡数', prop: 'bound_card_count' },
{ label: '状态', prop: 'status' },
{ label: '店铺', prop: 'shop_name' },
{ label: '批次号', prop: 'batch_no' },
{ label: '激活时间', prop: 'activated_at' },
{ label: '创建时间', prop: 'created_at' },
{ label: '操作', prop: 'operation' }
]
const deviceList = ref<Device[]>([])
// 分配表单
const allocateForm = reactive({
target_shop_id: undefined as number | undefined,
remark: ''
})
// 分配表单验证规则
const allocateRules = reactive<FormRules>({
target_shop_id: [{ required: true, message: '请选择目标店铺', trigger: 'change' }]
})
// 回收表单
const recallForm = reactive({
remark: ''
})
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'id',
label: 'ID',
width: 80
},
{
prop: 'device_no',
label: '设备号',
minWidth: 150
},
{
prop: 'device_name',
label: '设备名称',
minWidth: 120
},
{
prop: 'device_model',
label: '设备型号',
minWidth: 120
},
{
prop: 'device_type',
label: '设备类型',
width: 100
},
{
prop: 'manufacturer',
label: '制造商',
minWidth: 120
},
{
prop: 'max_sim_slots',
label: '最大插槽数',
width: 100,
align: 'center'
},
{
prop: 'bound_card_count',
label: '已绑定卡数',
width: 110,
align: 'center',
formatter: (row: Device) => {
const color = row.bound_card_count > 0 ? '#67c23a' : '#909399'
return h('span', { style: { color, fontWeight: 'bold' } }, row.bound_card_count)
}
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: Device) => {
const statusMap: Record<number, { text: string; type: any }> = {
1: { text: '在库', type: 'info' },
2: { text: '已分销', type: 'primary' },
3: { text: '已激活', type: 'success' },
4: { text: '已停用', type: 'danger' }
}
const status = statusMap[row.status] || { text: '未知', type: 'info' }
return h(ElTag, { type: status.type }, () => status.text)
}
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 120,
formatter: (row: Device) => row.shop_name || '-'
},
{
prop: 'batch_no',
label: '批次号',
minWidth: 120,
formatter: (row: Device) => row.batch_no || '-'
},
{
prop: 'activated_at',
label: '激活时间',
width: 180,
formatter: (row: Device) => (row.activated_at ? formatDateTime(row.activated_at) : '-')
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: Device) => formatDateTime(row.created_at)
},
{
prop: 'operation',
label: '操作',
width: 150,
fixed: 'right',
formatter: (row: Device) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(ArtButtonTable, {
type: 'view',
onClick: () => viewDeviceDetail(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteDevice(row)
})
])
}
}
])
onMounted(() => {
getTableData()
loadShopList()
})
// 加载店铺列表
const loadShopList = async () => {
try {
const res = await ShopService.getShops({ page: 1, pageSize: 1000 })
if (res.code === 0) {
shopList.value = res.data.items || []
}
} catch (error) {
console.error('获取店铺列表失败:', error)
}
}
// 获取设备列表
const getTableData = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
device_no: searchForm.device_no || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
shop_id: searchForm.shop_id,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
const res = await DeviceService.getDevices(params)
if (res.code === 0 && res.data) {
deviceList.value = res.data.list || []
pagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
ElMessage.error('获取设备列表失败')
} finally {
loading.value = false
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
}
// 处理选择变化
const handleSelectionChange = (selection: Device[]) => {
selectedDevices.value = selection
}
// 查看设备详情
const viewDeviceDetail = (row: Device) => {
router.push({
path: '/asset-management/device-detail',
query: { id: row.id }
})
}
// 删除设备
const deleteDevice = (row: Device) => {
ElMessageBox.confirm(`确定删除设备 ${row.device_no} 吗?删除后将自动解绑所有卡。`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
})
.then(async () => {
try {
await DeviceService.deleteDevice(row.id)
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
console.error(error)
ElMessage.error('删除失败')
}
})
.catch(() => {
// 用户取消删除
})
}
// 批量分配
const handleBatchAllocate = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
return
}
allocateForm.target_shop_id = undefined
allocateForm.remark = ''
allocateResult.value = null
allocateDialogVisible.value = true
}
// 确认批量分配
const handleConfirmAllocate = async () => {
if (!allocateFormRef.value) return
await allocateFormRef.value.validate(async (valid) => {
if (valid) {
allocateLoading.value = true
try {
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
target_shop_id: allocateForm.target_shop_id!,
remark: allocateForm.remark
}
const res = await DeviceService.allocateDevices(data)
if (res.code === 0) {
allocateResult.value = res.data
if (res.data.fail_count === 0) {
ElMessage.success('全部分配成功')
setTimeout(() => {
handleCloseAllocateDialog()
getTableData()
}, 1500)
} else {
ElMessage.warning(`部分分配失败,请查看失败详情`)
}
}
} catch (error) {
console.error(error)
ElMessage.error('分配失败')
} finally {
allocateLoading.value = false
}
}
})
}
// 关闭分配对话框
const handleCloseAllocateDialog = () => {
allocateDialogVisible.value = false
allocateResult.value = null
if (allocateFormRef.value) {
allocateFormRef.value.resetFields()
}
}
// 批量回收
const handleBatchRecall = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要回收的设备')
return
}
recallForm.remark = ''
recallResult.value = null
recallDialogVisible.value = true
}
// 确认批量回收
const handleConfirmRecall = async () => {
recallLoading.value = true
try {
const data = {
device_ids: selectedDevices.value.map((d) => d.id),
remark: recallForm.remark
}
const res = await DeviceService.recallDevices(data)
if (res.code === 0) {
recallResult.value = res.data
if (res.data.fail_count === 0) {
ElMessage.success('全部回收成功')
setTimeout(() => {
handleCloseRecallDialog()
getTableData()
}, 1500)
} else {
ElMessage.warning(`部分回收失败,请查看失败详情`)
}
}
} catch (error) {
console.error(error)
ElMessage.error('回收失败')
} finally {
recallLoading.value = false
}
}
// 关闭回收对话框
const handleCloseRecallDialog = () => {
recallDialogVisible.value = false
recallResult.value = null
recallForm.remark = ''
}
// 导入设备
const handleImportDevice = () => {
router.push('/batch/device-import')
}
</script>
<style scoped lang="scss">
.device-list-page {
height: 100%;
}
</style>

View File

@@ -10,8 +10,15 @@
<!-- 任务基本信息 --> <!-- 任务基本信息 -->
<ElDescriptions title="任务基本信息" :column="3" border class="task-info"> <ElDescriptions title="任务基本信息" :column="3" border class="task-info">
<ElDescriptionsItem label="任务编号">{{ taskDetail?.task_no || '-' }}</ElDescriptionsItem> <ElDescriptionsItem label="任务编号">{{ taskDetail?.task_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="任务类型">
<ElTag :type="taskType === 'device' ? 'warning' : 'primary'" size="small">
{{ taskType === 'device' ? '设备导入' : 'ICCID导入' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="批次号">{{ taskDetail?.batch_no || '-' }}</ElDescriptionsItem> <ElDescriptionsItem label="批次号">{{ taskDetail?.batch_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="运营商">{{ taskDetail?.carrier_name || '-' }}</ElDescriptionsItem> <ElDescriptionsItem label="运营商" v-if="taskType === 'card'">
{{ (taskDetail as IotCardImportTaskDetail)?.carrier_name || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="文件名">{{ taskDetail?.file_name || '-' }}</ElDescriptionsItem> <ElDescriptionsItem label="文件名">{{ taskDetail?.file_name || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="任务状态"> <ElDescriptionsItem label="任务状态">
<ElTag :type="getStatusType(taskDetail?.status)" v-if="taskDetail"> <ElTag :type="getStatusType(taskDetail?.status)" v-if="taskDetail">
@@ -55,7 +62,18 @@
</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 prop="iccid" label="ICCID" min-width="180" /> <ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn prop="reason" label="失败原因" min-width="300" /> <ElTableColumn prop="reason" label="失败原因" min-width="300" />
</ElTable> </ElTable>
</div> </div>
@@ -67,7 +85,18 @@
</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 prop="iccid" label="ICCID" min-width="180" /> <ElTableColumn
v-if="taskType === 'card'"
prop="iccid"
label="ICCID"
min-width="180"
/>
<ElTableColumn
v-else
prop="device_no"
label="设备号"
min-width="180"
/>
<ElTableColumn prop="reason" label="跳过原因" min-width="300" /> <ElTableColumn prop="reason" label="跳过原因" min-width="300" />
</ElTable> </ElTable>
</div> </div>
@@ -78,18 +107,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { CardService } 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'
defineOptions({ name: 'TaskDetail' }) defineOptions({ name: 'TaskDetail' })
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const taskDetail = ref<IotCardImportTaskDetail | null>(null) type TaskType = 'card' | 'device'
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
const taskDetail = ref<TaskDetail | null>(null)
const loading = ref(false) const loading = ref(false)
const taskType = ref<TaskType>('card')
// 获取状态标签类型 // 获取状态标签类型
const getStatusType = (status?: IotCardImportTaskStatus) => { const getStatusType = (status?: IotCardImportTaskStatus) => {
@@ -116,17 +150,33 @@
// 获取任务详情 // 获取任务详情
const getTaskDetail = async () => { const getTaskDetail = async () => {
const taskId = route.query.id const taskId = route.query.id
const queryTaskType = route.query.task_type as TaskType | undefined
if (!taskId) { if (!taskId) {
ElMessage.error('缺少任务ID参数') ElMessage.error('缺少任务ID参数')
goBack() goBack()
return return
} }
// 设置任务类型
if (queryTaskType) {
taskType.value = queryTaskType
}
loading.value = true loading.value = true
try { try {
const res = await CardService.getIotCardImportTaskDetail(Number(taskId)) if (taskType.value === 'device') {
if (res.code === 0) { // 获取设备导入任务详情
taskDetail.value = res.data const res = await DeviceService.getImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
} else {
// 获取ICCID导入任务详情
const res = await CardService.getIotCardImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)

View File

@@ -42,13 +42,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { CardService } from '@/api/modules' import { CardService, DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus' import { ElMessage, ElTag } 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 type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card' import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
import type { DeviceImportTask } from '@/types/api/device'
defineOptions({ name: 'TaskManagement' }) defineOptions({ name: 'TaskManagement' })
@@ -56,8 +57,13 @@
const loading = ref(false) const loading = ref(false)
const tableRef = ref() const tableRef = ref()
// 任务类型
type TaskType = 'card' | 'device'
type ImportTask = IotCardImportTask | DeviceImportTask
// 搜索表单初始值 // 搜索表单初始值
const initialSearchState = { const initialSearchState = {
task_type: undefined as TaskType | undefined,
status: undefined, status: undefined,
carrier_id: undefined, carrier_id: undefined,
batch_no: '', batch_no: '',
@@ -77,6 +83,19 @@
// 搜索表单配置 // 搜索表单配置
const searchFormItems: SearchFormItem[] = [ const searchFormItems: SearchFormItem[] = [
{
label: '任务类型',
prop: 'task_type',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
options: () => [
{ label: 'ICCID导入', value: 'card' },
{ label: '设备导入', value: 'device' }
]
},
{ {
label: '任务状态', label: '任务状态',
prop: 'status', prop: 'status',
@@ -123,7 +142,7 @@
type: 'daterange', type: 'daterange',
startPlaceholder: '开始时间', startPlaceholder: '开始时间',
endPlaceholder: '结束时间', endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD HH:mm:ss' valueFormat: 'YYYY-MM-DDTHH:mm:ssZ'
} }
} }
] ]
@@ -131,6 +150,7 @@
// 列配置 // 列配置
const columnOptions = [ const columnOptions = [
{ label: '任务编号', prop: 'task_no' }, { label: '任务编号', prop: 'task_no' },
{ label: '任务类型', prop: 'task_type' },
{ label: '批次号', prop: 'batch_no' }, { label: '批次号', prop: 'batch_no' },
{ label: '运营商', prop: 'carrier_name' }, { label: '运营商', prop: 'carrier_name' },
{ label: '文件名', prop: 'file_name' }, { label: '文件名', prop: 'file_name' },
@@ -144,7 +164,7 @@
{ label: '操作', prop: 'operation' } { label: '操作', prop: 'operation' }
] ]
const taskList = ref<IotCardImportTask[]>([]) const taskList = ref<ImportTask[]>([])
// 获取状态标签类型 // 获取状态标签类型
const getStatusType = (status: IotCardImportTaskStatus) => { const getStatusType = (status: IotCardImportTaskStatus) => {
@@ -162,11 +182,28 @@
} }
} }
// 获取任务类型
const getTaskType = (row: ImportTask): TaskType => {
// 判断是否为设备导入任务(设备导入任务有 device_no 字段,卡导入任务有 carrier_name 字段)
if ('device_no' in row || (row.batch_no && row.batch_no.startsWith('DEV-'))) {
return 'device'
}
return 'card'
}
// 获取任务类型文本
const getTaskTypeText = (taskType: TaskType) => {
return taskType === 'device' ? '设备导入' : 'ICCID导入'
}
// 查看详情 // 查看详情
const viewDetail = (row: IotCardImportTask) => { const viewDetail = (row: ImportTask) => {
router.push({ router.push({
path: '/asset-management/task-detail', path: '/asset-management/task-detail',
query: { id: row.id } query: {
id: row.id,
task_type: getTaskType(row)
}
}) })
} }
@@ -177,6 +214,16 @@
label: '任务编号', label: '任务编号',
width: 150 width: 150
}, },
{
prop: 'task_type',
label: '任务类型',
width: 100,
formatter: (row: ImportTask) => {
const taskType = getTaskType(row)
const tagType = taskType === 'device' ? 'warning' : 'primary'
return h(ElTag, { type: tagType, size: 'small' }, () => getTaskTypeText(taskType))
}
},
{ {
prop: 'batch_no', prop: 'batch_no',
label: '批次号', label: '批次号',
@@ -185,7 +232,10 @@
{ {
prop: 'carrier_name', prop: 'carrier_name',
label: '运营商', label: '运营商',
width: 100 width: 100,
formatter: (row: ImportTask) => {
return (row as IotCardImportTask).carrier_name || '-'
}
}, },
{ {
prop: 'file_name', prop: 'file_name',
@@ -196,7 +246,7 @@
prop: 'status', prop: 'status',
label: '任务状态', label: '任务状态',
width: 100, width: 100,
formatter: (row: IotCardImportTask) => { formatter: (row: ImportTask) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text) return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
} }
}, },
@@ -214,7 +264,7 @@
prop: 'fail_count', prop: 'fail_count',
label: '失败数', label: '失败数',
width: 80, width: 80,
formatter: (row: IotCardImportTask) => { formatter: (row: ImportTask) => {
const type = row.fail_count > 0 ? 'danger' : 'success' const type = row.fail_count > 0 ? 'danger' : 'success'
return h(ElTag, { type, size: 'small' }, () => row.fail_count) return h(ElTag, { type, size: 'small' }, () => row.fail_count)
} }
@@ -228,20 +278,20 @@
prop: 'created_at', prop: 'created_at',
label: '创建时间', label: '创建时间',
width: 160, width: 160,
formatter: (row: IotCardImportTask) => formatDateTime(row.created_at) formatter: (row: ImportTask) => formatDateTime(row.created_at)
}, },
{ {
prop: 'completed_at', prop: 'completed_at',
label: '完成时间', label: '完成时间',
width: 160, width: 160,
formatter: (row: IotCardImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-') formatter: (row: ImportTask) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
}, },
{ {
prop: 'operation', prop: 'operation',
label: '操作', label: '操作',
width: 100, width: 100,
fixed: 'right', fixed: 'right',
formatter: (row: IotCardImportTask) => { formatter: (row: ImportTask) => {
return h(ArtButtonTable, { return h(ArtButtonTable, {
type: 'view', type: 'view',
onClick: () => viewDetail(row) onClick: () => viewDetail(row)
@@ -262,7 +312,6 @@
page: pagination.page, page: pagination.page,
page_size: pagination.pageSize, page_size: pagination.pageSize,
status: searchForm.status, status: searchForm.status,
carrier_id: searchForm.carrier_id,
batch_no: searchForm.batch_no || undefined batch_no: searchForm.batch_no || undefined
} }
@@ -279,10 +328,48 @@
} }
}) })
const res = await CardService.getIotCardImportTasks(params) // 根据任务类型获取不同的数据
if (res.code === 0) { if (searchForm.task_type === 'device') {
taskList.value = res.data.list || [] // 仅获取设备导入任务
pagination.total = res.data.total || 0 const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
taskList.value = res.data.list || []
pagination.total = res.data.total || 0
}
} else if (searchForm.task_type === 'card') {
// 仅获取ICCID导入任务需要carrier_id参数
const cardParams = {
...params,
carrier_id: searchForm.carrier_id
}
const res = await CardService.getIotCardImportTasks(cardParams)
if (res.code === 0) {
taskList.value = res.data.list || []
pagination.total = res.data.total || 0
}
} else {
// 获取所有类型任务 - 分别调用两个API然后合并结果
const [cardRes, deviceRes] = await Promise.all([
CardService.getIotCardImportTasks({
...params,
carrier_id: searchForm.carrier_id
}),
DeviceService.getImportTasks(params)
])
const cardTasks = cardRes.code === 0 ? cardRes.data.list || [] : []
const deviceTasks = deviceRes.code === 0 ? deviceRes.data.list || [] : []
// 合并并按创建时间排序
const allTasks = [...cardTasks, ...deviceTasks].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// 前端分页
const start = (pagination.page - 1) * pagination.pageSize
const end = start + pagination.pageSize
taskList.value = allTasks.slice(start, end)
pagination.total = allTasks.length
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)

View File

@@ -12,11 +12,12 @@
<template #title> <template #title>
<div style="line-height: 1.8"> <div style="line-height: 1.8">
<p><strong>导入说明</strong></p> <p><strong>导入说明</strong></p>
<p>1. 请先下载模板文件按照模板格式填写设备信息</p> <p>1. 请先下载 CSV 模板文件按照模板格式填写设备信息</p>
<p>2. 支持 Excel 格式.xlsx, .xls单次最多导入 500 </p> <p>2. 支持 CSV 格式.csv单次最多导入 1000 </p>
<p>3. 必填字段设备编号设备名称设备类型ICCID绑定网卡</p> <p>3. CSV 文件编码UTF-8推荐 GBK</p>
<p>4. ICCID 必须在系统中已存在否则导入失败</p> <p>4. 必填字段device_no设备号device_name设备名称device_model设备型号</p>
<p>5. 设备编号重复将自动跳过</p> <p>5. 可选字段device_type设备类型manufacturer制造商max_sim_slots最大插槽数默认1</p>
<p>6. 设备号重复将自动跳过导入后可在任务管理中查看详情</p>
</div> </div>
</template> </template>
</ElAlert> </ElAlert>
@@ -31,18 +32,15 @@
<ElUpload <ElUpload
ref="uploadRef" ref="uploadRef"
drag drag
:action="uploadUrl"
:on-change="handleFileChange"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:auto-upload="false" :auto-upload="false"
:on-change="handleFileChange"
:limit="1" :limit="1"
accept=".xlsx,.xls" accept=".csv"
> >
<el-icon class="el-icon--upload"><upload-filled /></el-icon> <el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div> <div class="el-upload__text"> CSV 文件拖到此处<em>点击选择</em></div>
<template #tip> <template #tip>
<div class="el-upload__tip">只能上传 xlsx/xls 文件且不超过 5MB</div> <div class="el-upload__tip">只能上传 CSV 文件且不超过 10MB</div>
</template> </template>
</ElUpload> </ElUpload>
<div style="margin-top: 16px; text-align: center"> <div style="margin-top: 16px; text-align: center">
@@ -124,7 +122,7 @@
<ElOption label="完成" value="success" /> <ElOption label="完成" value="success" />
<ElOption label="失败" value="failed" /> <ElOption label="失败" value="failed" />
</ElSelect> </ElSelect>
<ElButton size="small" @click="refreshList">刷新</ElButton> <ElButton @click="refreshList">刷新</ElButton>
</div> </div>
</div> </div>
</template> </template>
@@ -239,6 +237,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { import {
Download, Download,
UploadFilled, UploadFilled,
@@ -249,10 +248,14 @@
CircleCloseFilled, CircleCloseFilled,
TrendCharts TrendCharts
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import type { UploadInstance, UploadRawFile } from 'element-plus' import type { UploadInstance } from 'element-plus'
import { StorageService } from '@/api/modules/storage'
import { DeviceService } from '@/api/modules'
defineOptions({ name: 'DeviceImport' }) defineOptions({ name: 'DeviceImport' })
const router = useRouter()
interface FailReason { interface FailReason {
row: number row: number
deviceCode: string deviceCode: string
@@ -276,8 +279,7 @@
} }
const uploadRef = ref<UploadInstance>() const uploadRef = ref<UploadInstance>()
const uploadUrl = ref('/api/batch/device-import') const fileList = ref<File[]>([])
const fileList = ref<UploadRawFile[]>([])
const uploading = ref(false) const uploading = ref(false)
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
const statusFilter = ref('') const statusFilter = ref('')
@@ -366,8 +368,25 @@
}, 1000) }, 1000)
} }
const handleFileChange = (file: any, files: any[]) => { const handleFileChange = (uploadFile: any) => {
fileList.value = files // 验证文件大小10MB限制
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 = () => { const clearFiles = () => {
@@ -375,56 +394,73 @@
fileList.value = [] fileList.value = []
} }
/**
* 三步上传流程
* 1. 调用 StorageService.getUploadUrl() 获取预签名 URL 和 file_key
* 2. 调用 StorageService.uploadFile() 上传文件到对象存储
* 3. 调用 DeviceService.importDevices() 触发后端导入任务
*/
const submitUpload = async () => { const submitUpload = async () => {
if (!fileList.value.length) { if (!fileList.value.length) {
ElMessage.warning('请先选择文件') ElMessage.warning('请先选择CSV文件')
return return
} }
const file = fileList.value[0]
uploading.value = true uploading.value = true
ElMessage.info('正在导入设备并绑定ICCID请稍候...')
// 模拟上传和导入过程 try {
setTimeout(() => { // 第一步:获取预签名上传 URL
const newRecord: ImportRecord = { ElMessage.info('正在准备上传...')
id: Date.now().toString(), const uploadUrlRes = await StorageService.getUploadUrl({
batchNo: `DEV${new Date().getTime()}`, file_name: file.name,
fileName: fileList.value[0].name, content_type: 'text/csv',
totalCount: 100, purpose: 'iot_import'
successCount: 95, })
failCount: 5,
bindCount: 95, if (uploadUrlRes.code !== 0) {
status: 'success', throw new Error(uploadUrlRes.msg || '获取上传地址失败')
progress: 100,
importTime: new Date().toLocaleString('zh-CN'),
operator: 'admin',
failReasons: [
{
row: 12,
deviceCode: 'TEST001',
iccid: '89860123456789012351',
message: 'ICCID 不存在'
},
{ row: 34, deviceCode: 'TEST002', iccid: '89860123456789012352', message: '设备类型无效' }
]
} }
importRecords.value.unshift(newRecord) const { upload_url, file_key } = uploadUrlRes.data
uploading.value = false
// 第二步:上传文件到对象存储
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, 'text/csv')
// 第三步调用设备导入API
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
// 清空文件列表
clearFiles() clearFiles()
ElMessage.success(
`导入完成!成功 ${newRecord.successCount} 条,失败 ${newRecord.failCount} 条,已绑定 ${newRecord.bindCount} 个ICCID`
)
}, 2000)
}
const handleUploadSuccess = () => { // 显示成功消息并提供跳转链接
ElMessage.success('上传成功') ElMessage.success({
} message: `导入任务已创建!任务编号:${taskNo}`,
duration: 5000,
showClose: true
})
const handleUploadError = () => { // 3秒后跳转到任务管理页面
uploading.value = false setTimeout(() => {
ElMessage.error('上传失败') router.push('/asset-management/task-management')
}, 3000)
} catch (error: any) {
console.error('设备导入失败:', error)
ElMessage.error(error.message || '设备导入失败')
} finally {
uploading.value = false
}
} }
const refreshList = () => { const refreshList = () => {

View File

@@ -29,6 +29,12 @@ export default ({ mode }) => {
target: VITE_API_URL, target: VITE_API_URL,
changeOrigin: true changeOrigin: true
// rewrite: (path) => path.replace(/^\/api/, '') // 注释掉后端API包含/api前缀不需要重写 // rewrite: (path) => path.replace(/^\/api/, '') // 注释掉后端API包含/api前缀不需要重写
},
// 对象存储代理 - 解决开发环境 CORS 问题
'/obs-proxy': {
target: 'http://obs-helf.cucloud.cn',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/obs-proxy/, '')
} }
}, },
host: true host: true