This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# Design: 后台任务页与操作审计日志文件下载入口
|
||||
|
||||
## Context
|
||||
|
||||
当前项目已经具备对象存储上传与下载地址获取能力:
|
||||
|
||||
- `StorageService.getUploadUrl()` 已用于上传导入文件
|
||||
- `StorageService.batchDownloadUrls()` 已封装 `POST /api/admin/storage/batch-download-urls`
|
||||
|
||||
但在后台实际业务入口上,下载链路还没有被接起来:
|
||||
|
||||
- IoT 卡任务页和设备任务页只有“失败数据”等操作,没有“下载原始导入文件”
|
||||
- 共享的资产操作审计日志组件虽然会显示 `文件存储键`,但只能看见 `file_key` 字符串,不能直接下载
|
||||
|
||||
此外,操作审计日志组件是共享组件,同时用于:
|
||||
|
||||
- 资产详情页
|
||||
- IoT 卡列表中的操作审计日志弹窗
|
||||
- 设备列表中的操作审计日志弹窗
|
||||
|
||||
这意味着“下载文件”能力既要复用统一实现,又要允许三个入口使用不同权限编码。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
- Goals:
|
||||
- 为 IoT 卡任务页增加原始导入文件下载入口
|
||||
- 为设备任务页增加原始导入文件下载入口
|
||||
- 为三个后台审计日志入口增加基于 `file_key` 的内联下载入口
|
||||
- 为以上 5 个入口分别施加独立权限控制
|
||||
- 复用现有 `batch-download-urls` 接口完成下载
|
||||
- Non-Goals:
|
||||
- 不改造对象存储上传流程
|
||||
- 不增加批量下载多个文件的 UI
|
||||
- 不扩展到 C 端页面
|
||||
- 不在本次提案中统一所有历史权限命名差异
|
||||
|
||||
## Decisions
|
||||
|
||||
- Decision: 所有“下载文件”入口统一通过 `POST /api/admin/storage/batch-download-urls` 获取下载 URL
|
||||
|
||||
- Rationale: 现有基础设施已经覆盖私有文件下载,不需要新增独立接口;单文件下载可直接以 `file_keys: [file_key]` 方式复用批量接口。
|
||||
|
||||
- Decision: 任务页“下载文件”继续放在现有操作列中,而不是新增文件列
|
||||
|
||||
- Rationale: IoT 卡任务页和设备任务页已经有行级操作模式;把下载文件放入操作列,和“失败数据”等现有动作一致,改动面更小。
|
||||
|
||||
- Decision: 审计日志中的“下载文件”必须紧跟在 `文件存储键` / `file_key` 值后,而不是放到日志行外层操作区
|
||||
|
||||
- Rationale: 需求明确指出“如果变更内容出现了文件存储键,后面也要跟上下载文件”,因此入口应与该字段形成强绑定。
|
||||
|
||||
- Decision: 共享 `OperationLogsCard` 作为唯一实现,但下载权限由调用入口传入
|
||||
|
||||
- Rationale: 三个审计日志入口复用同一组件,如果把权限写死在组件内部,就无法满足“3 个不同权限编码”的要求。
|
||||
|
||||
- Decision: 本提案默认新增权限编码建议如下
|
||||
|
||||
- 任务页:
|
||||
- `iot_card_task:download_file`
|
||||
- `device_task:download_file`
|
||||
- 审计日志入口:
|
||||
- `asset_info:download_log_file`
|
||||
- `iot_card:download_log_file`
|
||||
- `device:download_log_file`
|
||||
- Rationale: 任务页下载权限与现有同页行级动作前缀保持一致;日志下载权限与各自入口页面前缀保持一致,便于角色配置时理解来源。
|
||||
|
||||
- Decision: 不在本次变更中顺带统一 IoT 卡任务页历史上的 `lot_task:*` / `iot_card_task:*` 前缀差异
|
||||
|
||||
- Rationale: 当前需求只要求增加下载文件权限入口;若同时做权限前缀清理,会把提案扩大为权限命名治理,超出本次范围。
|
||||
|
||||
- Decision: 当 `file_key` 缺失、为空或下载 URL 获取失败时,页面只提示失败,不影响列表或日志正常渲染
|
||||
|
||||
- Rationale: 下载属于增强能力,不应把原有任务列表和审计日志变成硬依赖下载服务的阻塞页面。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 任务列表前端类型当前未显式声明 `file_key`,实施时若接口已返回但类型未补齐,容易出现“功能依赖字段存在但类型缺口”的问题。
|
||||
- `OperationLogsCard` 的 props 和渲染逻辑一旦调整,会同时影响资产详情页、IoT 卡列表弹窗和设备列表弹窗,需要联动验证。
|
||||
- IoT 卡任务页现存 `lot_task:bulk_import` 与 `iot_card_task:*` 并存,新增权限虽可落地,但会继续保留命名历史包袱。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 明确导入任务记录的 `file_key` 契约,并补齐前端类型。
|
||||
2. 在 IoT 卡任务页和设备任务页的操作列增加“下载文件”。
|
||||
3. 为两个任务页接入各自独立的下载权限编码。
|
||||
4. 扩展共享 `OperationLogsCard`,在 `file_key` 场景渲染内联“下载文件”入口。
|
||||
5. 由资产详情页、IoT 卡列表、设备列表分别传入对应的日志下载权限编码。
|
||||
6. 联调验证 5 个入口的权限隔离与下载行为。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- IoT 卡任务新增下载权限默认建议使用 `iot_card_task:download_file`。如果后端权限树仍希望完全沿用 `lot_task:*` 前缀,需要在评审阶段确认是否改为 `lot_task:download_file`。
|
||||
- 当前导入任务列表接口是否已经返回 `file_key` 需要评审确认;若未返回,则实施范围需要同步包含接口契约补齐。
|
||||
@@ -0,0 +1,54 @@
|
||||
# Change: 为后台任务页与操作审计日志补充文件下载入口
|
||||
|
||||
## Why
|
||||
|
||||
当前后台已经具备对象存储上传能力和下载 URL 获取接口,但与“下载文件”相关的运营入口仍然缺失两块关键能力:
|
||||
|
||||
- IoT 卡任务页和设备任务页虽然展示了导入任务记录,但操作列里没有“下载文件”入口,运营无法直接回看任务原始导入文件。
|
||||
- 资产操作审计日志已经会显示 `文件存储键` / `file_key`,但仍然只是纯文本,无法直接从日志里发起下载。
|
||||
|
||||
同时,本次需求明确要求这些下载入口按页面上下文拆分权限,不允许任务页和三个审计日志入口共用同一个按钮权限编码。
|
||||
|
||||
如果不补齐这些入口,后台用户仍然需要依赖复制 `file_key`、跳外部系统或人工排查对象存储文件,审计与任务追溯链路会继续断裂。
|
||||
|
||||
本提案仅覆盖后台管理端(admin)能力,不包含 C 端页面和 C 端接口消费逻辑。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 为后台任务管理增加原始导入文件下载入口:
|
||||
- 在 IoT 卡任务列表操作列增加“下载文件”
|
||||
- 在设备任务列表操作列增加“下载文件”
|
||||
- 两个任务页分别使用独立按钮权限编码
|
||||
- 为后台资产操作审计日志增加基于 `file_key` 的下载入口:
|
||||
- 当日志变更内容包含 `文件存储键` / `file_key` 时,在对应值后追加“下载文件”
|
||||
- 资产详情页、IoT 卡列表审计日志弹窗、设备列表审计日志弹窗分别使用独立按钮权限编码
|
||||
- 共享日志组件需要支持由不同入口传入对应的下载权限上下文
|
||||
- 明确复用现有下载接口:
|
||||
- 所有下载入口统一通过 `POST /api/admin/storage/batch-download-urls` 获取预签名下载 URL
|
||||
- 本次不新增新的对象存储下载 API
|
||||
- 范围约束:
|
||||
- 不改造上传流程
|
||||
- 不扩展为“文件中心”或批量多文件下载能力
|
||||
- 不顺带统一现有全部权限命名历史差异,仅为本次新增入口定义可审查的权限编码
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:
|
||||
- `admin-file-download`
|
||||
- Affected code:
|
||||
- `src/api/modules/storage.ts`
|
||||
- `src/views/asset-management/task-management/iot-card-task/index.vue`
|
||||
- `src/views/asset-management/task-management/device-task/index.vue`
|
||||
- `src/views/asset-management/asset-information/components/OperationLogsCard.vue`
|
||||
- `src/components/business/OperationLogsDialog.vue`
|
||||
- `src/views/asset-management/asset-information/index.vue`
|
||||
- `src/views/asset-management/iot-card-management/index.vue`
|
||||
- `src/views/asset-management/device-list/index.vue`
|
||||
- `src/types/api/card.ts`
|
||||
- `src/types/api/device.ts`
|
||||
- Dependencies:
|
||||
- 依赖现有 `POST /api/admin/storage/batch-download-urls` 可为单个 `file_key` 返回可直接下载的预签名 URL
|
||||
- 与活跃变更 `update-admin-asset-device-signal-and-audit-logs` 存在 `OperationLogsCard.vue` 共享组件交集,实施前需要基于其最新状态合并
|
||||
- 导入任务列表/详情接口需要提供可下载所需的 `file_key`;如果当前接口尚未返回,实施阶段需要同步补齐契约
|
||||
- Breaking changes:
|
||||
- 无外部接口路径和 HTTP 方法层面的破坏性变更;主要是后台入口、权限和前端类型契约的增量扩展
|
||||
@@ -0,0 +1,80 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Admin Import Tasks Provide Source File Download Actions
|
||||
|
||||
The admin import task pages SHALL provide a source-file download action for eligible task rows and SHALL gate the action with page-specific permissions.
|
||||
|
||||
#### Scenario: Show download action on IoT card task rows
|
||||
|
||||
- **GIVEN** 后台 IoT 卡任务列表中的某条任务记录包含可下载的原始文件标识
|
||||
- **AND** 当前用户具备该页面的下载文件权限
|
||||
- **WHEN** 任务列表渲染该条记录
|
||||
- **THEN** 该条记录的操作列 MUST 显示“下载文件”
|
||||
- **AND** 用户点击后,页面 MUST 通过 `POST /api/admin/storage/batch-download-urls` 获取该文件的下载地址并发起下载
|
||||
|
||||
#### Scenario: Show download action on device task rows
|
||||
|
||||
- **GIVEN** 后台设备任务列表中的某条任务记录包含可下载的原始文件标识
|
||||
- **AND** 当前用户具备该页面的下载文件权限
|
||||
- **WHEN** 任务列表渲染该条记录
|
||||
- **THEN** 该条记录的操作列 MUST 显示“下载文件”
|
||||
- **AND** 用户点击后,页面 MUST 通过 `POST /api/admin/storage/batch-download-urls` 获取该文件的下载地址并发起下载
|
||||
|
||||
#### Scenario: Hide task download action when permission or file key is missing
|
||||
|
||||
- **GIVEN** 某条任务记录缺少可下载文件标识,或当前用户不具备该页面的下载文件权限
|
||||
- **WHEN** 任务列表渲染该条记录
|
||||
- **THEN** 页面 MUST NOT 显示“下载文件”入口
|
||||
|
||||
### Requirement: Admin Operation Logs Provide Inline File Download Actions
|
||||
|
||||
The admin asset operation log views SHALL append an inline download action next to `文件存储键` / `file_key` values when a downloadable file exists.
|
||||
|
||||
#### Scenario: Asset detail operation log shows inline download action
|
||||
|
||||
- **GIVEN** 用户在资产详情页查看操作审计日志
|
||||
- **AND** 某条日志变更内容包含 `文件存储键` 或 `file_key`
|
||||
- **AND** 当前用户具备资产详情页日志下载权限
|
||||
- **WHEN** 该日志内容被渲染
|
||||
- **THEN** 页面 MUST 在对应文件键值后显示“下载文件”
|
||||
- **AND** 用户点击后,页面 MUST 通过 `POST /api/admin/storage/batch-download-urls` 获取该文件的下载地址并发起下载
|
||||
|
||||
#### Scenario: IoT card list log dialog shows inline download action
|
||||
|
||||
- **GIVEN** 用户从 IoT 卡列表打开操作审计日志弹窗
|
||||
- **AND** 某条日志变更内容包含 `文件存储键` 或 `file_key`
|
||||
- **AND** 当前用户具备 IoT 卡列表日志下载权限
|
||||
- **WHEN** 该日志内容被渲染
|
||||
- **THEN** 页面 MUST 在对应文件键值后显示“下载文件”
|
||||
|
||||
#### Scenario: Device list log dialog shows inline download action
|
||||
|
||||
- **GIVEN** 用户从设备列表打开操作审计日志弹窗
|
||||
- **AND** 某条日志变更内容包含 `文件存储键` 或 `file_key`
|
||||
- **AND** 当前用户具备设备列表日志下载权限
|
||||
- **WHEN** 该日志内容被渲染
|
||||
- **THEN** 页面 MUST 在对应文件键值后显示“下载文件”
|
||||
|
||||
#### Scenario: Hide inline log download action without permission or file key
|
||||
|
||||
- **GIVEN** 某条日志不包含可下载文件键值,或当前用户不具备当前入口的日志下载权限
|
||||
- **WHEN** 该日志内容被渲染
|
||||
- **THEN** 页面 MUST NOT 显示“下载文件”入口
|
||||
|
||||
### Requirement: Admin File Download Permissions Remain Entry-Specific
|
||||
|
||||
The admin file-download entry points MUST use distinct permission checks per page context instead of sharing one global download permission.
|
||||
|
||||
#### Scenario: Task page permissions are isolated
|
||||
|
||||
- **GIVEN** 某用户具备设备任务页的下载文件权限,但不具备 IoT 卡任务页的下载文件权限
|
||||
- **WHEN** 用户分别访问两个任务页
|
||||
- **THEN** 设备任务页 MAY 显示“下载文件”
|
||||
- **AND** IoT 卡任务页 MUST NOT 因设备任务权限而显示该入口
|
||||
|
||||
#### Scenario: Shared log component respects caller-specific permissions
|
||||
|
||||
- **GIVEN** 某用户具备 IoT 卡列表日志下载权限,但不具备设备列表日志下载权限
|
||||
- **WHEN** 同一共享日志组件分别在两个入口渲染包含 `file_key` 的日志
|
||||
- **THEN** IoT 卡列表入口 MAY 显示“下载文件”
|
||||
- **AND** 设备列表入口 MUST NOT 因共享组件复用而错误显示该入口
|
||||
@@ -0,0 +1,25 @@
|
||||
## 1. Proposal Review
|
||||
|
||||
- [x] 1.1 确认本提案仅覆盖后台管理端,不包含 C 端页面与 C 端接口消费逻辑。
|
||||
- [x] 1.2 确认 5 个新增下载入口需要使用彼此独立的权限编码。
|
||||
- [x] 1.3 确认 IoT 卡任务下载权限是否沿用提案建议 `iot_card_task:download_file`,还是改为 `lot_task:download_file`。
|
||||
- [ ] 1.4 确认导入任务列表/详情接口是否已经提供原始文件下载所需的 `file_key`。
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 补齐 IoT 卡导入任务与设备导入任务的前端类型契约,接收原始文件下载所需字段。
|
||||
- [x] 2.2 在 IoT 卡任务列表操作列增加“下载文件”,并接入独立权限编码。
|
||||
- [x] 2.3 在设备任务列表操作列增加“下载文件”,并接入独立权限编码。
|
||||
- [x] 2.4 复用 `POST /api/admin/storage/batch-download-urls` 实现单文件下载流程。
|
||||
- [x] 2.5 扩展共享操作审计日志组件,在 `文件存储键` / `file_key` 后增加“下载文件”入口。
|
||||
- [x] 2.6 让资产详情页、IoT 卡列表日志弹窗、设备列表日志弹窗分别传入各自的日志下载权限编码。
|
||||
- [x] 2.7 确保无权限或无 `file_key` 时不显示下载入口,且下载失败不影响原有内容渲染。
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [ ] 3.1 验证 IoT 卡任务页仅在具备对应权限且记录存在可下载文件时显示“下载文件”。
|
||||
- [ ] 3.2 验证设备任务页仅在具备对应权限且记录存在可下载文件时显示“下载文件”。
|
||||
- [ ] 3.3 验证资产详情页操作审计日志中,`文件存储键` 后可按权限显示“下载文件”。
|
||||
- [ ] 3.4 验证 IoT 卡列表操作审计日志弹窗中,`文件存储键` 后可按权限显示“下载文件”。
|
||||
- [ ] 3.5 验证设备列表操作审计日志弹窗中,`文件存储键` 后可按权限显示“下载文件”。
|
||||
- [ ] 3.6 验证 5 个下载入口均通过 `batch-download-urls` 正常发起下载。
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
ListResponse,
|
||||
GatewayRealnameLinkResponse,
|
||||
ImportIotCardRequest,
|
||||
ImportIotCardResponse
|
||||
ImportIotCardResponse,
|
||||
IotCardImportTask,
|
||||
IotCardImportTaskDetail
|
||||
} from '@/types/api'
|
||||
|
||||
export class CardService extends BaseService {
|
||||
@@ -296,7 +298,7 @@ export class CardService extends BaseService {
|
||||
* 获取导入任务列表
|
||||
* @param params 查询参数
|
||||
*/
|
||||
static getIotCardImportTasks(params?: any): Promise<PaginationResponse<any>> {
|
||||
static getIotCardImportTasks(params?: any): Promise<PaginationResponse<IotCardImportTask>> {
|
||||
return this.getPage('/api/admin/iot-cards/import-tasks', params)
|
||||
}
|
||||
|
||||
@@ -304,8 +306,8 @@ export class CardService extends BaseService {
|
||||
* 获取导入任务详情
|
||||
* @param id 任务ID
|
||||
*/
|
||||
static getIotCardImportTaskDetail(id: number): Promise<BaseResponse<any>> {
|
||||
return this.getOne(`/api/admin/iot-cards/import-tasks/${id}`)
|
||||
static getIotCardImportTaskDetail(id: number): Promise<BaseResponse<IotCardImportTaskDetail>> {
|
||||
return this.getOne<IotCardImportTaskDetail>(`/api/admin/iot-cards/import-tasks/${id}`)
|
||||
}
|
||||
|
||||
// ========== 单卡列表(未绑定设备)相关 ==========
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { BaseResponse } from '@/types/api'
|
||||
|
||||
const inferFileNameFromKey = (fileKey: string) => {
|
||||
return fileKey.split('/').pop() || 'download'
|
||||
}
|
||||
|
||||
const triggerBrowserDownload = (downloadUrl: string, fileName: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = fileName
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件用途枚举
|
||||
*/
|
||||
@@ -132,4 +147,36 @@ export class StorageService extends BaseService {
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单文件下载 URL
|
||||
* @param fileKey 文件路径标识
|
||||
*/
|
||||
static async getDownloadUrl(fileKey: string): Promise<string> {
|
||||
const normalizedFileKey = fileKey.trim()
|
||||
const res = await this.batchDownloadUrls({
|
||||
file_keys: [normalizedFileKey]
|
||||
})
|
||||
|
||||
if (res.code !== 0 || !res.data?.urls?.[normalizedFileKey]) {
|
||||
throw new Error(res.msg || '获取下载地址失败')
|
||||
}
|
||||
|
||||
return res.data.urls[normalizedFileKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* 按文件标识直接触发浏览器下载
|
||||
* @param fileKey 文件路径标识
|
||||
* @param fileName 可选文件名
|
||||
*/
|
||||
static async downloadFileByKey(fileKey: string, fileName?: string): Promise<void> {
|
||||
const normalizedFileKey = fileKey.trim()
|
||||
if (!normalizedFileKey) {
|
||||
throw new Error('文件标识不能为空')
|
||||
}
|
||||
|
||||
const downloadUrl = await this.getDownloadUrl(normalizedFileKey)
|
||||
triggerBrowserDownload(downloadUrl, fileName || inferFileNameFromKey(normalizedFileKey))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
size="70%"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<OperationLogsCard :asset-identifier="identifier" />
|
||||
<OperationLogsCard :asset-identifier="identifier" :download-permission="downloadPermission" />
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
identifier?: string
|
||||
downloadPermission?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
@@ -216,6 +216,9 @@ export interface AssetPackageUsageRecord {
|
||||
enable_virtual_data?: boolean // 是否启用虚流量
|
||||
paid_amount?: number // 实际支付金额(分)
|
||||
package_price?: number // 套餐价格(分)
|
||||
start_time?: string // 开始时间(兼容前端现有映射字段)
|
||||
expire_time?: string // 到期时间(兼容前端现有映射字段)
|
||||
duration_days?: number // 套餐时长(兼容前端现有映射字段)
|
||||
activated_at?: string // 激活时间
|
||||
expires_at?: string // 到期时间
|
||||
master_usage_id?: number | null // 主套餐 ID(加油包时有值)
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface ImportIotCardRequest {
|
||||
file_key: string // 对象存储文件路径
|
||||
batch_no?: string // 批次号
|
||||
card_category?: CardCategory // 卡业务类型(新增,可选,默认 normal)
|
||||
realname_policy?: 'none' | 'before_order' | 'after_order' // 实名认证策略
|
||||
}
|
||||
|
||||
// IoT 卡导入响应
|
||||
@@ -283,6 +284,8 @@ export interface IotCardImportTask {
|
||||
carrier_type: string // 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信)
|
||||
carrier_name: string // 运营商名称
|
||||
file_name: string // 文件名
|
||||
file_key?: string // 原始导入文件存储键
|
||||
card_category?: CardCategory // 卡业务类型
|
||||
status: IotCardImportTaskStatus // 任务状态
|
||||
status_text: string // 任务状态文本
|
||||
total_count: number // 总数
|
||||
@@ -369,6 +372,12 @@ export interface StandaloneIotCard {
|
||||
cost_price: number // 成本价(分)
|
||||
distribute_price: number // 分销价(分)
|
||||
data_usage_mb: number // 累计流量使用(MB)
|
||||
current_month_usage_mb?: number // 自然月累计流量(MB)
|
||||
current_month_start_date?: string | null // 本月开始日期
|
||||
last_month_total_mb?: number // 上月流量总量(MB)
|
||||
last_data_check_at?: string | null // 最后流量检查时间
|
||||
last_real_name_check_at?: string | null // 最后实名检查时间
|
||||
enable_polling?: boolean // 是否启用轮询
|
||||
first_commission_paid: boolean // 是否已支付首次佣金
|
||||
accumulated_recharge: number // 累计充值金额(分)
|
||||
activated_at?: string | null // 激活时间 (可选)
|
||||
|
||||
@@ -200,6 +200,7 @@ export interface DeviceImportTask {
|
||||
task_no: string // 任务编号
|
||||
batch_no: string // 批次号
|
||||
file_name: string // 文件名
|
||||
file_key?: string // 原始导入文件存储键
|
||||
status: DeviceImportTaskStatus // 任务状态
|
||||
status_text: string // 任务状态文本
|
||||
total_count: number // 总数
|
||||
|
||||
@@ -246,8 +246,8 @@
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { AssetPackageUsageRecord } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import type { PackageInfo } from '../types'
|
||||
|
||||
const {
|
||||
formatAmount,
|
||||
@@ -265,10 +265,10 @@
|
||||
)
|
||||
|
||||
interface Props {
|
||||
currentPackage: PackageInfo | null
|
||||
currentPackage: AssetPackageUsageRecord | null
|
||||
loading?: boolean
|
||||
errorMsg?: string
|
||||
boundDeviceId?: number
|
||||
boundDeviceId?: number | null
|
||||
boundDeviceNo?: string
|
||||
boundDeviceName?: string
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
<span class="field-value">
|
||||
<template v-if="item.type === 'change'">
|
||||
<span class="new-value">{{ formatFieldValue(item.key, item.afterValue) }}</span>
|
||||
<ElButton
|
||||
v-if="canDownloadLogFile && isDownloadableFileKey(item.key, item.afterValue)"
|
||||
link
|
||||
type="primary"
|
||||
@click.stop="handleDownloadLogFile(item.afterValue)"
|
||||
>
|
||||
下载文件
|
||||
</ElButton>
|
||||
</template>
|
||||
<span v-else :class="['message-value', item.level === 'error' ? 'is-error' : '']">
|
||||
{{ item.message }}
|
||||
@@ -106,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
@@ -118,13 +126,15 @@
|
||||
ElTableColumn,
|
||||
ElTag
|
||||
} from 'element-plus'
|
||||
import { AssetService } from '@/api/modules'
|
||||
import { AssetService, StorageService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { AssetOperationLogItem } from '@/types/api'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
|
||||
interface Props {
|
||||
assetIdentifier?: string
|
||||
assetType?: string
|
||||
downloadPermission?: string
|
||||
}
|
||||
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
@@ -285,10 +295,14 @@
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
const loading = ref(false)
|
||||
const logList = ref<OperationLogRow[]>([])
|
||||
const total = ref(0)
|
||||
const canDownloadLogFile = computed(() => {
|
||||
return props.downloadPermission ? hasAuth(props.downloadPermission) : false
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20
|
||||
@@ -369,6 +383,20 @@
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const isDownloadableFileKey = (key: string, value: unknown) => {
|
||||
return key === 'file_key' && typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
|
||||
const handleDownloadLogFile = async (fileKey: string) => {
|
||||
try {
|
||||
await StorageService.downloadFileByKey(fileKey)
|
||||
ElMessage.success('文件下载已开始')
|
||||
} catch (error) {
|
||||
console.error('下载日志文件失败:', error)
|
||||
ElMessage.error('下载文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getOperatorTypeText = (row: OperationLogRow) => {
|
||||
if (row.operator_type_code && operatorTypeMap[row.operator_type_code]) {
|
||||
return operatorTypeMap[row.operator_type_code]
|
||||
|
||||
@@ -165,20 +165,17 @@
|
||||
import { useAssetFormatters } from '../composables/useAssetFormatters'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import { AssetService } from '@/api/modules'
|
||||
|
||||
interface WalletInfo {
|
||||
balance: number
|
||||
available_balance: number
|
||||
frozen_balance: number
|
||||
status: number
|
||||
status_text?: string
|
||||
}
|
||||
import type {
|
||||
AssetWalletResponse,
|
||||
AssetWalletTransactionParams,
|
||||
TransactionType
|
||||
} from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
assetIdentifier: string
|
||||
walletInfo: WalletInfo | null
|
||||
walletInfo: AssetWalletResponse | null
|
||||
walletLoading?: boolean
|
||||
boundDeviceId?: number
|
||||
boundDeviceId?: number | null
|
||||
boundDeviceNo?: string
|
||||
}
|
||||
|
||||
@@ -200,16 +197,16 @@
|
||||
const total = ref(0)
|
||||
const pagination = ref({ page: 1, page_size: 20 })
|
||||
const filterForm = ref<{
|
||||
transaction_type: string | null
|
||||
transaction_type: TransactionType | null
|
||||
date_range: [Date, Date] | null
|
||||
}>({
|
||||
transaction_type: null,
|
||||
date_range: null
|
||||
})
|
||||
const queryParams = ref({
|
||||
const queryParams = ref<AssetWalletTransactionParams>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
transaction_type: null as string | null,
|
||||
transaction_type: null as TransactionType | null,
|
||||
start_time: null as string | null,
|
||||
end_time: null as string | null
|
||||
})
|
||||
|
||||
@@ -121,8 +121,8 @@ export function useAssetFormatters() {
|
||||
}
|
||||
|
||||
// 获取钱包状态标签类型
|
||||
const getWalletStatusType = (status?: number) => {
|
||||
const map: Record<number, string> = {
|
||||
const getWalletStatusType = (status?: number): TagType => {
|
||||
const map: Record<number, TagType> = {
|
||||
1: 'success',
|
||||
2: 'warning',
|
||||
3: 'danger'
|
||||
@@ -176,8 +176,8 @@ export function useAssetFormatters() {
|
||||
}
|
||||
|
||||
// 获取交易类型标签颜色
|
||||
const getTransactionTypeTag = (type: string | undefined) => {
|
||||
const map: Record<string, string> = {
|
||||
const getTransactionTypeTag = (type: string | undefined): TagType => {
|
||||
const map: Record<string, TagType> = {
|
||||
recharge: 'success',
|
||||
deduct: 'danger',
|
||||
refund: 'warning'
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<OperationLogsCard
|
||||
:asset-identifier="cardInfo.identifier"
|
||||
:asset-type="cardInfo.asset_type"
|
||||
download-permission="asset_info:download_log_file"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +135,7 @@
|
||||
/>
|
||||
<DailyRecordsDialog
|
||||
v-model="dailyRecordsDialogVisible"
|
||||
:package-usage-id="selectedPackageUsageId"
|
||||
:package-usage-id="selectedPackageUsageId ?? undefined"
|
||||
/>
|
||||
<OrderHistoryDialog
|
||||
v-model="orderHistoryDialogVisible"
|
||||
@@ -276,8 +277,7 @@
|
||||
slot_position: card.slot_position,
|
||||
carrier_name: card.carrier_name,
|
||||
msisdn: card.msisdn,
|
||||
network_status:
|
||||
card.network_status === undefined ? undefined : Number(card.network_status),
|
||||
network_status: card.network_status === undefined ? undefined : Number(card.network_status),
|
||||
real_name_status:
|
||||
card.real_name_status === undefined ? undefined : Number(card.real_name_status),
|
||||
is_current: card.is_current
|
||||
@@ -466,8 +466,7 @@
|
||||
/**
|
||||
* 更新实名状态成功
|
||||
*/
|
||||
const handleUpdateRealnameStatusSuccess = async () => {
|
||||
}
|
||||
const handleUpdateRealnameStatusSuccess = async () => {}
|
||||
|
||||
/**
|
||||
* 显示绑定卡更新实名状态对话框
|
||||
@@ -481,8 +480,7 @@
|
||||
/**
|
||||
* 绑定卡更新实名状态成功
|
||||
*/
|
||||
const handleBindingCardRealnameStatusSuccess = async () => {
|
||||
}
|
||||
const handleBindingCardRealnameStatusSuccess = async () => {}
|
||||
|
||||
/**
|
||||
* 确认实名认证策略
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
<ElOption
|
||||
v-if="iotCardList.length === 0 && !iotCardSearchLoading"
|
||||
label="未找到相关数据"
|
||||
:value="undefined"
|
||||
value=""
|
||||
disabled
|
||||
/>
|
||||
</ElSelect>
|
||||
@@ -498,6 +498,7 @@
|
||||
<OperationLogsDialog
|
||||
v-model="operationLogsDialogVisible"
|
||||
:identifier="operationLogsIdentifier"
|
||||
download-permission="device:download_log_file"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
@@ -867,7 +868,7 @@
|
||||
|
||||
// 分配表单
|
||||
const allocateForm = reactive({
|
||||
target_shop_id: undefined as number | undefined,
|
||||
target_shop_id: undefined as number[] | undefined,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
@@ -1467,9 +1468,15 @@
|
||||
if (valid) {
|
||||
allocateLoading.value = true
|
||||
try {
|
||||
const targetShopPath = allocateForm.target_shop_id
|
||||
if (!targetShopPath?.length) {
|
||||
ElMessage.warning('请选择目标店铺')
|
||||
return
|
||||
}
|
||||
|
||||
const data = {
|
||||
device_ids: selectedDevices.value.map((d) => d.id),
|
||||
target_shop_id: allocateForm.target_shop_id![allocateForm.target_shop_id.length - 1],
|
||||
target_shop_id: targetShopPath[targetShopPath.length - 1],
|
||||
remark: allocateForm.remark
|
||||
}
|
||||
const res = await DeviceService.allocateDevices(data)
|
||||
@@ -1890,8 +1897,7 @@
|
||||
slot_position: card.slot_position,
|
||||
carrier_name: card.carrier_name,
|
||||
msisdn: card.msisdn,
|
||||
network_status:
|
||||
card.network_status === undefined ? undefined : Number(card.network_status),
|
||||
network_status: card.network_status === undefined ? undefined : Number(card.network_status),
|
||||
real_name_status:
|
||||
card.real_name_status === undefined ? undefined : Number(card.real_name_status),
|
||||
is_current: card.is_current
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
>
|
||||
批量设置套餐系列
|
||||
</ElButton>
|
||||
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -516,6 +515,7 @@
|
||||
<OperationLogsDialog
|
||||
v-model="operationLogsDialogVisible"
|
||||
:identifier="operationLogsIdentifier"
|
||||
download-permission="iot_card:download_log_file"
|
||||
/>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="120"
|
||||
:actionsWidth="180"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -614,9 +614,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTaskFile = async (row: DeviceImportTask) => {
|
||||
const fileKey = row.file_name?.trim()
|
||||
if (!fileKey) {
|
||||
ElMessage.warning('当前任务没有可下载的原始文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StorageService.downloadFileByKey(fileKey)
|
||||
ElMessage.success('原始文件下载已开始')
|
||||
} catch (error) {
|
||||
console.error('下载原始文件失败:', error)
|
||||
ElMessage.error('下载原始文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: DeviceImportTask) => {
|
||||
const actions: any[] = []
|
||||
const showDownloadFileAction = false
|
||||
|
||||
if (showDownloadFileAction && row.file_name?.trim() && hasAuth('device_task:download_file')) {
|
||||
actions.push({
|
||||
label: '下载文件',
|
||||
handler: () => downloadTaskFile(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (row.fail_count > 0 && hasAuth('device_task:download_fail_data')) {
|
||||
actions.push({
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="120"
|
||||
:actionsWidth="180"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -237,6 +237,7 @@
|
||||
import { StorageService } from '@/api/modules/storage'
|
||||
import { generatePackageCode } from '@/utils/codeGenerator'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { CardCategory } from '@/types/api/card'
|
||||
import type { IotCardImportTask, IotCardImportTaskStatus } from '@/types/api/card'
|
||||
import type { Carrier } from '@/types/api'
|
||||
|
||||
@@ -253,7 +254,7 @@
|
||||
const uploading = ref(false)
|
||||
const importDialogVisible = ref(false)
|
||||
const selectedCarrierId = ref<number>()
|
||||
const selectedCardCategory = ref<'normal' | 'industry'>('normal') // 默认普通卡
|
||||
const selectedCardCategory = ref<CardCategory>(CardCategory.NORMAL) // 默认普通卡
|
||||
const carrierList = ref<Carrier[]>([])
|
||||
const carrierLoading = ref(false)
|
||||
const failDataDialogVisible = ref(false)
|
||||
@@ -697,7 +698,7 @@
|
||||
// 取消导入
|
||||
const handleCancelImport = () => {
|
||||
clearFiles()
|
||||
selectedCardCategory.value = 'normal'
|
||||
selectedCardCategory.value = CardCategory.NORMAL
|
||||
importForm.batch_no = ''
|
||||
importForm.realname_policy = ''
|
||||
importDialogVisible.value = false
|
||||
@@ -797,9 +798,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTaskFile = async (row: IotCardImportTask) => {
|
||||
const fileKey = row.file_name?.trim()
|
||||
if (!fileKey) {
|
||||
ElMessage.warning('当前任务没有可下载的原始文件')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StorageService.downloadFileByKey(fileKey)
|
||||
ElMessage.success('原始文件下载已开始')
|
||||
} catch (error) {
|
||||
console.error('下载原始文件失败:', error)
|
||||
ElMessage.error('下载原始文件失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取操作按钮
|
||||
const getActions = (row: IotCardImportTask) => {
|
||||
const actions: any[] = []
|
||||
const showDownloadFileAction = false
|
||||
|
||||
if (showDownloadFileAction && row.file_name?.trim() && hasAuth('iot_card_task:download_file')) {
|
||||
actions.push({
|
||||
label: '下载文件',
|
||||
handler: () => downloadTaskFile(row),
|
||||
type: 'primary'
|
||||
})
|
||||
}
|
||||
|
||||
if (row.fail_count > 0 && hasAuth('iot_card_task:download_fail_data')) {
|
||||
actions.push({
|
||||
|
||||
Reference in New Issue
Block a user