feat: 产品迭代7月份
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m51s

This commit is contained in:
luo
2026-07-27 16:30:29 +08:00
parent cc6fc9243e
commit f0a2b84e53
75 changed files with 2926 additions and 534 deletions

View File

@@ -9,6 +9,14 @@
</ElTag>
</div>
<div class="card-header-right">
<ElButton
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
type="primary"
link
@click="emit('showSpeedLimit')"
>
设置限速
</ElButton>
<ElTooltip content="开启后系统将自动轮询更新资产状态" placement="top">
<div v-if="canShowPolling" class="polling-switch-wrapper">
<span class="polling-label">自动轮询</span>
@@ -194,33 +202,6 @@
</template>
</ElDescriptions>
<ElDivider content-position="left">同步状态</ElDivider>
<ElDescriptions :column="descriptionsColumn" border>
<ElDescriptionsItem label="轮询状态">
<ElTag
v-if="cardInfo?.polling"
:type="cardInfo.polling.enabled ? 'success' : 'info'"
size="small"
>
{{ cardInfo.polling.enabled ? '已启用' : '未启用' }}
</ElTag>
<span v-else>-</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="最后活跃时间">
{{ formatDateTime(cardInfo?.polling?.last_activity_at) || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="同步轨迹">
<ElButton
v-permission="'asset_info:view_sync_trail'"
type="primary"
link
@click="emit('viewSyncTrail')"
>
查看同步轨迹
</ElButton>
</ElDescriptionsItem>
</ElDescriptions>
<template v-if="previousExchangeAsset || nextExchangeAsset">
<ElDivider content-position="left">换货链路</ElDivider>
<ElDescriptions :column="descriptionsColumn" border>
@@ -596,6 +577,7 @@
} from '@/types/api'
import { useAssetFormatters } from '../composables/useAssetFormatters'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import {
@@ -798,7 +780,6 @@
(e: 'navigateToCard', iccid: string): void
(e: 'navigateToDevice', deviceNo: string): void
(e: 'navigateToAsset', identifier: string): void
(e: 'viewSyncTrail'): void
}
const emit = defineEmits<Emits>()

View File

@@ -126,6 +126,7 @@
import { useUserStore } from '@/store/modules/user'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { hasVoucherKeys, toVoucherKeyList } from '@/utils/business'
import { normalizeApiError } from '@/utils/business/apiError'
interface Props {
modelValue: boolean
@@ -336,7 +337,11 @@
data.payment_voucher_key = toVoucherKeyList(form.payment_voucher_key)
}
await OrderService.createOrder(data)
const response = await OrderService.createOrder(data)
if (response.code !== 0) {
ElMessage.error(response.msg || '订单创建失败')
return
}
if (form.payment_method === 'wallet') {
ElMessage.success('订单创建成功,已自动完成支付')
@@ -352,6 +357,7 @@
return
}
console.error('创建订单失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}

View File

@@ -1,25 +1,10 @@
<template>
<ElDialog v-model="visible" title="设置限速" width="500px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="下行速率" prop="download_speed">
<ElInputNumber
v-model="form.download_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
</ElFormItem>
<ElFormItem label="上行速率" prop="upload_speed">
<ElInputNumber
v-model="form.upload_speed"
:min="1"
:step="128"
controls-position="right"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; color: #909399">单位: KB/s</div>
<ElFormItem label="固定档位" prop="code">
<ElSelect v-model="form.code" style="width: 100%">
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
@@ -31,7 +16,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElInputNumber, ElButton } from 'element-plus'
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
@@ -40,7 +25,7 @@
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { download_speed: number; upload_speed: number }): void
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
}
const props = defineProps<Props>()
@@ -49,14 +34,23 @@
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
download_speed: 1024,
upload_speed: 512
})
const speedTiers = [
{ code: -1, label: '不限速' },
{ code: 0, label: '0kbps' },
{ code: 1, label: '128Kbps' },
{ code: 2, label: '512Kbps' },
{ code: 3, label: '1Mbps' },
{ code: 4, label: '2Mbps' },
{ code: 5, label: '10Mbps' },
{ code: 6, label: '20Mbps' },
{ code: 7, label: '50Mbps' },
{ code: 8, label: '100Mbps' }
] as const
const form = reactive({ code: 3 as (typeof speedTiers)[number]['code'] })
const rules: FormRules = {
download_speed: [{ required: true, message: '请输入下行速率', trigger: 'blur' }],
upload_speed: [{ required: true, message: '请输入上行速率', trigger: 'blur' }]
code: [{ required: true, message: '请选择限速档位', trigger: 'change' }]
}
const visible = computed({
@@ -67,8 +61,7 @@
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.download_speed = 1024
form.upload_speed = 512
form.code = 3
}
})
@@ -82,8 +75,7 @@
try {
await formRef.value.validate()
emit('confirm', {
download_speed: form.download_speed,
upload_speed: form.upload_speed
code: form.code
})
} catch (error) {
console.error('表单验证失败:', error)

View File

@@ -16,7 +16,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
const manualDeactivateCardLoading = ref(false)
const rebootDeviceLoading = ref(false)
const resetDeviceLoading = ref(false)
const speedLimitLoading = ref(false)
const switchCardLoading = ref(false)
const setWiFiLoading = ref(false)
const pollingLoading = ref(false)
@@ -181,35 +180,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
}
}
/**
* Set device speed limit
*/
const setSpeedLimit = async (form: { download_speed: number; upload_speed: number }) => {
try {
speedLimitLoading.value = true
const res = await DeviceService.setSpeedLimit(cardInfo.value.imei, {
download_speed: form.download_speed,
upload_speed: form.upload_speed
})
if (res.code === 0) {
ElMessage.success('限速设置成功')
if (refreshAssetFn) {
await refreshAssetFn()
}
return true
} else {
ElMessage.error(res.msg || '设置失败')
return false
}
} catch (error: any) {
console.error('设置限速失败:', error)
console.log(error?.message || '设置失败')
return false
} finally {
speedLimitLoading.value = false
}
}
/**
* Switch to different card
*/
@@ -320,7 +290,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
manualDeactivateCardLoading,
rebootDeviceLoading,
resetDeviceLoading,
speedLimitLoading,
switchCardLoading,
setWiFiLoading,
pollingLoading,
@@ -333,7 +302,6 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
// Device operations
rebootDevice,
resetDevice,
setSpeedLimit,
switchCard,
setWiFi,

