feat: 文件下载
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 3m54s

This commit is contained in:
sexygoat
2026-04-30 18:04:01 +08:00
parent 81155fd8e4
commit 950520635d
19 changed files with 438 additions and 44 deletions

View File

@@ -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}`)
}
// ========== 单卡列表(未绑定设备)相关 ==========

View File

@@ -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))
}
}

View File

@@ -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>()

View File

@@ -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加油包时有值

View File

@@ -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 // 激活时间 (可选)

View File

@@ -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 // 总数

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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
})

View File

@@ -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'

View File

@@ -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 () => {}
/**
* 确认实名认证策略

View File

@@ -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

View File

@@ -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>

View File

@@ -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({

View File

@@ -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({