View File

@@ -36,7 +36,8 @@
@show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@@ -44,7 +45,6 @@
@navigate-to-asset="handleNavigateToAsset"
@navigate-to-card="handleNavigateToCard"
@navigate-to-device="handleNavigateToDevice"
@view-sync-trail="handleViewSyncTrail"
/>
</div>
@@ -174,6 +174,7 @@
:current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess"
/>
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
</div>
</template>
@@ -182,7 +183,7 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
import { RoutesAlias } from '@/router/routesAlias'
import { DeviceService } from '@/api/modules'
import { CardService, DeviceService } from '@/api/modules'
import {
formatRemainingTime,
FrontendRateLimitError,
@@ -203,8 +204,9 @@
SwitchModeDialog,
WiFiConfigDialog,
PackageRechargeDialog,
DailyRecordsDialog,
OrderHistoryDialog
DailyRecordsDialog,
OrderHistoryDialog,
SpeedLimitDialog
} from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -291,6 +293,7 @@
// 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false)
const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0)
@@ -444,18 +447,6 @@
}
}
const handleViewSyncTrail = () => {
if (!cardInfo.value) return
router.push({
path: '/audit/integrations',
query: {
resource_type: cardInfo.value.asset_type,
resource_key: cardInfo.value.identifier
}
})
}
/**
* IoT卡操作 - 启用卡
*/
@@ -814,6 +805,15 @@
handleSearch({ identifier })
}
}
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!cardInfo.value?.iccid) return
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
if (response.code === 0) {
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
speedLimitDialogVisible.value = false
await handleRefresh()
}
}
</script>
<style lang="scss" scoped>

View File

@@ -2299,7 +2299,8 @@
return
}
ElMessage.success('批量修改实名顺序成功')
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = []
await getTableData()

View File

@@ -83,6 +83,11 @@
formatter: (_, data) =>
data.flow_type_name || (flowType === 'direct' ? '直接换货' : '物流换货')
},
{
label: '提交人',
formatter: (value) => value || '--',
prop: 'submitter_name'
},
{
label: '换货原因',
prop: 'exchange_reason',

View File

@@ -380,7 +380,13 @@
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ExchangeService, CardService, DeviceService } from '@/api/modules'
import type { ExchangeResponse } from '@/api/modules/exchange'
import type {
CreateExchangeRequest,
ExchangeAssetType,
ExchangeFlowType,
ExchangeQueryParams,
ExchangeResponse
} from '@/api/modules/exchange'
import type { StandaloneIotCard, Device } from '@/types/api'
import { ElMessage, ElTag, ElButton, ElMessageBox, ElSwitch } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -422,7 +428,7 @@
]
// 搜索表单
const searchForm = reactive({
const searchForm = reactive<ExchangeQueryParams & { created_at_range: string[] }>({
status: undefined,
flow_type: undefined, // 流程类型筛选
old_asset_keyword: '',
@@ -433,7 +439,16 @@
})
// 创建换货单表单
const createForm = reactive({
const createForm = reactive<{
exchange_reason: string
exchange_reason_other: string
old_asset_type: ExchangeAssetType | ''
old_identifier: string
flow_type: ExchangeFlowType
new_identifier: string
migrate_data: boolean
remark: string
}>({
exchange_reason: '',
exchange_reason_other: '',
old_asset_type: '',
@@ -819,7 +834,7 @@
loading.value = true
try {
// 过滤掉空值参数
const params: any = {
const params: ExchangeQueryParams = {
page: pagination.page,
page_size: pagination.page_size
}
@@ -1328,9 +1343,9 @@
? createForm.exchange_reason_other.trim()
: createForm.exchange_reason
const requestData: any = {
const requestData: CreateExchangeRequest = {
exchange_reason: exchangeReason,
old_asset_type: createForm.old_asset_type,
old_asset_type: createForm.old_asset_type as ExchangeAssetType,
old_identifier: createForm.old_identifier,
flow_type: createForm.flow_type,
remark: createForm.remark || undefined
@@ -1351,7 +1366,7 @@
}
ElMessage.error(res.msg || '换货单创建失败')
} catch (error: any) {
} catch (error) {
console.error('创建换货单失败:', error)
} finally {
createLoading.value = false

View File

@@ -0,0 +1,364 @@
<template>
<ArtTableFullScreen>
<div class="expiring-assets-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="true"
label-width="85"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<div class="page-intro"> 仅展示可预计且尚未过期的资产0-3 天资产优先显示 </div>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="asset_id"
:loading="loading"
:data="items"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="140"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElTag } from 'element-plus'
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
import type {
AssetType,
ExpiringAssetItem,
ExpiringAssetQueryParams,
PackageResponse,
ShopResponse
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { RoutesAlias } from '@/router/routesAlias'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'ExpiringAssets' })
const router = useRouter()
const route = useRoute()
const tableRef = ref()
const loading = ref(false)
const shopLoading = ref(false)
const packageLoading = ref(false)
const shopOptions = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([])
const items = ref<ExpiringAssetItem[]>([])
const searchForm = reactive<ExpiringAssetQueryParams>({
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
})
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '资产类型',
prop: 'asset_type',
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: [
{ label: '网卡', value: 'card' },
{ label: '设备', value: 'device' }
]
},
{
label: '关键词',
prop: 'keyword',
type: 'input',
config: { clearable: true, placeholder: '请输入资产标识' }
},
{
label: '店铺',
prop: 'shop_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: shopLoading.value,
remoteMethod: (query: string) => searchShops(query),
placeholder: '请选择或搜索店铺'
},
options: () => shopOptions.value.map((shop) => ({ label: shop.shop_name, value: shop.id }))
},
{
label: '套餐',
prop: 'package_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
loading: packageLoading.value,
remoteMethod: (query: string) => searchPackages(query),
placeholder: '请选择或搜索套餐'
},
options: () =>
packageOptions.value.map((pkg) => ({
label: `${pkg.package_name} (${pkg.package_code})`,
value: pkg.id
}))
},
{
label: '最小剩余天数',
prop: 'days_min',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
},
{
label: '最大剩余天数',
prop: 'days_max',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
},
{
label: '预计到期起始',
prop: 'expires_from',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
},
{
label: '预计到期结束',
prop: 'expires_to',
type: 'date',
config: { valueFormat: 'YYYY-MM-DD', clearable: true }
}
])
const columnOptions = [
{ label: '资产类型', prop: 'asset_type' },
{ label: '资产标识', prop: 'identifier' },
{ label: '店铺', prop: 'shop_name' },
{ label: '当前套餐', prop: 'package_name' },
{ label: '预计最终到期', prop: 'estimated_final_expires_at' },
{ label: '剩余天数', prop: 'days_until_final_expiry' },
{ label: '临期等级', prop: 'expiry_level_name' }
]
const getExpiryClass = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'expiry-critical'
if (level === 'warning' || level === '4_7') return 'expiry-warning'
if (level === 'notice' || level === '8_15') return 'expiry-notice'
return ''
}
const getExpiryTagType = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'danger'
if (level === 'warning' || level === '4_7') return 'warning'
return 'info'
}
const searchShops = async (query = '') => {
shopLoading.value = true
try {
const response = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (response.code === 0) shopOptions.value = response.data.items || []
} finally {
shopLoading.value = false
}
}
const searchPackages = async (query = '') => {
packageLoading.value = true
try {
const response = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query || undefined
})
if (response.code === 0) packageOptions.value = response.data.items || []
} finally {
packageLoading.value = false
}
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
},
{
prop: 'identifier',
label: '资产标识',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.identifier || '-'
},
{
prop: 'shop_name',
label: '店铺',
minWidth: 140,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.shop_name || '-'
},
{
prop: 'package_name',
label: '当前套餐',
minWidth: 160,
showOverflowTooltip: true,
formatter: (row: ExpiringAssetItem) => row.package_name || '-'
},
{
prop: 'estimated_final_expires_at',
label: '预计最终到期',
width: 180,
formatter: (row: ExpiringAssetItem) =>
h(
'span',
{ class: getExpiryClass(row.expiry_level) },
formatDateTime(row.estimated_final_expires_at)
)
},
{
prop: 'days_until_final_expiry',
label: '剩余天数',
width: 100,
formatter: (row: ExpiringAssetItem) =>
h(
ElTag,
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
() => `${row.days_until_final_expiry}`
)
},
{
prop: 'expiry_level_name',
label: '临期等级',
width: 120,
formatter: (row: ExpiringAssetItem) => row.expiry_level_name || '-'
}
])
const loadAssets = async () => {
loading.value = true
try {
const response = await AssetService.getExpiringAssets({
...searchForm,
page: pagination.currentPage,
size: pagination.pageSize
})
if (response.code === 0 && response.data) {
items.value = response.data.items || []
pagination.total = response.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.currentPage = 1
void loadAssets()
}
const handleReset = () => {
Object.assign(searchForm, {
asset_type: undefined,
keyword: '',
shop_id: undefined,
package_id: undefined,
days_min: undefined,
days_max: undefined,
expires_from: undefined,
expires_to: undefined
})
pagination.currentPage = 1
void loadAssets()
}
const handleRefresh = () => void loadAssets()
const handleSizeChange = (value: number) => {
pagination.pageSize = value
void loadAssets()
}
const handleCurrentChange = (value: number) => {
pagination.currentPage = value
void loadAssets()
}
const goToAsset = (row: ExpiringAssetItem) => {
router.push({
path: RoutesAlias.AssetInformation,
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
})
}
const getActions = (row: ExpiringAssetItem) => [
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
...(row.can_renew
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
: [])
]
onMounted(() => {
const assetType = route.query.asset_type
if (assetType === 'card' || assetType === 'device')
searchForm.asset_type = assetType as AssetType
void Promise.all([loadAssets(), searchShops(), searchPackages()])
})
</script>
<style scoped lang="scss">
.expiring-assets-page {
.page-intro {
color: var(--el-text-color-secondary);
}
.expiry-critical,
.expiry-warning,
.expiry-notice {
font-weight: 600;
}
.expiry-critical {
color: #f56c6c;
}
.expiry-warning {
color: #8e44ad;
}
.expiry-notice {
color: #e83e8c;
}
}
</style>

View File

@@ -2448,7 +2448,8 @@
return
}
ElMessage.success('批量修改实名顺序成功')
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedCards.value = []
await getTableData()

View File

@@ -1,6 +1,6 @@
<template>
<ArtTableFullScreen>
<div class="device-task-page" id="table-full-screen">
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
@@ -10,6 +10,16 @@
@search="handleSearch"
></ArtSearchBar>
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert
type="warning"
:closable="false"
show-icon
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
/>
<ElButton size="small" @click="polling.retry">重试</ElButton>
</div>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
@@ -19,10 +29,11 @@
>
<template #left>
<ElButton
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="'device_task:bulk_import'"
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
>
批量导入设备
</ElButton>
@@ -57,8 +68,8 @@
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 Excel 模板文件按照模板格式填写设备信息</p>
<p>2. 仅支持 Excel 格式.xlsx单次最多导入 1000 </p>
<p>1. 设备分配使用单列 UTF-8 CSV导入设备仍使用 Excel 模板</p>
<p>2. CSV 单次最多 1000 文件不超过 10MB</p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. <strong>重要列顺序固定不可调整</strong>系统按位置读取不识别列名</p>
<p style="color: var(--el-color-primary)">5. 必填列虚拟号第1列</p>
@@ -77,7 +88,23 @@
</ElButton>
</div>
<ElRow :gutter="20">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="任务类型">
<ElSelect v-model="importForm.operation_type" style="width: 100%">
<ElOption label="导入设备" value="import" />
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol v-if="importForm.operation_type !== 'import'" :span="12">
<ElFormItem label="目标 ID">
<ElInput v-model="importForm.target_id" placeholder="代理店铺或套餐系列 ID" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no">
<ElInput
@@ -108,18 +135,18 @@
</ElCol>
</ElRow>
<ElUpload
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx"
>
accept=".xlsx,.csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 300MB</div>
<div class="el-upload__tip">导入设备支持 .xlsx批量分配支持单列 .csv</div>
</template>
</ElUpload>
@@ -140,8 +167,8 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
@@ -149,16 +176,29 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
import type {
DeviceImportTask,
DeviceImportTaskDetail,
DeviceImportTaskQueryParams,
DeviceImportTaskStatus,
RealnamePolicy
} from '@/types/api/device'
import { RoutesAlias } from '@/router/routesAlias'
import { generatePackageCode } from '@/utils/codeGenerator'
import { JULY_PERMISSIONS } from '@/config/constants'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceTask' })
const router = useRouter()
const route = useRoute()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const tableRef = ref()
@@ -168,14 +208,16 @@
const importDialogVisible = ref(false)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
target_id: ''
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
batch_no: '',
dateRange: undefined as any,
dateRange: undefined as string[] | undefined,
start_time: '',
end_time: ''
}
@@ -247,6 +289,23 @@
const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task',
autoRestore: false,
fetchTask: async (taskId) => {
const res = await DeviceService.getImportTaskDetail(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
return res.data
},
isForbidden: (error) => {
const response = (error as { response?: { status?: number; data?: { code?: number } } })
?.response
return response?.status === 403 || response?.data?.code === 403
}
})
const pollingError = polling.error
const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) {
@@ -265,6 +324,7 @@
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
router.push({
path: RoutesAlias.TaskDetail,
query: {
@@ -276,7 +336,7 @@
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => {
if (hasAuth('device_task:view_detail')) {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
@@ -385,15 +445,31 @@
}
])
watch(polling.task, () => {
if (isPlatformAccount.value) void getTableData()
})
onMounted(() => {
getTableData()
if (!isPlatformAccount.value) return
void getTableData()
const routeTaskId = Number(route.query.task_id)
if (routeTaskId) {
void polling.start(routeTaskId)
} else if (polling.taskId.value) {
void polling.retry()
}
})
// 获取设备任务列表
const getTableData = async () => {
if (!isPlatformAccount.value) {
taskList.value = []
pagination.total = 0
return
}
loading.value = true
try {
const params: any = {
const params: DeviceImportTaskQueryParams = {
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
@@ -406,13 +482,6 @@
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
@@ -422,6 +491,7 @@
}
} catch (error) {
console.error(error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}
@@ -473,8 +543,9 @@
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 300 * 1024 * 1024
const handleFileChange = async (uploadFile: any) => {
const isCsvAllocation = importForm.operation_type !== 'import'
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
@@ -482,13 +553,23 @@
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (isCsvAllocation && uploadFile.raw) {
const rows = (await uploadFile.raw.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
if (rows.length > 1001 || rows.some((row: string) => row.includes(','))) {
ElMessage.error('设备分配 CSV 必须是单列且最多 1000 行数据')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
}
@@ -510,24 +591,33 @@
clearFiles()
importForm.batch_no = ''
importForm.realname_policy = ''
importForm.operation_type = 'import'
importForm.target_id = ''
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择 Excel 文件')
return
}
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return
if (!fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
ElMessage.warning('请输入有效的目标 ID')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
const isAllocation = importForm.operation_type !== 'import'
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
})
if (uploadUrlRes.code !== 0) {
@@ -538,18 +628,20 @@
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(
upload_url,
file,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
await StorageService.uploadFile(upload_url, file, contentType)
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...')
const importRes = isAllocation
? await DeviceService.createAllocationTask({
file_key,
operation_type: importForm.operation_type as 'assign_shop' | 'assign_series',
target_id: Number(importForm.target_id)
})
: await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
@@ -557,8 +649,11 @@
}
const taskNo = importRes.data.task_no
const taskId = importRes.data.task_id
handleCancelImport()
await router.replace({ path: route.path, query: { task_id: String(taskId) } })
await polling.start(taskId)
await getTableData()
ElMessage.success({
@@ -568,7 +663,7 @@
})
} catch (error: any) {
console.error('设备导入失败:', error)
console.log(error.message || '设备导入失败')
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
} finally {
uploading.value = false
}
@@ -576,6 +671,7 @@
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
@@ -622,6 +718,7 @@
}
const downloadTaskFile = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
const fileKey = row.file_name?.trim()
if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件')
@@ -639,6 +736,7 @@
// 获取操作按钮
const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return []
const actions: any[] = []
const showDownloadFileAction = false
@@ -664,6 +762,13 @@
<style lang="scss" scoped>
.device-task-page {
.task-polling-error {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;

View File

@@ -59,11 +59,14 @@
import type { DeviceImportTaskDetail } from '@/types/api/device'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import DetailPage from '@/components/common/DetailPage.vue'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'TaskDetail' })
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
type TaskType = 'card' | 'device'
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
@@ -244,6 +247,12 @@
taskType.value = queryTaskType
}
if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情')
goBack()
return
}
loading.value = true
try {
if (taskType.value === 'device') {