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

This commit is contained in:
luo
2026-07-28 14:00:22 +08:00
parent f0a2b84e53
commit 2706c52a47
63 changed files with 4113 additions and 3945 deletions

1520
q

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ import type {
AssetPackageUsageRecord, AssetPackageUsageRecord,
UpdateAssetPackageUsedDataRequest, UpdateAssetPackageUsedDataRequest,
UpdateAssetPackageExpiresAtRequest, UpdateAssetPackageExpiresAtRequest,
ExpiringAssetItem, ExpiringAssetListResponse,
ExpiringAssetQueryParams ExpiringAssetQueryParams
} from '@/types/api' } from '@/types/api'
@@ -53,12 +53,8 @@ export class AssetService extends BaseService {
*/ */
static getExpiringAssets( static getExpiringAssets(
params?: ExpiringAssetQueryParams params?: ExpiringAssetQueryParams
): Promise< ): Promise<BaseResponse<ExpiringAssetListResponse>> {
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }> return this.get<BaseResponse<ExpiringAssetListResponse>>('/api/admin/expiring-assets', params)
> {
return this.get<
BaseResponse<{ items: ExpiringAssetItem[]; total: number; page: number; size: number }>
>('/api/admin/expiring-assets', params)
} }
/** /**
* 通过任意标识符查询设备或卡的完整详情 * 通过任意标识符查询设备或卡的完整详情

View File

@@ -66,7 +66,8 @@ export class CardService extends BaseService {
): Promise<BaseResponse<SetSpeedTierResponse>> { ): Promise<BaseResponse<SetSpeedTierResponse>> {
return this.put<BaseResponse<SetSpeedTierResponse>>( return this.put<BaseResponse<SetSpeedTierResponse>>(
`/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`, `/api/admin/iot-cards/${encodeURIComponent(iccid)}/speed-tier`,
{ code } { code },
{ requestOptions: { errorMessageMode: 'none' } }
) )
} }
// ========== 号卡商品管理 ========== // ========== 号卡商品管理 ==========

View File

@@ -1,9 +1,7 @@
import { BaseService } from '../BaseService' import { BaseService } from '../BaseService'
import type { import type {
CancelExportTaskApiResponse,
CreateExportTaskApiResponse, CreateExportTaskApiResponse,
CreateExportTaskRequest, CreateExportTaskRequest,
ExportTaskDetail,
ExportTaskDetailApiResponse, ExportTaskDetailApiResponse,
ExportTaskListApiResponse, ExportTaskListApiResponse,
ExportTaskQueryParams ExportTaskQueryParams
@@ -21,9 +19,4 @@ export class ExportTaskService extends BaseService {
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> { static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
return this.get<ExportTaskDetailApiResponse>(`/api/admin/export-tasks/${id}`) return this.get<ExportTaskDetailApiResponse>(`/api/admin/export-tasks/${id}`)
} }
/** 保留旧页面的取消入口,后端支持时由页面权限控制显示。 */
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
}
} }

View File

@@ -3,6 +3,7 @@ import type {
BaseResponse, BaseResponse,
NotificationListResponse, NotificationListResponse,
NotificationQueryParams, NotificationQueryParams,
NotificationReadRequest,
NotificationReadAllRequest, NotificationReadAllRequest,
NotificationReadAllResponse, NotificationReadAllResponse,
NotificationReadResponse, NotificationReadResponse,
@@ -29,10 +30,9 @@ export class NotificationService extends BaseService {
} }
static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> { static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> {
return this.put<BaseResponse<NotificationReadResponse>>( return this.put<BaseResponse<NotificationReadResponse>>(`/api/admin/notifications/${id}/read`, {
`/api/admin/notifications/${id}/read`, id
{} } satisfies NotificationReadRequest)
)
} }
static markAllRead( static markAllRead(

View File

@@ -7,6 +7,7 @@ import type {
WecomApplicationQueryParams, WecomApplicationQueryParams,
WecomApplicationRequest, WecomApplicationRequest,
WecomApplicationResponse, WecomApplicationResponse,
WecomBusinessFieldListResponse,
WecomBusinessType, WecomBusinessType,
WecomMemberListResponse, WecomMemberListResponse,
WecomMemberQueryParams, WecomMemberQueryParams,
@@ -14,7 +15,9 @@ import type {
WecomSceneQueryParams, WecomSceneQueryParams,
WecomSceneRequest, WecomSceneRequest,
WecomSceneResponse, WecomSceneResponse,
WecomSyncMembersApiResponse WecomSyncMembersApiResponse,
WecomTemplateDetailResponse,
WecomTemplateInspectRequest
} from '@/types/api' } from '@/types/api'
export class WecomService extends BaseService { export class WecomService extends BaseService {
@@ -38,14 +41,8 @@ export class WecomService extends BaseService {
) )
} }
static getMembers( static getMembers(id: number, params?: WecomMemberQueryParams): Promise<WecomMemberListResponse> {
id: number, return this.get<WecomMemberListResponse>(`/api/admin/wecom/applications/${id}/members`, params)
params?: WecomMemberQueryParams
): Promise<WecomMemberListResponse> {
return this.get<WecomMemberListResponse>(
`/api/admin/wecom/applications/${id}/members`,
params
)
} }
static setDefaultCreator(id: number, userid: string): Promise<WecomApplicationResponse> { static setDefaultCreator(id: number, userid: string): Promise<WecomApplicationResponse> {
@@ -66,6 +63,24 @@ export class WecomService extends BaseService {
return this.put<WecomSceneResponse>(`/api/admin/wecom/scenes/${businessType}`, data) return this.put<WecomSceneResponse>(`/api/admin/wecom/scenes/${businessType}`, data)
} }
static inspectTemplate(
applicationId: number,
data: WecomTemplateInspectRequest
): Promise<WecomTemplateDetailResponse> {
return this.post<WecomTemplateDetailResponse>(
`/api/admin/wecom/applications/${applicationId}/templates/inspect`,
data
)
}
static getBusinessFields(
businessType: WecomBusinessType
): Promise<WecomBusinessFieldListResponse> {
return this.get<WecomBusinessFieldListResponse>(
`/api/admin/wecom/scenes/${businessType}/fields`
)
}
static bindAccount( static bindAccount(
accountId: number, accountId: number,
data: WecomAccountBindingRequest data: WecomAccountBindingRequest

View File

@@ -97,6 +97,7 @@
modelValue: boolean modelValue: boolean
initialOrderId?: number initialOrderId?: number
initialOrderNo?: string initialOrderNo?: string
initialPackageUsageId?: number
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -119,6 +120,7 @@
const formData = reactive<{ const formData = reactive<{
order_id: number | null order_id: number | null
package_usage_id?: number
requested_refund_amount?: number requested_refund_amount?: number
actual_received_amount: number actual_received_amount: number
refund_reason: string refund_reason: string
@@ -127,6 +129,7 @@
attachments: RefundAttachment[] attachments: RefundAttachment[]
}>({ }>({
order_id: null, order_id: null,
package_usage_id: undefined,
requested_refund_amount: undefined, requested_refund_amount: undefined,
actual_received_amount: 0, actual_received_amount: 0,
refund_reason: '', refund_reason: '',
@@ -213,6 +216,7 @@
formRef.value?.resetFields() formRef.value?.resetFields()
voucherUploading.value = false voucherUploading.value = false
formData.order_id = null formData.order_id = null
formData.package_usage_id = undefined
formData.requested_refund_amount = undefined formData.requested_refund_amount = undefined
formData.actual_received_amount = 0 formData.actual_received_amount = 0
formData.refund_reason = '' formData.refund_reason = ''
@@ -239,6 +243,7 @@
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0, requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0,
actual_received_amount: formData.actual_received_amount, actual_received_amount: formData.actual_received_amount,
attachments: formData.attachments, attachments: formData.attachments,
package_usage_id: formData.package_usage_id,
refund_reason: formData.refund_reason || undefined, refund_reason: formData.refund_reason || undefined,
remark: formData.remark || undefined remark: formData.remark || undefined
} }
@@ -261,6 +266,7 @@
if (hasInitialOrder.value && props.initialOrderId) { if (hasInitialOrder.value && props.initialOrderId) {
formData.order_id = props.initialOrderId formData.order_id = props.initialOrderId
} }
formData.package_usage_id = props.initialPackageUsageId
if (hasInitialOrder.value && props.initialOrderNo) { if (hasInitialOrder.value && props.initialOrderNo) {
searchOrders(props.initialOrderNo) searchOrders(props.initialOrderNo)
} else { } else {

View File

@@ -85,13 +85,22 @@
} }
) )
const getNormalizedQuery = () => { const getNormalizedQuery = (): Record<string, string> => {
const query: Record<string, unknown> = {} const query: Record<string, string> = {}
Object.entries(props.query || {}).forEach(([key, value]) => { Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return if (Array.isArray(value) && value.length === 0) return
query[key] = value
if (typeof value === 'string') {
query[key] = value
} else if (typeof value === 'number' || typeof value === 'boolean') {
query[key] = String(value)
} else if (Array.isArray(value)) {
query[key] = value.join(',')
} else {
query[key] = JSON.stringify(value)
}
}) })
return query return query

View File

@@ -40,7 +40,7 @@
<span class="text"> <span class="text">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small>{{ item.body }}</small> <small>{{ item.body }}</small>
<time>{{ item.created_at || '-' }}</time> <time>{{ formatDateTime(item.created_at) }}</time>
</span> </span>
<span v-if="!isRead(item)" class="unread-dot" /> <span v-if="!isRead(item)" class="unread-dot" />
</button> </button>
@@ -50,9 +50,6 @@
<p>暂无通知</p> <p>暂无通知</p>
</div> </div>
</div> </div>
<div class="btn-wrapper">
<ElButton class="view-all" @click="handleViewAll">进入通知中心</ElButton>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -68,6 +65,7 @@
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification' import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation' import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
import { formatDateTime } from '@/utils/business/format'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -108,11 +106,6 @@
const close = () => emit('update:modelValue', false) const close = () => emit('update:modelValue', false)
const handleViewAll = () => {
close()
void router.push('/notifications')
}
const handleMarkAllRead = async () => { const handleMarkAllRead = async () => {
try { try {
const response = await notificationStore.markAllRead() const response = await notificationStore.markAllRead()

View File

@@ -158,15 +158,6 @@
font-size: 42px; font-size: 42px;
} }
} }
.btn-wrapper {
padding: 12px 16px 16px;
border-top: 1px solid var(--art-border-color);
.view-all {
width: 100%;
}
}
} }
@media only screen and (max-width: $device-phone) { @media only screen and (max-width: $device-phone) {

View File

@@ -3,7 +3,6 @@ import type { ExportTaskScene } from '@/types/api'
export interface ExportTaskScenePermissions { export interface ExportTaskScenePermissions {
detail: string detail: string
download: string download: string
cancel: string
} }
export interface ExportTaskSceneConfig { export interface ExportTaskSceneConfig {
@@ -20,8 +19,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出设备', pageTitle: '导出设备',
permissions: { permissions: {
detail: 'export_task:device_detail', detail: 'export_task:device_detail',
download: 'export_task:device_download', download: 'export_task:device_download'
cancel: 'export_task:device_cancel'
} }
}, },
iot_card: { iot_card: {
@@ -30,8 +28,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出IOT卡', pageTitle: '导出IOT卡',
permissions: { permissions: {
detail: 'export_task:iot_card_detail', detail: 'export_task:iot_card_detail',
download: 'export_task:iot_card_download', download: 'export_task:iot_card_download'
cancel: 'export_task:iot_card_cancel'
} }
}, },
order: { order: {
@@ -40,8 +37,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出订单', pageTitle: '导出订单',
permissions: { permissions: {
detail: 'export_task:order_detail', detail: 'export_task:order_detail',
download: 'export_task:order_download', download: 'export_task:order_download'
cancel: 'export_task:order_cancel'
} }
}, },
package: { package: {
@@ -50,8 +46,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出套餐', pageTitle: '导出套餐',
permissions: { permissions: {
detail: 'export_task:package_detail', detail: 'export_task:package_detail',
download: 'export_task:package_download', download: 'export_task:package_download'
cancel: 'export_task:package_cancel'
} }
}, },
agent_wallet_transaction: { agent_wallet_transaction: {
@@ -60,8 +55,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出代理主钱包流水', pageTitle: '导出代理主钱包流水',
permissions: { permissions: {
detail: 'export_task:agent_wallet_transaction_detail', detail: 'export_task:agent_wallet_transaction_detail',
download: 'export_task:agent_wallet_transaction_download', download: 'export_task:agent_wallet_transaction_download'
cancel: 'export_task:agent_wallet_transaction_cancel'
} }
}, },
agent_recharge: { agent_recharge: {
@@ -70,8 +64,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出代理充值', pageTitle: '导出代理充值',
permissions: { permissions: {
detail: 'export_task:agent_recharge_detail', detail: 'export_task:agent_recharge_detail',
download: 'export_task:agent_recharge_download', download: 'export_task:agent_recharge_download'
cancel: 'export_task:agent_recharge_cancel'
} }
}, },
refund: { refund: {
@@ -80,8 +73,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出退款', pageTitle: '导出退款',
permissions: { permissions: {
detail: 'export_task:refund_detail', detail: 'export_task:refund_detail',
download: 'export_task:refund_download', download: 'export_task:refund_download'
cancel: 'export_task:refund_cancel'
} }
}, },
exchange: { exchange: {
@@ -90,8 +82,7 @@ export const EXPORT_TASK_SCENE_CONFIG: Record<ExportTaskScene, ExportTaskSceneCo
pageTitle: '导出换货', pageTitle: '导出换货',
permissions: { permissions: {
detail: 'export_task:exchange_detail', detail: 'export_task:exchange_detail',
download: 'export_task:exchange_download', download: 'export_task:exchange_download'
cancel: 'export_task:exchange_cancel'
} }
} }
} }

View File

@@ -22,6 +22,9 @@ export const JULY_PERMISSIONS = {
create: 'device_task:allocation:create', create: 'device_task:allocation:create',
detail: 'device_task:allocation:detail' detail: 'device_task:allocation:detail'
}, },
seriesGrants: {
updateExpiryBase: 'series_grants:update_expiry_base'
},
batchPurchase: { batchPurchase: {
page: 'bulk_purchase:view', page: 'bulk_purchase:view',
create: 'bulk_purchase:create', create: 'bulk_purchase:create',

View File

@@ -432,6 +432,9 @@
"standaloneCardList": "IoT Card Management", "standaloneCardList": "IoT Card Management",
"iotCardTask": "IoT Card Tasks", "iotCardTask": "IoT Card Tasks",
"deviceTask": "Device Tasks", "deviceTask": "Device Tasks",
"deviceBatchAllocation": "Device Batch Allocation",
"bulkPurchase": "Bulk Package Purchase",
"bulkPurchaseDetail": "Bulk Purchase Task Detail",
"orderPackageInvalidateTask": "Order Package Void Tasks", "orderPackageInvalidateTask": "Order Package Void Tasks",
"orderPackageInvalidateTaskDetail": "Order Package Void Task Detail", "orderPackageInvalidateTaskDetail": "Order Package Void Task Detail",
"taskDetail": "Task Details", "taskDetail": "Task Details",
@@ -452,6 +455,11 @@
"exportDevice": "Export Devices", "exportDevice": "Export Devices",
"exportIotCard": "Export IOT Cards", "exportIotCard": "Export IOT Cards",
"exportOrder": "Export Orders", "exportOrder": "Export Orders",
"exportPackage": "Export Packages",
"exportAgentWalletTransaction": "Export Agent Wallet Transactions",
"exportRefund": "Export Refunds",
"exportAgentRecharge": "Export Agent Recharges",
"exportExchange": "Export Exchanges",
"exportTaskDetail": "Export Task Detail", "exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management", "exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail" "exchangeDetail": "Exchange Order Detail"

View File

@@ -370,6 +370,9 @@
"standaloneCardList": "IoT卡管理", "standaloneCardList": "IoT卡管理",
"iotCardTask": "IoT卡任务", "iotCardTask": "IoT卡任务",
"deviceTask": "设备任务", "deviceTask": "设备任务",
"deviceBatchAllocation": "设备批量分配",
"bulkPurchase": "批量订购套餐",
"bulkPurchaseDetail": "批量订购任务详情",
"orderPackageInvalidateTask": "订单套餐批量作废", "orderPackageInvalidateTask": "订单套餐批量作废",
"orderPackageInvalidateTaskDetail": "订单套餐作废任务详情", "orderPackageInvalidateTaskDetail": "订单套餐作废任务详情",
"taskDetail": "任务详情", "taskDetail": "任务详情",
@@ -386,6 +389,11 @@
"exportDevice": "导出设备", "exportDevice": "导出设备",
"exportIotCard": "导出IOT卡", "exportIotCard": "导出IOT卡",
"exportOrder": "导出订单", "exportOrder": "导出订单",
"exportPackage": "导出套餐",
"exportAgentWalletTransaction": "导出代理主钱包流水",
"exportRefund": "导出退款",
"exportAgentRecharge": "导出代理充值",
"exportExchange": "导出换货",
"exportTaskDetail": "导出任务详情", "exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理", "exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情" "exchangeDetail": "换货单详情"

View File

@@ -1,7 +1,7 @@
import { RoutesAlias } from '../routesAlias' import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase' import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification' import { JULY_PERMISSIONS } from '@/config/constants/julyIteration'
/** /**
* 菜单列表、异步路由 * 菜单列表、异步路由
@@ -14,31 +14,6 @@ import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
* 可以是 i18n 的 key也可以是字符串比如'用户列表' * 可以是 i18n 的 key也可以是字符串比如'用户列表'
*/ */
export const asyncRoutes: AppRouteRecord[] = [ export const asyncRoutes: AppRouteRecord[] = [
// 站内通知
{
path: '/notifications',
name: 'Notifications',
component: RoutesAlias.Home,
meta: {
title: '通知中心',
isHide: true
},
children: [
{
path: '',
name: 'NotificationCenter',
component: RoutesAlias.Notifications,
meta: {
title: '通知中心',
permissions: [NOTIFICATION_PERMISSIONS.page],
keepAlive: true,
isHide: true,
isHideTab: true
}
}
]
},
// 仪表台 // 仪表台
{ {
name: 'Dashboard', name: 'Dashboard',
@@ -466,6 +441,28 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
// 设备批量分配任务
{
path: 'device-batch-allocation',
name: 'DeviceBatchAllocation',
component: RoutesAlias.DeviceBatchAllocation,
meta: {
title: 'menus.assetManagement.deviceBatchAllocation',
permissions: [JULY_PERMISSIONS.deviceAllocation.page],
keepAlive: true
}
},
// 批量订购套餐任务
{
path: 'bulk-purchase',
name: 'BulkPurchase',
component: RoutesAlias.BulkPurchase,
meta: {
title: 'menus.assetManagement.bulkPurchase',
permissions: [BULK_PURCHASE_PERMISSIONS.page],
keepAlive: true
}
},
// 订单套餐批量作废任务 // 订单套餐批量作废任务
{ {
path: 'order-package-invalidate-task', path: 'order-package-invalidate-task',
@@ -500,6 +497,18 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: false keepAlive: false
} }
}, },
// 批量订购套餐任务详情
{
path: 'task-management/bulk-purchase/detail/:id',
name: 'BulkPurchaseDetail',
component: RoutesAlias.BulkPurchaseDetail,
meta: {
title: 'menus.assetManagement.bulkPurchaseDetail',
isHide: true,
permissions: [BULK_PURCHASE_PERMISSIONS.detail],
keepAlive: false
}
},
// 导出管理 // 导出管理
{ {
path: 'export-task-management', path: 'export-task-management',
@@ -540,6 +549,56 @@ export const asyncRoutes: AppRouteRecord[] = [
keepAlive: true keepAlive: true
} }
}, },
{
path: 'export-package',
name: 'ExportPackageTaskList',
component: RoutesAlias.ExportPackageTaskList,
meta: {
title: 'menus.assetManagement.exportPackage',
exportTaskScene: 'package',
keepAlive: true
}
},
{
path: 'export-agent-wallet-transaction',
name: 'ExportAgentWalletTransactionTaskList',
component: RoutesAlias.ExportAgentWalletTransactionTaskList,
meta: {
title: 'menus.assetManagement.exportAgentWalletTransaction',
exportTaskScene: 'agent_wallet_transaction',
keepAlive: true
}
},
{
path: 'export-refund',
name: 'ExportRefundTaskList',
component: RoutesAlias.ExportRefundTaskList,
meta: {
title: 'menus.assetManagement.exportRefund',
exportTaskScene: 'refund',
keepAlive: true
}
},
{
path: 'export-agent-recharge',
name: 'ExportAgentRechargeTaskList',
component: RoutesAlias.ExportAgentRechargeTaskList,
meta: {
title: 'menus.assetManagement.exportAgentRecharge',
exportTaskScene: 'agent_recharge',
keepAlive: true
}
},
{
path: 'export-exchange',
name: 'ExportExchangeTaskList',
component: RoutesAlias.ExportExchangeTaskList,
meta: {
title: 'menus.assetManagement.exportExchange',
exportTaskScene: 'exchange',
keepAlive: true
}
},
{ {
path: 'export-task-detail', path: 'export-task-detail',
name: 'ExportTaskDetail', name: 'ExportTaskDetail',
@@ -585,17 +644,6 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true, isHide: true,
keepAlive: false keepAlive: false
} }
},
// 批量订购
{
path: 'bulk-purchase',
name: 'BulkPurchase',
component: RoutesAlias.BulkPurchase,
meta: {
title: '批量订购套餐',
permissions: [BULK_PURCHASE_PERMISSIONS.page],
keepAlive: true
}
} }
] ]
}, },
@@ -786,29 +834,6 @@ export const asyncRoutes: AppRouteRecord[] = [
roles: ['R_SUPER', 'R_ADMIN'] roles: ['R_SUPER', 'R_ADMIN']
} }
}, },
// 企业微信应用配置详情
{
path: 'wecom/applications/create',
name: 'WecomApplicationCreate',
component: RoutesAlias.WecomApplicationDetail,
meta: {
title: '新增企微应用',
isHide: true,
keepAlive: false,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'wecom/applications/detail/:id',
name: 'WecomApplicationDetail',
component: RoutesAlias.WecomApplicationDetail,
meta: {
title: '企微应用配置',
isHide: true,
keepAlive: false,
roles: ['R_SUPER', 'R_ADMIN']
}
},
// 企业微信成员及默认发起人 // 企业微信成员及默认发起人
{ {
path: 'wecom/members', path: 'wecom/members',

View File

@@ -59,27 +59,31 @@ export enum RoutesAlias {
// 任务管理 // 任务管理
IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务 IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/task-management/device-task', // 设备任务 DeviceTask = '/asset-management/task-management/device-task', // 设备任务
DeviceBatchAllocation = '/asset-management/task-management/device-batch-allocation', // 设备批量分配
BulkPurchase = '/asset-management/task-management/bulk-purchase', // 批量订购套餐
BulkPurchaseDetail = '/asset-management/task-management/bulk-purchase/detail', // 批量订购套餐详情
OrderPackageInvalidateTask = '/asset-management/task-management/order-package-invalidate-task', // 订单套餐批量作废任务 OrderPackageInvalidateTask = '/asset-management/task-management/order-package-invalidate-task', // 订单套餐批量作废任务
OrderPackageInvalidateTaskDetail = '/asset-management/task-management/order-package-invalidate-task/detail', // 订单套餐作废任务详情 OrderPackageInvalidateTaskDetail = '/asset-management/task-management/order-package-invalidate-task/detail', // 订单套餐作废任务详情
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情) TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情)
ExportDeviceTaskList = '/asset-management/export-task-management/export-device', // 导出设备 ExportDeviceTaskList = '/asset-management/export-task-management/export-device', // 导出设备
ExportIotCardTaskList = '/asset-management/export-task-management/export-iot-card', // 导出IOT卡 ExportIotCardTaskList = '/asset-management/export-task-management/export-iot-card', // 导出IOT卡
ExportOrderTaskList = '/asset-management/export-task-management/export-order', // 导出订单 ExportOrderTaskList = '/asset-management/export-task-management/export-order', // 导出订单
ExportPackageTaskList = '/asset-management/export-task-management/export-package', // 导出套餐
ExportAgentWalletTransactionTaskList = '/asset-management/export-task-management/export-agent-wallet-transaction', // 导出代理主钱包流水
ExportRefundTaskList = '/asset-management/export-task-management/export-refund', // 导出退款
ExportAgentRechargeTaskList = '/asset-management/export-task-management/export-agent-recharge', // 导出代理充值
ExportExchangeTaskList = '/asset-management/export-task-management/export-exchange', // 导出换货
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情 ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理 // 订单管理
OrderList = '/order-management/order-list', // 订单列表 OrderList = '/order-management/order-list', // 订单列表
OrderDetail = '/order-management/order-list/detail', // 订单详情 OrderDetail = '/order-management/order-list/detail', // 订单详情
BulkPurchase = '/order-management/bulk-purchase', // 批量订购
// 财务管理 // 财务管理
AgentRecharge = '/finance/agent-recharge', // 代理充值 AgentRecharge = '/finance/agent-recharge', // 代理充值
AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情 AgentRechargeDetail = '/finance/agent-recharge/detail', // 代理充值详情
RefundManagement = '/finance/refund', // 退款管理 RefundManagement = '/finance/refund', // 退款管理
RefundDetail = '/finance/refund/detail', // 退款详情 RefundDetail = '/finance/refund/detail', // 退款详情
// 站内通知
Notifications = '/notifications', // 通知中心
ExpiringAssets = '/asset-management/expiring-assets', // 临期资产 ExpiringAssets = '/asset-management/expiring-assets', // 临期资产
// 佣金管理 // 佣金管理
@@ -95,7 +99,6 @@ export enum RoutesAlias {
SystemConfigs = '/settings/system-configs', // 系统配置 SystemConfigs = '/settings/system-configs', // 系统配置
WecomSettings = '/settings/wecom', // 企业微信配置兼容入口 WecomSettings = '/settings/wecom', // 企业微信配置兼容入口
WecomApplications = '/settings/wecom/applications', // 企业微信应用 WecomApplications = '/settings/wecom/applications', // 企业微信应用
WecomApplicationDetail = '/settings/wecom/applications/detail', // 企业微信应用配置
WecomMembers = '/settings/wecom/members', // 企业微信成员 WecomMembers = '/settings/wecom/members', // 企业微信成员
WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景 WecomScenes = '/settings/wecom/scenes', // 企业微信审批场景

View File

@@ -37,6 +37,7 @@ export interface AgentRecharge {
remark?: string // 运营备注 remark?: string // 运营备注
submitter_name?: string | null // 提交人名称 submitter_name?: string | null // 提交人名称
submitter_id?: number | null submitter_id?: number | null
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_provider?: 'wecom' | null approval_provider?: 'wecom' | null
approval_instance_id?: number | null approval_instance_id?: number | null
approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 企微审批状态 approval_status?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | null // 企微审批状态

View File

@@ -72,23 +72,46 @@ export type AssetRealnamePolicy = 'none' | 'before_order' | 'after_order'
export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | 'none' | 'invalid_data' export type ExpiryEstimateStatus = 'exact' | 'waiting_activation' | 'none' | 'invalid_data'
// 临期等级由后端返回,前端仅用于展示和筛选结果标识 // 临期等级由后端返回,前端仅用于展示和筛选结果标识
export type ExpiryLevel = string export type ExpiryLevel = 'red' | 'purple' | 'pink' | string
export type ExpiringAssetType = 'iot_card' | 'device'
export interface ExpiringAssetItem { export interface ExpiringAssetItem {
asset_type: AssetType
asset_id: number asset_id: number
asset_type: ExpiringAssetType
days_until_final_expiry: number | null
estimated_final_expires_at: string | null
expiry_estimate_status: ExpiryEstimateStatus
expiry_estimate_status_name: string
expiry_level: ExpiryLevel
expiry_level_name: string
identifier: string identifier: string
shop_name?: string | null is_expiring: boolean
package_name?: string | null is_priority: boolean
estimated_final_expires_at: string package_id?: number
days_until_final_expiry: number package_name: string
expiry_level?: ExpiryLevel | null package_usage_id?: number
expiry_level_name?: string | null shop_id?: number | null
can_renew: boolean shop_name: string
}
export interface ExpiringAssetSummary {
card_count: number
device_count: number
total_count: number
window_days: number
}
export interface ExpiringAssetListResponse {
items: ExpiringAssetItem[]
page: number
size: number
summary: ExpiringAssetSummary
total: number
} }
export interface ExpiringAssetQueryParams { export interface ExpiringAssetQueryParams {
asset_type?: AssetType asset_type?: ExpiringAssetType
keyword?: string keyword?: string
shop_id?: number shop_id?: number
package_id?: number package_id?: number
@@ -97,7 +120,7 @@ export interface ExpiringAssetQueryParams {
expires_from?: string expires_from?: string
expires_to?: string expires_to?: string
page?: number page?: number
size?: number page_size?: number
} }
// 批量更新资产实名认证策略请求 // 批量更新资产实名认证策略请求

View File

@@ -18,7 +18,8 @@ export enum BulkPurchaseTaskStatus {
} }
export interface BulkPurchaseCreateResponse { export interface BulkPurchaseCreateResponse {
task_id: number id?: number
task_id?: number
task_no?: string task_no?: string
status?: BulkPurchaseTaskStatus status?: BulkPurchaseTaskStatus
status_name?: string status_name?: string

View File

@@ -215,9 +215,11 @@ export enum DeviceImportTaskStatus {
} }
// 导入任务查询参数 // 导入任务查询参数
export type DeviceImportTaskOperationType = 'import' | 'assign_shop' | 'assign_series' | 'recall'
export interface DeviceImportTaskQueryParams extends PaginationParams { export interface DeviceImportTaskQueryParams extends PaginationParams {
status?: DeviceImportTaskStatus // 任务状态 status?: DeviceImportTaskStatus // 任务状态
operation_type?: 'import' | 'assign_shop' | 'assign_series' operation_type?: DeviceImportTaskOperationType
batch_no?: string // 批次号(模糊查询) batch_no?: string // 批次号(模糊查询)
start_time?: string // 创建时间起始 start_time?: string // 创建时间起始
end_time?: string // 创建时间结束 end_time?: string // 创建时间结束
@@ -232,13 +234,14 @@ export interface DeviceImportTask {
file_key?: string // 原始导入文件存储键 file_key?: string // 原始导入文件存储键
creator_name?: string // 创建人姓名 creator_name?: string // 创建人姓名
status: DeviceImportTaskStatus // 任务状态 status: DeviceImportTaskStatus // 任务状态
status_name?: string // 任务状态名称
status_text: string // 任务状态文本 status_text: string // 任务状态文本
total_count: number // 总数 total_count: number // 总数
success_count: number // 成功数 success_count: number // 成功数
fail_count: number // 失败数 fail_count: number // 失败数
skip_count: number // 跳过数 skip_count: number // 跳过数
error_message: string // 错误信息 error_message: string // 错误信息
operation_type: 'import' | 'assign_shop' | 'assign_series' operation_type: DeviceImportTaskOperationType
operation_name: string operation_name: string
target_id: number | null target_id: number | null
realname_policy: 'none' | 'before_order' | 'after_order' realname_policy: 'none' | 'before_order' | 'after_order'
@@ -273,8 +276,8 @@ export interface DeviceImportTaskDetail extends DeviceImportTask {
export interface DeviceBatchAllocationRequest { export interface DeviceBatchAllocationRequest {
file_key: string file_key: string
operation_type: 'assign_shop' | 'assign_series' operation_type: Exclude<DeviceImportTaskOperationType, 'import'>
target_id: number target_id?: number
} }
export interface DeviceBatchAllocationResponse { export interface DeviceBatchAllocationResponse {

View File

@@ -69,7 +69,7 @@ export interface ExportTaskListResponse {
export interface CreateExportTaskRequest { export interface CreateExportTaskRequest {
scene: ExportTaskScene scene: ExportTaskScene
format: ExportTaskFormat format: ExportTaskFormat
query?: Record<string, unknown> query?: Record<string, string>
} }
export interface CreateExportTaskResponse { export interface CreateExportTaskResponse {
@@ -85,15 +85,6 @@ export interface ExportTaskDetail extends ExportTaskItem {
download_expires_at?: string download_expires_at?: string
} }
export interface CancelExportTaskResponse {
task_id: number
status: ExportTaskStatus
status_name: string
cancel_requested: boolean
message: string
}
export type ExportTaskListApiResponse = BaseResponse<ExportTaskListResponse> export type ExportTaskListApiResponse = BaseResponse<ExportTaskListResponse>
export type CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse> export type CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse>
export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail> export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail>
export type CancelExportTaskApiResponse = BaseResponse<CancelExportTaskResponse>

View File

@@ -49,6 +49,10 @@ export interface NotificationReadResponse {
success: boolean success: boolean
} }
export interface NotificationReadRequest {
id: number
}
export interface NotificationReadAllRequest { export interface NotificationReadAllRequest {
category?: string category?: string
} }

View File

@@ -281,8 +281,8 @@ export interface CommissionTier {
* 套餐信息(用于系列授权) * 套餐信息(用于系列授权)
*/ */
export interface GrantPackageInfo extends PackageAllocationExpiryBaseFields { export interface GrantPackageInfo extends PackageAllocationExpiryBaseFields {
allocation_id?: number
package_id: number package_id: number
allocation_id: number // 套餐分配记录 ID用于修改生效条件
package_name?: string package_name?: string
package_code?: string package_code?: string
cost_price: number // 成本价(分) cost_price: number // 成本价(分)

View File

@@ -113,6 +113,7 @@ export interface CreateRefundRequest {
requested_refund_amount: number // 申请退款金额(分) requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分) actual_received_amount: number // 实收金额(分)
attachments: RefundAttachment[] attachments: RefundAttachment[]
package_usage_id?: number // 关联套餐使用记录 ID
refund_reason?: string refund_reason?: string
remark?: string remark?: string
} }

View File

@@ -68,6 +68,37 @@ export interface WecomSceneRequest {
status?: 0 | 1 status?: 0 | 1
} }
export interface WecomTemplateInspectRequest {
template_id: string
}
export interface WecomTemplateControl {
id: string
option_keys?: string[] | null
required: boolean
title: string
type: string
}
export interface WecomTemplateDetail {
controls?: WecomTemplateControl[] | null
name: string
template_id: string
}
export interface WecomBusinessField {
code: string
description: string
name: string
value_type: string
}
export interface WecomBusinessFieldList {
business_type: WecomBusinessType
business_type_name: string
items?: WecomBusinessField[] | null
}
export interface WecomAccountBindingRequest { export interface WecomAccountBindingRequest {
application_id: number application_id: number
userid: string userid: string
@@ -85,11 +116,13 @@ export type WecomSceneListResponse = BaseResponse<PaginationData<WecomScene>>
export type WecomApplicationResponse = BaseResponse<WecomApplication> export type WecomApplicationResponse = BaseResponse<WecomApplication>
export type WecomSceneResponse = BaseResponse<WecomScene> export type WecomSceneResponse = BaseResponse<WecomScene>
export type WecomSyncMembersApiResponse = BaseResponse<WecomSyncMembersResponse> export type WecomSyncMembersApiResponse = BaseResponse<WecomSyncMembersResponse>
export type WecomTemplateDetailResponse = BaseResponse<WecomTemplateDetail>
export type WecomBusinessFieldListResponse = BaseResponse<WecomBusinessFieldList>
export interface WecomMemberQueryParams extends PaginationParams { export interface WecomMemberQueryParams extends PaginationParams {
keyword?: string keyword?: string
} }
export interface WecomApplicationQueryParams extends PaginationParams {} export type WecomApplicationQueryParams = PaginationParams
export interface WecomSceneQueryParams extends PaginationParams {} export type WecomSceneQueryParams = PaginationParams

View File

@@ -8,13 +8,52 @@ type TargetRoute = {
} }
const resolveTargetRoute = (target: NotificationTarget): TargetRoute | null => { const resolveTargetRoute = (target: NotificationTarget): TargetRoute | null => {
const targetValues = [target.target_key, target.target_type] const targetId = target.target_id === null ? undefined : String(target.target_id)
const targetKey = target.target_key || ''
if (targetValues.includes('expiring_assets') || targetValues.includes('expiring_asset_list')) { if (target.target_type === 'refund_detail' && targetId) {
return { path: `${RoutesAlias.RefundDetail}/${targetId}` }
}
if (target.target_type === 'agent_recharge_detail' && targetId) {
return { path: `${RoutesAlias.AgentRechargeDetail}/${targetId}` }
}
if (target.target_type === 'iot_card_detail' && (targetKey || targetId)) {
return {
path: RoutesAlias.AssetInformation,
query: { iccid: targetKey || targetId || '' }
}
}
if (target.target_type === 'device_detail' && (targetKey || targetId)) {
return {
path: RoutesAlias.AssetInformation,
query: { virtual_no: targetKey || targetId || '' }
}
}
if (
target.target_type === 'expiring_asset_list' ||
target.target_key === 'expiring_assets' ||
target.target_key === 'expiring_asset_list'
) {
return { path: RoutesAlias.ExpiringAssets } return { path: RoutesAlias.ExpiringAssets }
} }
if (targetValues.includes('notification_center')) return { path: RoutesAlias.Notifications } if (target.target_type === 'shop_fund_summary') {
return {
path: RoutesAlias.AgentFundOverview,
query: targetId ? { shop_id: targetId } : undefined
}
}
if (target.target_type === 'system_config') {
return {
path: RoutesAlias.SystemConfigs,
query: targetKey ? { config_key: targetKey } : undefined
}
}
return null return null
} }

View File

@@ -213,6 +213,75 @@
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
<!-- 绑定企微账号对话框 -->
<ElDialog
v-model="wecomBindingDialogVisible"
title="绑定企微账号"
width="520px"
destroy-on-close
@closed="resetWecomBindingForm"
>
<ElForm
ref="wecomBindingFormRef"
:model="wecomBindingForm"
:rules="wecomBindingRules"
label-width="100px"
>
<ElFormItem label="账号名称">
<span>{{ currentWecomAccount?.username || '-' }}</span>
</ElFormItem>
<ElFormItem label="企微应用" prop="application_id">
<ElSelect
v-model="wecomBindingForm.application_id"
placeholder="请选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomApplicationsLoading"
@change="handleWecomApplicationChange"
>
<ElOption
v-for="application in wecomApplications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="企微成员" prop="userid">
<ElSelect
v-model="wecomBindingForm.userid"
placeholder="请先选择企微应用"
filterable
clearable
style="width: 100%"
:loading="wecomMembersLoading"
:disabled="!wecomBindingForm.application_id"
>
<ElOption
v-for="member in wecomMembers"
:key="member.userid"
:label="member.name"
:value="member.userid"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="wecomBindingDialogVisible = false">取消</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.binding"
type="primary"
:loading="wecomBindingSubmitting"
@click="submitWecomBinding"
>
确认绑定
</ElButton>
</div>
</template>
</ElDialog>
</ElCard> </ElCard>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
@@ -237,11 +306,16 @@
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { AccountService } from '@/api/modules/account' import { AccountService } from '@/api/modules/account'
import { RoleService } from '@/api/modules/role' import { RoleService } from '@/api/modules/role'
import { ShopService, EnterpriseService } from '@/api/modules' import { ShopService, EnterpriseService, WecomService } from '@/api/modules'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import type { PlatformRole } from '@/types/api' import type { PlatformRole, WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { CommonStatus, getStatusText, STATUS_SELECT_OPTIONS } from '@/config/constants' import {
CommonStatus,
getStatusText,
JULY_PERMISSIONS,
STATUS_SELECT_OPTIONS
} from '@/config/constants'
defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制 defineOptions({ name: 'Account' }) // 定义组件名称,用于 KeepAlive 缓存控制
@@ -252,6 +326,7 @@
const dialogType = ref('add') const dialogType = ref('add')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const roleDialogVisible = ref(false) const roleDialogVisible = ref(false)
const wecomBindingDialogVisible = ref(false)
const loading = ref(false) const loading = ref(false)
const currentAccountId = ref<number>(0) const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('') const currentAccountName = ref<string>('')
@@ -262,6 +337,21 @@
const roleToAdd = ref<number | undefined>(undefined) // 单选时使用 const roleToAdd = ref<number | undefined>(undefined) // 单选时使用
const leftRoleFilter = ref('') const leftRoleFilter = ref('')
const rightRoleFilter = ref('') const rightRoleFilter = ref('')
const currentWecomAccount = ref<any | null>(null)
const wecomApplications = ref<WecomApplication[]>([])
const wecomMembers = ref<WecomMember[]>([])
const wecomApplicationsLoading = ref(false)
const wecomMembersLoading = ref(false)
const wecomBindingSubmitting = ref(false)
const wecomBindingFormRef = ref<FormInstance>()
const wecomBindingForm = reactive({
application_id: undefined as number | undefined,
userid: ''
})
const wecomBindingRules = reactive<FormRules>({
application_id: [{ required: true, message: '请选择企微应用', trigger: 'change' }],
userid: [{ required: true, message: '请选择企微成员', trigger: 'change' }]
})
// 是否为平台用户(平台用户可多选,其他单选) // 是否为平台用户(平台用户可多选,其他单选)
const isPlatformUser = computed(() => currentAccountType.value === 2) const isPlatformUser = computed(() => currentAccountType.value === 2)
@@ -541,6 +631,14 @@
const getActions = (row: any) => { const getActions = (row: any) => {
const actions: any[] = [] const actions: any[] = []
if (hasAuth(JULY_PERMISSIONS.wecom.binding)) {
actions.push({
label: '绑定账号',
handler: () => showWecomBindingDialog(row),
type: 'primary'
})
}
if (hasAuth('account:patch_role')) { if (hasAuth('account:patch_role')) {
actions.push({ actions.push({
label: '分配角色', label: '分配角色',
@@ -568,6 +666,75 @@
return actions return actions
} }
const showWecomBindingDialog = (row: any) => {
currentWecomAccount.value = row
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingDialogVisible.value = true
void loadWecomApplications()
}
const loadWecomApplications = async () => {
wecomApplicationsLoading.value = true
try {
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) wecomApplications.value = response.data.items || []
} finally {
wecomApplicationsLoading.value = false
}
}
const handleWecomApplicationChange = async (applicationId?: number) => {
wecomBindingForm.userid = ''
wecomMembers.value = []
if (!applicationId) return
wecomMembersLoading.value = true
try {
const response = await WecomService.getMembers(applicationId, {
page: 1,
page_size: 100
})
if (response.code === 0) wecomMembers.value = response.data.items || []
} finally {
wecomMembersLoading.value = false
}
}
const submitWecomBinding = async () => {
const valid = await wecomBindingFormRef.value?.validate().catch(() => false)
if (!valid || !currentWecomAccount.value || !wecomBindingForm.application_id) return
const accountId = Number(currentWecomAccount.value.id ?? currentWecomAccount.value.ID)
if (!accountId) {
ElMessage.error('当前账号缺少账号标识,无法绑定企微')
return
}
wecomBindingSubmitting.value = true
try {
const response = await AccountService.bindWecom(accountId, {
application_id: wecomBindingForm.application_id,
userid: wecomBindingForm.userid
})
if (response.code === 0) {
ElMessage.success('账号企微绑定成功')
wecomBindingDialogVisible.value = false
}
} finally {
wecomBindingSubmitting.value = false
}
}
const resetWecomBindingForm = () => {
currentWecomAccount.value = null
wecomBindingForm.application_id = undefined
wecomBindingForm.userid = ''
wecomMembers.value = []
wecomBindingFormRef.value?.clearValidate()
}
// 表单实例 // 表单实例
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()

View File

@@ -10,7 +10,11 @@
</div> </div>
<div class="card-header-right"> <div class="card-header-right">
<ElButton <ElButton
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)" v-if="
cardInfo?.asset_type === 'card' &&
hasAuth(JULY_PERMISSIONS.speedTier.view) &&
hasAuth(JULY_PERMISSIONS.speedTier.update)
"
type="primary" type="primary"
link link
@click="emit('showSpeedLimit')" @click="emit('showSpeedLimit')"

View File

@@ -270,6 +270,7 @@
v-model="createRefundDialogVisible" v-model="createRefundDialogVisible"
:initial-order-id="currentRefundOrderId" :initial-order-id="currentRefundOrderId"
:initial-order-no="currentRefundOrderNo" :initial-order-no="currentRefundOrderNo"
:initial-package-usage-id="currentRefundPackageUsageId"
@success="handleRefundSuccess" @success="handleRefundSuccess"
/> />
@@ -279,12 +280,7 @@
width="30%" width="30%"
@closed="resetUsedDataForm" @closed="resetUsedDataForm"
> >
<ElForm <ElForm ref="usedDataFormRef" :model="usedDataForm" :rules="usedDataRules" label-width="90px">
ref="usedDataFormRef"
:model="usedDataForm"
:rules="usedDataRules"
label-width="90px"
>
<ElFormItem label="套餐名称"> <ElFormItem label="套餐名称">
<span>{{ selectedPackage?.package_name || '-' }}</span> <span>{{ selectedPackage?.package_name || '-' }}</span>
</ElFormItem> </ElFormItem>
@@ -416,6 +412,7 @@
const createRefundDialogVisible = ref(false) const createRefundDialogVisible = ref(false)
const currentRefundOrderId = ref<number | undefined>(undefined) const currentRefundOrderId = ref<number | undefined>(undefined)
const currentRefundOrderNo = ref<string | undefined>(undefined) const currentRefundOrderNo = ref<string | undefined>(undefined)
const currentRefundPackageUsageId = ref<number | undefined>(undefined)
const selectedPackage = ref<PackageInfo>() const selectedPackage = ref<PackageInfo>()
const usedDataDialogVisible = ref(false) const usedDataDialogVisible = ref(false)
const expiresAtDialogVisible = ref(false) const expiresAtDialogVisible = ref(false)
@@ -474,10 +471,12 @@
} }
currentRefundOrderId.value = row.order_id currentRefundOrderId.value = row.order_id
currentRefundOrderNo.value = row.order_no currentRefundOrderNo.value = row.order_no
currentRefundPackageUsageId.value = row.package_usage_id ?? row.id
createRefundDialogVisible.value = true createRefundDialogVisible.value = true
} }
const handleRefundSuccess = () => { const handleRefundSuccess = () => {
currentRefundPackageUsageId.value = undefined
emit('refresh') emit('refresh')
} }

View File

@@ -1,39 +1,66 @@
<template> <template>
<ElDialog v-model="visible" title="设置限速" width="500px"> <ElDialog
v-model="visible"
title="设置限速"
width="500px"
:lock-scroll="true"
:z-index="4000"
modal-class="speed-limit-modal"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px"> <ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="固定档位" prop="code"> <ElFormItem label="固定档位" prop="code">
<ElSelect v-model="form.code" style="width: 100%"> <ElSelect
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" /> v-model="form.code"
style="width: 100%"
:teleported="true"
append-to="body"
popper-class="speed-limit-select-popper"
:popper-style="{ zIndex: 4001 }"
>
<ElOption
v-for="tier in speedTiers"
:key="tier.code"
:label="tier.label"
:value="tier.code"
/>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElForm> </ElForm>
<template #footer> <template #footer>
<ElButton @click="handleCancel">取消</ElButton> <ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton> <ElButton
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
:disabled="confirmLoading"
>
确认设置
</ElButton>
</template> </template>
</ElDialog> </ElDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, onUnmounted, watch } from 'vue'
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus' import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
confirmLoading?: boolean
} }
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void (e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
confirmLoading: false
})
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const loading = ref(false)
const speedTiers = [ const speedTiers = [
{ code: -1, label: '不限速' }, { code: -1, label: '不限速' },
{ code: 0, label: '0kbps' }, { code: 0, label: '0kbps' },
@@ -59,10 +86,25 @@
}) })
// 监听对话框打开,重置表单 // 监听对话框打开,重置表单
watch(visible, (newVal) => { const setLayoutScrollLock = (locked: boolean) => {
if (newVal) { document
form.code = 3 .querySelector<HTMLElement>('.layouts')
} ?.classList.toggle('speed-limit-scroll-locked', locked)
}
watch(
visible,
(newVal) => {
setLayoutScrollLock(newVal)
if (newVal) {
form.code = 3
}
},
{ immediate: true }
)
onUnmounted(() => {
setLayoutScrollLock(false)
}) })
const handleCancel = () => { const handleCancel = () => {
@@ -70,6 +112,7 @@
} }
const handleConfirm = async () => { const handleConfirm = async () => {
if (props.confirmLoading) return
if (!formRef.value) return if (!formRef.value) return
try { try {
@@ -83,4 +126,28 @@
} }
</script> </script>
<style lang="scss" scoped></style> <style lang="scss">
.speed-limit-modal .el-dialog__body {
overflow: visible;
}
.speed-limit-select-popper {
z-index: 4001 !important;
overscroll-behavior: contain;
}
.speed-limit-select-popper .el-select-dropdown__wrap,
.speed-limit-select-popper .el-scrollbar__wrap {
max-height: none !important;
overflow-y: visible !important;
}
.speed-limit-modal {
overflow: hidden;
overscroll-behavior: contain;
}
.layouts.speed-limit-scroll-locked {
overflow: hidden !important;
}
</style>

View File

@@ -36,8 +36,8 @@
@show-switch-card="showSwitchCardDialog" @show-switch-card="showSwitchCardDialog"
@show-switch-mode="showSwitchModeDialog" @show-switch-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog" @show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog" @show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true" @show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog" @show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard" @enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard" @disable-binding-card="handleDisableBindingCard"
@@ -174,7 +174,11 @@
:current-realname-status="bindingCardRealnameStatusValue" :current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess" @success="handleBindingCardRealnameStatusSuccess"
/> />
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" /> <SpeedLimitDialog
v-model="speedLimitDialogVisible"
:confirm-loading="speedTierSubmitting"
@confirm="handleSpeedTierConfirm"
/>
</div> </div>
</template> </template>
@@ -183,7 +187,10 @@
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus' import { ElMessage, ElCard, ElEmpty } from 'element-plus'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { CardService, DeviceService } from '@/api/modules' import { CardService, DeviceService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import { normalizeApiError } from '@/utils/business/apiError'
import { import {
formatRemainingTime, formatRemainingTime,
FrontendRateLimitError, FrontendRateLimitError,
@@ -204,9 +211,9 @@
SwitchModeDialog, SwitchModeDialog,
WiFiConfigDialog, WiFiConfigDialog,
PackageRechargeDialog, PackageRechargeDialog,
DailyRecordsDialog, DailyRecordsDialog,
OrderHistoryDialog, OrderHistoryDialog,
SpeedLimitDialog SpeedLimitDialog
} from './components/dialogs' } from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue' import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue' import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -235,6 +242,7 @@
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
// ========== 组件引用 ========== // ========== 组件引用 ==========
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>() const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
@@ -294,6 +302,7 @@
// 绑定卡实名状态更新 // 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false) const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false) const speedLimitDialogVisible = ref(false)
const speedTierSubmitting = ref(false)
const bindingCardRealnameStatusIccid = ref('') const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0) const bindingCardRealnameStatusValue = ref<number>(0)
@@ -806,12 +815,34 @@
} }
} }
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => { const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!cardInfo.value?.iccid) return if (!hasAuth(JULY_PERMISSIONS.speedTier.update)) {
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code) ElMessage.error('暂无设置限速权限')
if (response.code === 0) { return
}
const iccid = cardInfo.value?.iccid
if (!iccid) {
ElMessage.error('当前卡缺少 ICCID无法设置限速')
return
}
if (speedTierSubmitting.value) return
speedTierSubmitting.value = true
try {
const response = await CardService.setSpeedTier(iccid, data.code)
if (response.code !== 0) {
ElMessage.error(response.msg || '限速设置失败')
return
}
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`) ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
speedLimitDialogVisible.value = false speedLimitDialogVisible.value = false
await handleRefresh() await handleRefresh()
} catch (error) {
console.error('设置限速失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
speedTierSubmitting.value = false
} }
} }
</script> </script>

View File

@@ -17,29 +17,6 @@
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #left> <template #left>
<ElButton
type="primary"
@click="handleBatchAllocate"
:disabled="!selectedDevices.length"
v-permission="'device:batch_allocate'"
>
批量分配
</ElButton>
<ElButton
@click="handleBatchRecall"
:disabled="!selectedDevices.length"
v-permission="'device:batch_recall'"
>
批量回收
</ElButton>
<ElButton
type="info"
:disabled="!selectedDevices.length"
@click="handleBatchSetSeries"
v-permission="'device:batch_set_series'"
>
批量设置套餐系列
</ElButton>
<ElButton <ElButton
type="primary" type="primary"
:disabled="!selectedDevices.length" :disabled="!selectedDevices.length"
@@ -2299,8 +2276,8 @@
return return
} }
const successCount = res.data?.success_count ?? assetIds.length const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`) ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = [] selectedDevices.value = []
await getTableData() await getTableData()
@@ -2373,19 +2350,6 @@
}) })
} }
// 批量分配
const handleBatchAllocate = async () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要分配的设备')
return
}
allocateForm.target_shop_id = undefined
allocateForm.remark = ''
allocateResult.value = null
shopCascadeOptions.value = []
allocateDialogVisible.value = true
}
// 确认批量分配 // 确认批量分配
const handleConfirmAllocate = async () => { const handleConfirmAllocate = async () => {
if (!allocateFormRef.value) return if (!allocateFormRef.value) return
@@ -2436,17 +2400,6 @@
} }
} }
// 批量回收
const handleBatchRecall = () => {
if (selectedDevices.value.length === 0) {
ElMessage.warning('请先选择要回收的设备')
return
}
recallForm.remark = ''
recallResult.value = null
recallDialogVisible.value = true
}
// 确认批量回收 // 确认批量回收
const handleConfirmRecall = async () => { const handleConfirmRecall = async () => {
recallLoading.value = true recallLoading.value = true
@@ -2636,22 +2589,6 @@
}) })
} }
// 批量设置套餐系列
const handleBatchSetSeries = async () => {
seriesBindingResult.value = null
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
selection_type:
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
})
await Promise.all([
loadPackageSeriesList(),
loadSearchBatchNoOptions(),
loadSeriesBindingShopCascadeOptions()
])
seriesBindingDialogVisible.value = true
seriesBindingFormRef.value?.clearValidate()
}
// 加载套餐系列列表支持名称搜索默认20条 // 加载套餐系列列表支持名称搜索默认20条
const loadPackageSeriesList = async (seriesName?: string) => { const loadPackageSeriesList = async (seriesName?: string) => {
seriesLoading.value = true seriesLoading.value = true
@@ -2680,24 +2617,6 @@
await loadPackageSeriesList(query || undefined) await loadPackageSeriesList(query || undefined)
} }
const loadSeriesBindingShopCascadeOptions = async () => {
try {
const res = await ShopService.getShopsCascade({
parent_id: undefined,
exclude_self: true
})
if (res.code === 0) {
seriesBindingShopCascadeOptions.value = (res.data || []).map((item: any) => ({
value: item.id,
label: item.shop_name,
leaf: !item.has_children
}))
}
} catch (error) {
console.error('加载批量设置套餐系列根级店铺失败:', error)
}
}
const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => { const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
const request: BatchSetDeviceSeriesBindingRequest = { const request: BatchSetDeviceSeriesBindingRequest = {
selection_type: seriesBindingForm.selection_type, selection_type: seriesBindingForm.selection_type,

View File

@@ -18,6 +18,9 @@
<ElButton type="primary" @click="showCreateDialog" v-permission="'exchange:create'"> <ElButton type="primary" @click="showCreateDialog" v-permission="'exchange:create'">
创建换货单 创建换货单
</ElButton> </ElButton>
<ElButton v-if="hasAuth('exchange:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
@@ -41,6 +44,14 @@
</template> </template>
</ArtTable> </ArtTable>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="exchange"
:query="exportQuery"
confirm-permission="exchange:export"
title="导出换货"
/>
<!-- 创建换货单对话框 --> <!-- 创建换货单对话框 -->
<ElDialog <ElDialog
v-model="createDialogVisible" v-model="createDialogVisible"
@@ -393,6 +404,7 @@
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { import {
getApprovalStatusText, getApprovalStatusText,
@@ -413,6 +425,7 @@
const shipFormRef = ref<FormInstance>() const shipFormRef = ref<FormInstance>()
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const createLoading = ref(false) const createLoading = ref(false)
const exportDialogVisible = ref(false)
const tableRef = ref() const tableRef = ref()
const createFormRef = ref<FormInstance>() const createFormRef = ref<FormInstance>()
@@ -438,6 +451,18 @@
created_at_end: '' created_at_end: ''
}) })
const exportQuery = computed(() => {
const [startDate, endDate] = searchForm.created_at_range || []
return {
status: searchForm.status,
flow_type: searchForm.flow_type,
old_asset_keyword: searchForm.old_asset_keyword || undefined,
new_asset_keyword: searchForm.new_asset_keyword || undefined,
created_at_start: startDate || searchForm.created_at_start || undefined,
created_at_end: endDate || searchForm.created_at_end || undefined
}
})
// 创建换货单表单 // 创建换货单表单
const createForm = reactive<{ const createForm = reactive<{
exchange_reason: string exchange_reason: string

View File

@@ -5,7 +5,7 @@
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
:show-expand="true" :show-expand="true"
label-width="85" label-width="100px"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
/> />
@@ -17,7 +17,10 @@
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #left> <template #left>
<div class="page-intro"> 仅展示可预计且尚未过期的资产0-3 天资产优先显示 </div> <div class="page-intro">
临期资产 {{ summary.total_count }} {{ summary.card_count }}设备
{{ summary.device_count }}0-3 天资产优先显示
</div>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
@@ -47,11 +50,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, onMounted, reactive, ref, computed } from 'vue' import { h, onMounted, reactive, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElTag } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import { AssetService, PackageManageService, ShopService } from '@/api/modules' import { AssetService, PackageManageService, ShopService } from '@/api/modules'
import type { import type {
AssetType,
ExpiringAssetItem, ExpiringAssetItem,
ExpiringAssetSummary,
ExpiringAssetType,
ExpiringAssetQueryParams, ExpiringAssetQueryParams,
PackageResponse, PackageResponse,
ShopResponse ShopResponse
@@ -72,6 +76,12 @@
const shopOptions = ref<ShopResponse[]>([]) const shopOptions = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([]) const packageOptions = ref<PackageResponse[]>([])
const items = ref<ExpiringAssetItem[]>([]) const items = ref<ExpiringAssetItem[]>([])
const summary = reactive<ExpiringAssetSummary>({
card_count: 0,
device_count: 0,
total_count: 0,
window_days: 15
})
const searchForm = reactive<ExpiringAssetQueryParams>({ const searchForm = reactive<ExpiringAssetQueryParams>({
asset_type: undefined, asset_type: undefined,
keyword: '', keyword: '',
@@ -91,7 +101,7 @@
type: 'select', type: 'select',
config: { clearable: true, placeholder: '全部' }, config: { clearable: true, placeholder: '全部' },
options: [ options: [
{ label: '网卡', value: 'card' }, { label: '物联网卡', value: 'iot_card' },
{ label: '设备', value: 'device' } { label: '设备', value: 'device' }
] ]
}, },
@@ -137,13 +147,25 @@
label: '最小剩余天数', label: '最小剩余天数',
prop: 'days_min', prop: 'days_min',
type: 'input', type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' } config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最小天数'
}
}, },
{ {
label: '最大剩余天数', label: '最大剩余天数',
prop: 'days_max', prop: 'days_max',
type: 'input', type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' } config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最大天数'
}
}, },
{ {
label: '预计到期起始', label: '预计到期起始',
@@ -170,15 +192,15 @@
] ]
const getExpiryClass = (level?: string | null) => { const getExpiryClass = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'expiry-critical' if (level === 'red') return 'expiry-critical'
if (level === 'warning' || level === '4_7') return 'expiry-warning' if (level === 'purple') return 'expiry-warning'
if (level === 'notice' || level === '8_15') return 'expiry-notice' if (level === 'pink') return 'expiry-notice'
return '' return ''
} }
const getExpiryTagType = (level?: string | null) => { const getExpiryTagType = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'danger' if (level === 'red') return 'danger'
if (level === 'warning' || level === '4_7') return 'warning' if (level === 'purple') return 'warning'
return 'info' return 'info'
} }
@@ -215,7 +237,7 @@
prop: 'asset_type', prop: 'asset_type',
label: '资产类型', label: '资产类型',
width: 100, width: 100,
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备') formatter: (row: ExpiringAssetItem) => (row.asset_type === 'iot_card' ? '物联网卡' : '设备')
}, },
{ {
prop: 'identifier', prop: 'identifier',
@@ -246,7 +268,7 @@
h( h(
'span', 'span',
{ class: getExpiryClass(row.expiry_level) }, { class: getExpiryClass(row.expiry_level) },
formatDateTime(row.estimated_final_expires_at) formatDateTime(row.estimated_final_expires_at) || '-'
) )
}, },
{ {
@@ -254,10 +276,8 @@
label: '剩余天数', label: '剩余天数',
width: 100, width: 100,
formatter: (row: ExpiringAssetItem) => formatter: (row: ExpiringAssetItem) =>
h( h(ElTag, { type: getExpiryTagType(row.expiry_level), size: 'small' }, () =>
ElTag, row.days_until_final_expiry == null ? '-' : `${row.days_until_final_expiry}`
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
() => `${row.days_until_final_expiry}`
) )
}, },
{ {
@@ -271,15 +291,43 @@
const loadAssets = async () => { const loadAssets = async () => {
loading.value = true loading.value = true
try { try {
const response = await AssetService.getExpiringAssets({ const toInteger = (value: unknown) => {
...searchForm, if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
return Number.isInteger(numberValue) ? numberValue : undefined
}
const params: ExpiringAssetQueryParams = {
page: pagination.currentPage, page: pagination.currentPage,
size: pagination.pageSize page_size: pagination.pageSize
}) }
if (searchForm.asset_type) params.asset_type = searchForm.asset_type
const keyword = searchForm.keyword?.trim()
if (keyword) params.keyword = keyword
if (searchForm.shop_id !== undefined && searchForm.shop_id !== null) {
params.shop_id = searchForm.shop_id
}
if (searchForm.package_id !== undefined && searchForm.package_id !== null) {
params.package_id = searchForm.package_id
}
const daysMin = toInteger(searchForm.days_min)
const daysMax = toInteger(searchForm.days_max)
if (daysMin !== undefined) params.days_min = daysMin
if (daysMax !== undefined) params.days_max = daysMax
if (searchForm.expires_from) params.expires_from = searchForm.expires_from
if (searchForm.expires_to) params.expires_to = searchForm.expires_to
const response = await AssetService.getExpiringAssets(params)
if (response.code === 0 && response.data) { if (response.code === 0 && response.data) {
items.value = response.data.items || [] items.value = response.data.items || []
pagination.total = response.data.total || 0 pagination.total = response.data.total || 0
Object.assign(summary, response.data.summary)
} else {
ElMessage.error(response.msg || '获取临期资产失败')
} }
} catch (error) {
console.error('获取临期资产失败:', error)
ElMessage.error('获取临期资产失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -318,21 +366,20 @@
const goToAsset = (row: ExpiringAssetItem) => { const goToAsset = (row: ExpiringAssetItem) => {
router.push({ router.push({
path: RoutesAlias.AssetInformation, path: RoutesAlias.AssetInformation,
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier } query:
row.asset_type === 'iot_card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
}) })
} }
const getActions = (row: ExpiringAssetItem) => [ const getActions = (row: ExpiringAssetItem) => [
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) }, { label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) }
...(row.can_renew
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
: [])
] ]
onMounted(() => { onMounted(() => {
const assetType = route.query.asset_type const assetType = route.query.asset_type
if (assetType === 'card' || assetType === 'device') if (assetType === 'card' || assetType === 'iot_card' || assetType === 'device') {
searchForm.asset_type = assetType as AssetType searchForm.asset_type = assetType === 'card' ? 'iot_card' : (assetType as ExpiringAssetType)
}
void Promise.all([loadAssets(), searchShops(), searchPackages()]) void Promise.all([loadAssets(), searchShops(), searchPackages()])
}) })
</script> </script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportAgentRechargeTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportAgentWalletTransactionTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportExchangeTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportPackageTaskList' })
</script>

View File

@@ -0,0 +1,9 @@
<template>
<ExportTaskList />
</template>
<script setup lang="ts">
import ExportTaskList from '../export-task-list/index.vue'
defineOptions({ name: 'ExportRefundTaskList' })
</script>

View File

@@ -14,7 +14,6 @@
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask"> <ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
下载文件 下载文件
</ElButton> </ElButton>
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
</div> </div>
</div> </div>
@@ -41,7 +40,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted } from 'vue' import { computed, h, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElMessageBox, ElTag } from 'element-plus' import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue' import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue' import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue' import type { DetailSection } from '@/components/common/DetailPage.vue'
@@ -104,22 +103,11 @@
return status ? statusTypeMap[status] : 'info' return status ? statusTypeMap[status] : 'info'
} }
const canCancelTask = computed(
() =>
canViewDetail.value &&
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
) &&
!!taskPermissions.value &&
hasAuth(taskPermissions.value.cancel)
)
const detailSections = computed((): DetailSection[] => [ const detailSections = computed((): DetailSection[] => [
{ {
title: '任务基本信息', title: '任务基本信息',
fields: [ fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' }, { label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ label: '任务ID', prop: 'task_id', formatter: (value: number) => String(value ?? '-') },
{ {
label: '导出场景', label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene)) render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
@@ -269,35 +257,6 @@
window.open(taskDetail.value.download_url, '_blank') window.open(taskDetail.value.download_url, '_blank')
} }
const cancelTask = () => {
if (!taskDetail.value) return
if (!canCancelTask.value) {
ElMessage.warning('您没有取消该导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(
taskDetail.value!.task_id ?? taskDetail.value!.id
)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
polling.retry()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
onMounted(() => { onMounted(() => {
loadTaskDetail() loadTaskDetail()
}) })

View File

@@ -25,7 +25,7 @@
:total="pagination.total" :total="pagination.total"
:marginTop="10" :marginTop="10"
:actions="getActions" :actions="getActions"
:actionsWidth="180" :actionsWidth="120"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
> >
@@ -41,7 +41,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, reactive, ref, watch } from 'vue' import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus' import { ElMessage, ElProgress, ElTag } from 'element-plus'
import { ExportTaskService } from '@/api/modules' import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -231,6 +231,12 @@
} }
} }
const toRfc3339 = (date: string, endOfDay = false) => {
if (!date) return undefined
if (date.includes('T')) return date
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
}
const getTableData = async () => { const getTableData = async () => {
loading.value = true loading.value = true
try { try {
@@ -240,8 +246,8 @@
page_size: pagination.pageSize, page_size: pagination.pageSize,
scene: currentScene.value, scene: currentScene.value,
status: searchForm.status, status: searchForm.status,
start_time: searchForm.start_time || undefined, start_time: toRfc3339(searchForm.start_time),
end_time: searchForm.end_time || undefined end_time: toRfc3339(searchForm.end_time, true)
}) })
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
@@ -347,32 +353,6 @@
} }
} }
const cancelTask = (row: ExportTaskItem) => {
if (!hasAuth(getRowPermissions(row).cancel)) {
ElMessage.warning('您没有取消导出任务的权限')
return
}
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(row.task_id ?? row.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTableData()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
const getActions = (row: ExportTaskItem) => { const getActions = (row: ExportTaskItem) => {
const actions: any[] = [] const actions: any[] = []
const permissions = getRowPermissions(row) const permissions = getRowPermissions(row)
@@ -381,13 +361,6 @@
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' }) actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
} }
if (
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
hasAuth(permissions.cancel)
) {
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
}
return actions return actions
} }

View File

@@ -0,0 +1,334 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">批量订购任务详情</h2>
<ElTag v-if="taskDetail" :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
<div v-if="loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36"><Loading /></ElIcon>
<div>加载中...</div>
</div>
<template v-else-if="taskDetail">
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{ taskDetail.task_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="订单文件" :span="2">
<span class="ellipsis-value" :title="taskDetail.file_name || '-'">
{{ taskDetail.file_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐名称">
<span class="ellipsis-value" :title="taskDetail.package_name || '-'">
{{ taskDetail.package_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐编码">
<span class="ellipsis-value" :title="taskDetail.package_code || '-'">
{{ taskDetail.package_code || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">
{{ getPaymentMethodName(taskDetail.payment_method) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.fail_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建人">{{
taskDetail.creator_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="支付凭证">
{{
taskDetail.voucher_keys?.length
? `已上传 ${taskDetail.voucher_keys.length}`
: '未上传'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(taskDetail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{
formatDateTime(taskDetail.started_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{
formatDateTime(taskDetail.completed_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem v-if="taskDetail.error_message" label="错误信息" :span="4">
{{ taskDetail.error_message }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<template v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)">
<div class="items-toolbar">
<ElSelect
v-model="itemStatus"
clearable
placeholder="筛选行状态"
style="width: 140px"
>
<ElOption label="成功" :value="BulkPurchaseTaskStatus.COMPLETED" />
<ElOption label="失败" :value="BulkPurchaseTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="loading" @click="refreshTask">刷新结果</ElButton>
</div>
<ElTable v-loading="loading" :data="paginatedItems" border>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{ scope.row.line ?? '-' }}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getItemStatusType(scope.row.status)">
{{ scope.row.status_name || getItemStatusName(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
<ElTableColumn label="金额" width="120">
<template #default="scope">{{ formatMoney(scope.row.amount) }}</template>
</ElTableColumn>
<ElTableColumn label="处理结果" min-width="240" show-overflow-tooltip>
<template #default="scope">{{ getItemReason(scope.row) }}</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="filteredItems.length"
@size-change="handleItemSizeChange"
/>
</div>
</template>
</template>
<ElEmpty v-else :description="forbidden ? '暂无查看权限' : '暂无任务详情'" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElIcon,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { BulkPurchaseService } from '@/api/modules'
import type { BulkPurchaseItem, BulkPurchasePaymentMethod, BulkPurchaseTask } from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'BulkPurchaseDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const itemStatus = ref<BulkPurchaseTaskStatus | undefined>()
const itemsPage = ref(1)
const itemsSize = ref(20)
const taskId = Number(route.params.id)
const polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: `bulk-purchase-detail:${taskId}`,
autoRestore: false,
fetchTask: async (id) => {
const res = await BulkPurchaseService.getTask(id)
if (res.code === 403) {
const error = new Error('暂无查看该批量订购任务详情的权限') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) {
throw new Error(res.msg || '获取批量订购任务详情失败')
}
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
const taskDetail = polling.task
const loading = polling.loading
const forbidden = polling.forbidden
const loadTaskDetail = async () => {
if (!taskId) {
ElMessage.error('缺少任务 ID 参数')
goBack()
return
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('暂无查看该批量订购任务详情的权限')
}
const goBack = () => {
router.back()
}
const refreshTask = () => {
void polling.retry()
}
const getStatusName = (status?: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return status ? names[status] || '-' : '-'
}
const getStatusType = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const getPaymentMethodName = (method?: BulkPurchasePaymentMethod) => {
return method === 'offline' ? '线下支付' : method === 'wallet' ? '代理钱包' : '-'
}
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
const getItemStatusName = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return '成功'
if (status === BulkPurchaseTaskStatus.FAILED) return '失败'
return '-'
}
const getItemStatusType = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
return 'info'
}
const getItemReason = (item: BulkPurchaseItem) =>
item.reason || item.error_summary || item.error_reason || '-'
const filteredItems = computed(() => {
const items = taskDetail.value?.items || []
if (itemStatus.value === undefined) return items
return items.filter((item) => item.status === itemStatus.value)
})
const paginatedItems = computed(() => {
const start = (itemsPage.value - 1) * itemsSize.value
return filteredItems.value.slice(start, start + itemsSize.value)
})
const handleItemSizeChange = () => {
itemsPage.value = 1
}
watch(itemStatus, () => {
itemsPage.value = 1
})
onMounted(() => {
void loadTaskDetail()
})
</script>
<style scoped lang="scss">
.bulk-purchase-detail-page {
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-loading {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 220px;
color: var(--el-text-color-secondary);
}
.ellipsis-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,506 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="批量订购套餐"
direction="rtl"
size="520px"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="套餐" prop="package_id">
<ElSelect
v-model="form.package_id"
filterable
remote
clearable
:remote-method="handlePackageSearch"
:loading="packageLoading"
placeholder="请输入套餐名称搜索"
style="width: 100%"
@visible-change="handlePackageVisibleChange"
>
<ElOption
v-for="pkg in packageOptions"
:key="pkg.id"
:label="pkg.package_name"
:value="pkg.id"
>
<span class="package-option">{{ pkg.package_name }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElRadioGroup v-model="form.payment_method" @change="handlePaymentMethodChange">
<ElRadio value="wallet">代理钱包</ElRadio>
<ElRadio value="offline">线下支付</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="订单文件" prop="orderFile">
<VoucherUpload
ref="orderUploadRef"
v-model="orderFileKeys"
voucher-name="订单文件"
:max-count="1"
purpose="batch_purchase"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV最多 1000 行,最大 10MB"
@uploading-change="orderFileUploading = $event"
@change="formRef?.validateField('orderFile')"
/>
</ElFormItem>
<ElFormItem
v-if="form.payment_method === 'offline'"
label="整批支付凭证"
prop="voucherFile"
>
<VoucherUpload
ref="voucherUploadRef"
v-model="voucherFileKeys"
voucher-name="整批支付凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('voucherFile')"
/>
</ElFormItem>
<ElAlert
v-if="createDrawerBusy"
type="info"
:closable="false"
show-icon
:title="
voucherUploading
? '支付凭证上传中,请稍候'
: orderFileUploading
? '订单文件上传中,请稍候'
: '批量订购文件上传及任务创建中,请勿重复提交'
"
/>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:loading="createDrawerBusy"
:disabled="!hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
@click="submitTask"
>
创建批量订购任务
</ElButton>
</div>
</template>
</ElDrawer>
<ElCard
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
shadow="never"
class="art-table-card task-list-card"
>
<template #header>
<div class="task-list-header">
<span>批量订购任务</span>
<div class="task-list-toolbar">
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
tag="a"
href="/templates/bulk-purchase-template.csv"
download="批量订购套餐模板.csv"
>
下载模板
</ElButton>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
type="primary"
@click="openCreateDrawer"
>
批量订购套餐
</ElButton>
<ElSelect
v-model="taskListStatus"
clearable
placeholder="筛选任务状态"
style="width: 150px"
@change="handleTaskListStatusChange"
>
<ElOption label="待处理" :value="BulkPurchaseTaskStatus.PENDING" />
<ElOption label="处理中" :value="BulkPurchaseTaskStatus.PROCESSING" />
<ElOption label="已完成" :value="BulkPurchaseTaskStatus.COMPLETED" />
<ElOption label="已失败" :value="BulkPurchaseTaskStatus.FAILED" />
<ElOption label="已取消" :value="BulkPurchaseTaskStatus.CANCELED" />
</ElSelect>
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
</div>
</div>
</template>
<ElTable v-loading="taskListLoading" :data="taskList" border>
<ElTableColumn label="任务号" min-width="200" show-overflow-tooltip>
<template #default="scope">
<ElButton type="primary" link @click="showTask(scope.row)">
{{ scope.row.task_no || '-' }}
</ElButton>
</template>
</ElTableColumn>
<ElTableColumn prop="file_name" label="订单文件" min-width="180" show-overflow-tooltip />
<ElTableColumn
prop="package_name"
label="套餐名称"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn
prop="package_code"
label="套餐编码"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="支付方式" width="110">
<template #default="scope">{{
getPaymentMethodName(scope.row.payment_method)
}}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getStatusType(scope.row.status)">
{{ scope.row.status_name || getStatusName(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_count" label="总数" width="90" />
<ElTableColumn prop="success_count" label="成功数" width="90" />
<ElTableColumn prop="fail_count" label="失败数" width="90" />
<ElTableColumn label="创建时间" width="180">
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="taskListPage"
v-model:page-size="taskListSize"
layout="total, sizes, prev, pager, next"
:total="taskListTotal"
@current-change="loadTaskList"
@size-change="handleTaskListSizeChange"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDrawer,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BulkPurchaseService, PackageManageService } from '@/api/modules'
import type { BulkPurchasePaymentMethod, BulkPurchaseTask, PackageResponse } from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'BulkPurchase' })
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDrawerVisible = ref(false)
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
const voucherFileKeys = ref<string[]>([])
const voucherUploading = ref(false)
const packageOptions = ref<PackageResponse[]>([])
const packageLoading = ref(false)
const taskList = ref<BulkPurchaseTask[]>([])
const taskListLoading = ref(false)
const taskListPage = ref(1)
const taskListSize = ref(20)
const taskListTotal = ref(0)
const taskListStatus = ref<BulkPurchaseTaskStatus | undefined>()
const createDrawerBusy = computed(
() => submitting.value || voucherUploading.value || orderFileUploading.value
)
const form = reactive<{
package_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
package_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
package_id: [{ required: true, message: '请选择套餐', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
orderFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (orderFileKeys.value.length === 0) callback(new Error('请上传订单文件'))
else callback()
},
trigger: 'change'
}
],
voucherFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (form.payment_method === 'offline' && voucherFileKeys.value.length === 0) {
callback(new Error('线下支付必须上传整批支付凭证'))
} else callback()
},
trigger: 'change'
}
]
}))
const loadPackages = async (query = '') => {
packageLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query.trim() || undefined,
status: 1,
shelf_status: 1
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取套餐列表失败')
return
}
packageOptions.value = (res.data?.items || []).filter(
(item) => item.status === 1 && item.shelf_status === 1
)
} catch (error: any) {
ElMessage.error(error?.message || '获取套餐列表失败')
} finally {
packageLoading.value = false
}
}
const handlePackageSearch = (query: string) => {
void loadPackages(query)
}
const handlePackageVisibleChange = (visible: boolean) => {
if (visible && packageOptions.value.length === 0) void loadPackages()
}
const openCreateDrawer = () => {
createDrawerVisible.value = true
if (packageOptions.value.length === 0) void loadPackages()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
form.package_id = undefined
form.payment_method = 'wallet'
orderFileKeys.value = []
voucherFileKeys.value = []
orderUploadRef.value?.clearFiles(false)
voucherUploadRef.value?.clearFiles(false)
orderFileUploading.value = false
voucherUploading.value = false
}
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
if (value === 'wallet') {
voucherFileKeys.value = []
voucherUploadRef.value?.clearFiles()
formRef.value?.clearValidate('voucherFile')
}
}
const submitTask = async () => {
if (
!hasAuth(BULK_PURCHASE_PERMISSIONS.create) ||
submitting.value ||
voucherUploading.value ||
orderFileUploading.value
)
return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = {
file_key: orderFileKeys.value[0],
package_id: form.package_id,
payment_method: form.payment_method,
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
}
const res = await BulkPurchaseService.createTask(data)
const taskId = res.data?.task_id ?? res.data?.id
if (res.code !== 0 || !taskId) {
ElMessage.error(res.msg || '创建批量订购任务失败')
return
}
ElMessage.success(res.data.message || '批量订购任务已创建')
createDrawerVisible.value = false
await router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
submitting.value = false
}
}
const loadTaskList = async () => {
if (!hasAuth(BULK_PURCHASE_PERMISSIONS.detail)) return
taskListLoading.value = true
try {
const res = await BulkPurchaseService.getTasks({
page: taskListPage.value,
page_size: taskListSize.value,
status: taskListStatus.value
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取批量订购任务列表失败')
return
}
taskList.value = res.data?.items || []
taskListTotal.value = res.data?.total || 0
} catch (error: any) {
ElMessage.error(error?.message || '获取批量订购任务列表失败')
} finally {
taskListLoading.value = false
}
}
const handleTaskListStatusChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const handleTaskListSizeChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const showTask = (task: BulkPurchaseTask) => {
const taskId = task.task_id ?? task.id
if (!taskId) return
void router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
}
const getStatusName = (status: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return names[status] || '-'
}
const getStatusType = (status: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const getPaymentMethodName = (method?: BulkPurchasePaymentMethod) => {
return method === 'offline' ? '线下支付' : method === 'wallet' ? '代理钱包' : '-'
}
onMounted(() => {
void loadTaskList()
})
</script>
<style scoped lang="scss">
.bulk-purchase-page {
.task-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.task-list-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.package-option {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.create-form {
padding-bottom: 8px;
}
.drawer-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
@media (max-width: 768px) {
.task-list-header {
align-items: flex-start;
flex-direction: column;
}
.task-list-toolbar {
width: 100%;
.el-select {
flex: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,506 @@
<template>
<ArtTableFullScreen>
<div class="device-batch-allocation-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="设备批量分配"
direction="rtl"
size="520px"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" label-width="110px" class="create-form">
<ElFormItem label="操作类型" prop="operation_type">
<ElSelect
v-model="form.operation_type"
style="width: 100%"
@change="handleOperationTypeChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="requiresTargetId" label="目标 ID" prop="target_id">
<ElSelect
v-model="form.target_id"
filterable
remote
clearable
:remote-method="handleTargetSearch"
:loading="targetLoading"
:placeholder="targetPlaceholder"
style="width: 100%"
@visible-change="handleTargetVisibleChange"
>
<template v-if="form.operation_type === 'assign_shop'">
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</template>
<template v-else>
<ElOption
v-for="series in seriesOptions"
:key="series.id"
:label="series.series_name"
:value="series.id"
/>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem v-else label="回收目标">
<ElTag type="warning">回收到平台库存</ElTag>
</ElFormItem>
<ElFormItem label="设备文件" prop="file_key">
<VoucherUpload
ref="uploadRef"
v-model="fileKeys"
voucher-name="设备标识文件"
:max-count="1"
purpose="device_batch_allocation"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV最多 1000 行,最大 10MB"
@uploading-change="fileUploading = $event"
/>
</ElFormItem>
<ElAlert
v-if="createDrawerBusy"
type="info"
:closable="false"
show-icon
:title="fileUploading ? '设备文件上传中,请稍候' : '任务创建中,请勿重复提交'"
/>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:loading="submitting"
:disabled="!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
@click="submitTask"
>
创建分配任务
</ElButton>
</div>
</template>
</ElDrawer>
<ElCard shadow="never" class="art-table-card task-list-card">
<template #header>
<div class="task-list-header">
<span>设备批量分配任务</span>
<div class="task-list-toolbar">
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
tag="a"
href="/templates/device-batch-allocation-template.csv"
download="设备批量分配模板.csv"
>
下载模板
</ElButton>
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
type="primary"
@click="openCreateDrawer"
>
设备批量分配
</ElButton>
<ElSelect
v-model="taskListOperationType"
clearable
placeholder="筛选操作类型"
style="width: 170px"
@change="handleTaskListFilterChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
<ElSelect
v-model="taskListStatus"
clearable
placeholder="筛选任务状态"
style="width: 150px"
@change="handleTaskListFilterChange"
>
<ElOption label="待处理" :value="DeviceImportTaskStatus.PENDING" />
<ElOption label="处理中" :value="DeviceImportTaskStatus.PROCESSING" />
<ElOption label="已完成" :value="DeviceImportTaskStatus.COMPLETED" />
<ElOption label="失败" :value="DeviceImportTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
</div>
</div>
</template>
<ElTable v-loading="taskListLoading" :data="taskList" border>
<ElTableColumn label="任务号" min-width="210" show-overflow-tooltip>
<template #default="scope">
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.detail)"
type="primary"
link
@click="showTask(scope.row)"
>
{{ scope.row.task_no || '-' }}
</ElButton>
<span v-else>{{ scope.row.task_no || '-' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="150">
<template #default="scope">
<ElTag :type="getOperationTagType(scope.row.operation_type)">
{{ getOperationLabel(scope.row.operation_type) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="operation_name"
label="操作名称"
min-width="170"
show-overflow-tooltip
>
<template #default="scope">
{{ scope.row.operation_name || getOperationLabel(scope.row.operation_type) }}
</template>
</ElTableColumn>
<ElTableColumn label="目标 ID" width="110">
<template #default="scope">{{ scope.row.target_id ?? '-' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getStatusTagType(scope.row.status)">
{{ scope.row.status_name || scope.row.status_text || '-' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="total_count" label="总数" width="90" />
<ElTableColumn prop="success_count" label="成功数" width="90" />
<ElTableColumn prop="fail_count" label="失败数" width="90" />
<ElTableColumn prop="skip_count" label="跳过数" width="90" />
<ElTableColumn label="创建时间" width="180">
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="完成时间" width="180">
<template #default="scope">
{{ scope.row.completed_at ? formatDateTime(scope.row.completed_at) : '-' }}
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="taskListPage"
v-model:page-size="taskListSize"
layout="total, sizes, prev, pager, next"
:total="taskListTotal"
@current-change="loadTaskList"
@size-change="handleTaskListSizeChange"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDrawer,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { DeviceService, PackageSeriesService, ShopService } from '@/api/modules'
import { DeviceImportTaskStatus } from '@/types/api/device'
import type {
DeviceBatchAllocationRequest,
DeviceImportTask,
DeviceImportTaskOperationType
} from '@/types/api/device'
import type { PackageSeriesResponse, ShopResponse } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceBatchAllocation' })
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDrawerVisible = ref(false)
const submitting = ref(false)
const fileUploading = ref(false)
const fileKeys = ref<string[]>([])
const taskList = ref<DeviceImportTask[]>([])
const taskListLoading = ref(false)
const taskListPage = ref(1)
const taskListSize = ref(20)
const taskListTotal = ref(0)
const taskListStatus = ref<DeviceImportTaskStatus | undefined>()
const taskListOperationType = ref<Exclude<DeviceImportTaskOperationType, 'import'> | undefined>()
const shopOptions = ref<ShopResponse[]>([])
const seriesOptions = ref<PackageSeriesResponse[]>([])
const targetLoading = ref(false)
const form = reactive<{
operation_type: Exclude<DeviceImportTaskOperationType, 'import'>
target_id?: number
}>({
operation_type: 'assign_shop',
target_id: undefined
})
const requiresTargetId = computed(() =>
['assign_shop', 'assign_series'].includes(form.operation_type)
)
const targetPlaceholder = computed(() =>
form.operation_type === 'assign_shop' ? '请选择启用的目标店铺' : '请选择启用的套餐系列'
)
const createDrawerBusy = computed(() => submitting.value || fileUploading.value)
const getOperationLabel = (operationType: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
default:
return '设备导入'
}
}
const getOperationTagType = (operationType: DeviceImportTaskOperationType) =>
operationType === 'recall' ? 'warning' : 'primary'
const getStatusTagType = (status: DeviceImportTaskStatus) => {
switch (status) {
case DeviceImportTaskStatus.PENDING:
return 'info'
case DeviceImportTaskStatus.PROCESSING:
return 'warning'
case DeviceImportTaskStatus.COMPLETED:
return 'success'
case DeviceImportTaskStatus.FAILED:
return 'danger'
default:
return 'info'
}
}
const openCreateDrawer = () => {
if (!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)) return
createDrawerVisible.value = true
}
const loadTargetOptions = async (query = '') => {
if (form.operation_type === 'recall') return
targetLoading.value = true
try {
if (form.operation_type === 'assign_shop') {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
status: 1,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} else {
const res = await PackageSeriesService.getPackageSeries({
page: 1,
page_size: 20,
status: 1,
series_name: query || undefined
})
if (res.code === 0) seriesOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取批量分配目标失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
targetLoading.value = false
}
}
const handleTargetSearch = (query: string) => {
void loadTargetOptions(query)
}
const handleTargetVisibleChange = (visible: boolean) => {
if (
visible &&
(form.operation_type === 'assign_shop'
? !shopOptions.value.length
: !seriesOptions.value.length)
) {
void loadTargetOptions()
}
}
const handleOperationTypeChange = () => {
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
if (form.operation_type !== 'recall') void loadTargetOptions()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
form.operation_type = 'assign_shop'
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
fileKeys.value = []
fileUploading.value = false
uploadRef.value?.clearFiles()
}
const handleTaskListFilterChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const loadTaskList = async () => {
taskListLoading.value = true
try {
const res = await DeviceService.getImportTasks({
page: taskListPage.value,
page_size: taskListSize.value,
status: taskListStatus.value,
operation_type: taskListOperationType.value
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取设备批量分配任务失败')
return
}
taskList.value = (res.data.items || []).filter((item) => item.operation_type !== 'import')
taskListTotal.value = res.data.total || 0
} catch (error) {
console.error('获取设备批量分配任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
taskListLoading.value = false
}
}
const handleTaskListSizeChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const showTask = (row: DeviceImportTask) => {
router.push({
path: RoutesAlias.TaskDetail,
query: { id: row.id, task_type: 'device' }
})
}
const submitTask = async () => {
if (createDrawerBusy.value) return
if (!fileKeys.value[0]) {
ElMessage.warning('请先上传设备标识文件')
return
}
if (requiresTargetId.value && !form.target_id) {
ElMessage.warning('请选择目标店铺或套餐系列')
return
}
const request: DeviceBatchAllocationRequest = {
file_key: fileKeys.value[0],
operation_type: form.operation_type
}
if (requiresTargetId.value) request.target_id = form.target_id
submitting.value = true
try {
const res = await DeviceService.createAllocationTask(request)
if (res.code !== 0) {
ElMessage.error(res.msg || '创建设备批量分配任务失败')
return
}
createDrawerVisible.value = false
await loadTaskList()
ElMessage.success(`任务已创建:${res.data.task_no}`)
} catch (error) {
console.error('创建设备批量分配任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
submitting.value = false
}
}
onMounted(() => {
void loadTaskList()
})
</script>
<style lang="scss" scoped>
.device-batch-allocation-page {
.task-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.task-list-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 900px) {
.task-list-header {
align-items: flex-start;
flex-direction: column;
}
.task-list-toolbar {
justify-content: flex-start;
}
}
}
</style>

View File

@@ -1,14 +1,13 @@
<template> <template>
<ArtTableFullScreen> <ArtTableFullScreen>
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen"> <div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar <ArtSearchBar
v-model:filter="searchForm" v-model:filter="searchForm"
:items="searchFormItems" :items="searchFormItems"
:show-expand="false" :show-expand="false"
@reset="handleReset" @reset="handleReset"
@search="handleSearch" @search="handleSearch"
></ArtSearchBar> />
<div v-if="pollingError || pollingForbidden" class="task-polling-error"> <div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert <ElAlert
@@ -21,26 +20,18 @@
</div> </div>
<ElCard shadow="never" class="art-table-card"> <ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader <ArtTableHeader
:columnList="columnOptions" :columnList="columnOptions"
v-model:columns="columnChecks" v-model:columns="columnChecks"
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #left> <template #left>
<ElButton <ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
>
批量导入设备 批量导入设备
</ElButton> </ElButton>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
<!-- 表格 -->
<ArtTable <ArtTable
ref="tableRef" ref="tableRef"
row-key="id" row-key="id"
@@ -62,21 +53,20 @@
</ElCard> </ElCard>
</div> </div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="40%" align-center> <ElDialog v-model="importDialogVisible" title="批量导入设备" width="40%" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px"> <ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<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. 设备分配使用单列 UTF-8 CSV导入设备仍使用 Excel 模板</p> <p>1. 设备导入使用 Excel 模板文件大小不能超过 300MB</p>
<p>2. CSV 单次最多 1000 文件不超过 10MB</p> <p>2. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p> <p>3. <strong>列顺序固定不可调整</strong>系统按位置读取不识别列名</p>
<p>4. <strong>重要列顺序固定不可调整</strong>系统按位置读取不识别列名</p> <p style="color: var(--el-color-primary)">
<p style="color: var(--el-color-primary)">5. 必填列虚拟号1列</p> 4. 必填列设备标识 1 支持 VirtualNoIMEI SN
</p>
<p> <p>
6. 5. 可选列SN设备名称设备型号设备类型IMEI制造商最大 SIM 槽数 1~ 4
可选列SN设备名称设备型号设备类型IMEI制造商最大SIM槽数默认4有效范围1-4卡1~卡4 ICCID
ICCID
</p> </p>
</div> </div>
</template> </template>
@@ -88,23 +78,7 @@
</ElButton> </ElButton>
</div> </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"> <ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no"> <ElFormItem label="批次号" prop="batch_no">
<ElInput <ElInput
@@ -135,18 +109,18 @@
</ElCol> </ElCol>
</ElRow> </ElRow>
<ElUpload <ElUpload
ref="uploadRef" ref="uploadRef"
drag drag
:auto-upload="false" :auto-upload="false"
:on-change="handleFileChange" :on-change="handleFileChange"
:limit="1" :limit="1"
accept=".xlsx,.csv" accept=".xlsx"
> >
<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">将文件拖到此处<em>点击选择</em></div>
<template #tip> <template #tip>
<div class="el-upload__tip">导入设备支持 .xlsx批量分配支持单列 .csv</div> <div class="el-upload__tip">设备导入仅支持 .xlsx 文件</div>
</template> </template>
</ElUpload> </ElUpload>
@@ -166,12 +140,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h } from 'vue' import { h, onMounted, reactive, ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { DeviceService } from '@/api/modules' import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue' import { Download, Upload, UploadFilled } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus' import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -189,7 +162,6 @@
} from '@/types/api/device' } from '@/types/api/device'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { generatePackageCode } from '@/utils/codeGenerator' import { generatePackageCode } from '@/utils/codeGenerator'
import { JULY_PERMISSIONS } from '@/config/constants'
import { normalizeApiError } from '@/utils/business/apiError' import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceTask' }) defineOptions({ name: 'DeviceTask' })
@@ -208,40 +180,23 @@
const importDialogVisible = ref(false) const importDialogVisible = ref(false)
const importForm = reactive({ const importForm = reactive({
batch_no: '', batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order', realname_policy: '' as '' | RealnamePolicy
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
target_id: ''
}) })
// 搜索表单初始值
const initialSearchState = { const initialSearchState = {
status: undefined, status: undefined as DeviceImportTaskStatus | undefined,
batch_no: '', batch_no: '',
dateRange: undefined as string[] | undefined, dateRange: undefined as string[] | undefined
start_time: '',
end_time: ''
} }
// 搜索表单
const searchForm = reactive({ ...initialSearchState }) const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [ const searchFormItems: SearchFormItem[] = [
{ {
label: '任务状态', label: '任务状态',
prop: 'status', prop: 'status',
type: 'select', type: 'select',
config: { config: { clearable: true, placeholder: '全部' },
clearable: true,
placeholder: '全部'
},
options: () => [ options: () => [
{ label: '待处理', value: 1 }, { label: '待处理', value: 1 },
{ label: '处理中', value: 2 }, { label: '处理中', value: 2 },
@@ -253,10 +208,7 @@
label: '批次号', label: '批次号',
prop: 'batch_no', prop: 'batch_no',
type: 'input', type: 'input',
config: { config: { clearable: true, placeholder: '请输入批次号' }
clearable: true,
placeholder: '请输入批次号'
}
}, },
{ {
label: '创建时间', label: '创建时间',
@@ -271,7 +223,6 @@
} }
] ]
// 列配置
const columnOptions = [ const columnOptions = [
{ label: '任务编号', prop: 'task_no' }, { label: '任务编号', prop: 'task_no' },
{ label: '任务状态', prop: 'status' }, { label: '任务状态', prop: 'status' },
@@ -288,7 +239,6 @@
] ]
const taskList = ref<DeviceImportTask[]>([]) const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({ const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task', storageKey: 'device-import-active-task',
autoRestore: false, autoRestore: false,
@@ -306,7 +256,6 @@
const pollingError = polling.error const pollingError = polling.error
const pollingForbidden = polling.forbidden const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => { const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) { switch (status) {
case 1: case 1:
@@ -322,19 +271,14 @@
} }
} }
// 查看详情
const viewDetail = (row: DeviceImportTask) => { const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return if (!isPlatformAccount.value) return
router.push({ router.push({
path: RoutesAlias.TaskDetail, path: RoutesAlias.TaskDetail,
query: { query: { id: row.id, task_type: 'device' }
id: row.id,
task_type: 'device'
}
}) })
} }
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => { const handleNameClick = (row: DeviceImportTask) => {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) { if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row) viewDetail(row)
@@ -343,15 +287,14 @@
} }
} }
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
prop: 'task_no', prop: 'task_no',
label: '任务编号', label: '任务编号',
width: 220, width: 220,
showOverflowTooltip: true, showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) =>
return h( h(
'span', 'span',
{ {
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;', style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
@@ -362,48 +305,31 @@
}, },
row.task_no row.task_no
) )
}
}, },
{ {
prop: 'status', prop: 'status',
label: '任务状态', label: '任务状态',
width: 100, width: 100,
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) =>
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text) h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || row.status_text)
}
},
{
prop: 'total_count',
label: '总数',
width: 80
}, },
{ prop: 'total_count', label: '总数', width: 80 },
{ {
prop: 'success_count', prop: 'success_count',
label: '成功数', label: '成功数',
width: 80, width: 80,
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) =>
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count) h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
}, },
{ {
prop: 'fail_count', prop: 'fail_count',
label: '失败数', label: '失败数',
width: 80, width: 80,
formatter: (row: DeviceImportTask) => { formatter: (row: DeviceImportTask) =>
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count) h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
prop: 'skip_count',
label: '跳过数',
width: 80
},
{
prop: 'batch_no',
label: '批次号',
width: 180,
showOverflowTooltip: true
}, },
{ prop: 'skip_count', label: '跳过数', width: 80 },
{ prop: 'batch_no', label: '批次号', width: 180, showOverflowTooltip: true },
{ {
prop: 'started_at', prop: 'started_at',
label: '开始时间', label: '开始时间',
@@ -424,12 +350,7 @@
showOverflowTooltip: true, showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => row.error_message || '-' formatter: (row: DeviceImportTask) => row.error_message || '-'
}, },
{ { prop: 'file_name', label: '文件名', minWidth: 180, showOverflowTooltip: true },
prop: 'file_name',
label: '文件名',
minWidth: 180,
showOverflowTooltip: true
},
{ {
prop: 'creator_name', prop: 'creator_name',
label: '操作人', label: '操作人',
@@ -460,7 +381,6 @@
} }
}) })
// 获取设备任务列表
const getTableData = async () => { const getTableData = async () => {
if (!isPlatformAccount.value) { if (!isPlatformAccount.value) {
taskList.value = [] taskList.value = []
@@ -473,10 +393,9 @@
page: pagination.page, page: pagination.page,
page_size: pagination.pageSize, page_size: pagination.pageSize,
status: searchForm.status, status: searchForm.status,
operation_type: 'import',
batch_no: searchForm.batch_no || undefined batch_no: searchForm.batch_no || undefined
} }
// 处理时间范围
if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) { if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) {
params.start_time = searchForm.dateRange[0] params.start_time = searchForm.dateRange[0]
params.end_time = searchForm.dateRange[1] params.end_time = searchForm.dateRange[1]
@@ -484,9 +403,7 @@
const res = await DeviceService.getImportTasks(params) const res = await DeviceService.getImportTasks(params)
if (res.code === 0) { if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null }) taskList.value = res.data.items || []
.items
taskList.value = taskItems || []
pagination.total = res.data.total || 0 pagination.total = res.data.total || 0
} }
} catch (error) { } catch (error) {
@@ -497,37 +414,30 @@
} }
} }
// 重置搜索
const handleReset = () => { const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState }) Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1 pagination.page = 1
getTableData() void getTableData()
} }
// 搜索
const handleSearch = () => { const handleSearch = () => {
pagination.page = 1 pagination.page = 1
getTableData() void getTableData()
} }
// 刷新表格 const handleRefresh = () => void getTableData()
const handleRefresh = () => {
getTableData()
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => { const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize pagination.pageSize = newPageSize
getTableData() void getTableData()
} }
const handleCurrentChange = (newCurrentPage: number) => { const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage pagination.page = newCurrentPage
getTableData() void getTableData()
} }
// 下载模板 const downloadTemplate = () => {
const downloadTemplate = async () => {
try { try {
const link = document.createElement('a') const link = document.createElement('a')
link.href = new URL('@/template/设备导入模板.xlsx', import.meta.url).href link.href = new URL('@/template/设备导入模板.xlsx', import.meta.url).href
@@ -542,107 +452,75 @@
} }
} }
// 文件选择变化 const handleFileChange = (uploadFile: any) => {
const handleFileChange = async (uploadFile: any) => { const file = uploadFile.raw as File | undefined
const isCsvAllocation = importForm.operation_type !== 'import' if (!file) return
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024 if (file.size > 300 * 1024 * 1024) {
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 300MB') ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles() uploadRef.value?.clearFiles()
fileList.value = [] fileList.value = []
return return
} }
if (!file.name.toLowerCase().endsWith('.xlsx')) {
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) { ElMessage.error('设备导入只能上传 .xlsx 文件')
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
uploadRef.value?.clearFiles() uploadRef.value?.clearFiles()
fileList.value = [] fileList.value = []
return return
} }
fileList.value = [file]
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] : []
} }
// 清空文件
const clearFiles = () => { const clearFiles = () => {
uploadRef.value?.clearFiles() uploadRef.value?.clearFiles()
fileList.value = [] fileList.value = []
} }
// 生成批次号
const handleGenerateBatchNo = () => { const handleGenerateBatchNo = () => {
const code = generatePackageCode().replace('PKG', 'DEV') importForm.batch_no = generatePackageCode().replace('PKG', 'DEV')
importForm.batch_no = code
ElMessage.success('批次号生成成功') ElMessage.success('批次号生成成功')
} }
// 取消导入
const handleCancelImport = () => { const handleCancelImport = () => {
clearFiles() clearFiles()
importForm.batch_no = '' importForm.batch_no = ''
importForm.realname_policy = '' importForm.realname_policy = ''
importForm.operation_type = 'import'
importForm.target_id = ''
importDialogVisible.value = false importDialogVisible.value = false
} }
// 提交上传
const submitUpload = async () => { const submitUpload = async () => {
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return if (!isPlatformAccount.value || !fileList.value.length) {
if (!fileList.value.length) { ElMessage.warning('请先选择文件')
ElMessage.warning('请先选择文件') return
return }
}
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
ElMessage.warning('请输入有效的目标 ID')
return
}
const file = fileList.value[0] const file = fileList.value[0]
uploading.value = true uploading.value = true
try { try {
ElMessage.info('正在准备上传...') ElMessage.info('正在准备上传...')
const isAllocation = importForm.operation_type !== 'import' const uploadUrlRes = await StorageService.getUploadUrl({
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' file_name: file.name,
const uploadUrlRes = await StorageService.getUploadUrl({ content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
file_name: file.name, purpose: 'iot_import'
content_type: contentType,
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
}) })
if (uploadUrlRes.code !== 0) { if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败') ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return return
} }
const { upload_url, file_key } = uploadUrlRes.data const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...') ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, contentType) await StorageService.uploadFile(
upload_url,
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...') file,
const importRes = isAllocation 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
? 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
})
ElMessage.info('正在创建设备导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
if (importRes.code !== 0) { if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败') ElMessage.error(importRes.msg || '创建导入任务失败')
return return
@@ -650,12 +528,10 @@
const taskNo = importRes.data.task_no const taskNo = importRes.data.task_no
const taskId = importRes.data.task_id const taskId = importRes.data.task_id
handleCancelImport() handleCancelImport()
await router.replace({ path: route.path, query: { task_id: String(taskId) } }) await router.replace({ path: route.path, query: { task_id: String(taskId) } })
await polling.start(taskId) await polling.start(taskId)
await getTableData() await getTableData()
ElMessage.success({ ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`, message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000, duration: 3000,
@@ -669,62 +545,51 @@
} }
} }
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => { const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return if (!isPlatformAccount.value) return
try { try {
const res = await DeviceService.getImportTaskDetail(row.id) const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) { if (res.code !== 0 || !res.data) return
const detail = res.data const failReasons =
const failReasons = res.data.failed_items?.map((item) => ({
detail.failed_items?.map((item: any) => ({ line: item.line || '-',
line: item.line || '-', deviceNo: item.device_identifier || item.virtual_no || '-',
deviceNo: item.virtual_no || '-', message: item.reason || '未知错误'
message: item.reason || '未知错误' })) || []
})) || [] if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
if (failReasons.length === 0) { return
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', '设备编号', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.line, `\t${item.deviceNo}`, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${row.batch_no}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
} }
const csvRows = [
['行号', '设备编号', '失败原因'].join(','),
...failReasons.map((item) =>
[item.line, `\t${item.deviceNo}`, `"${item.message.replaceAll('"', '""')}"`].join(',')
)
]
const blob = new Blob(['\uFEFF' + csvRows.join('\n')], {
type: 'text/csv;charset=utf-8;'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `导入失败数据_${row.batch_no}.csv`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
} catch (error) { } catch (error) {
console.error('下载失败数据失败:', error) console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
} }
} }
const downloadTaskFile = async (row: DeviceImportTask) => { const downloadTaskFile = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return if (!isPlatformAccount.value) return
const fileKey = row.file_name?.trim() const fileKey = row.file_key || row.file_name?.trim()
if (!fileKey) { if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件') ElMessage.warning('当前任务没有可下载的原始文件')
return return
} }
try { try {
await StorageService.downloadFileByKey(fileKey) await StorageService.downloadFileByKey(fileKey)
ElMessage.success('原始文件下载已开始') ElMessage.success('原始文件下载已开始')
@@ -734,20 +599,12 @@
} }
} }
// 获取操作按钮
const getActions = (row: DeviceImportTask) => { const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return [] if (!isPlatformAccount.value) return []
const actions: any[] = [] const actions: any[] = []
const showDownloadFileAction = false if (row.file_key && hasAuth('device_task:download_file')) {
actions.push({ label: '下载文件', handler: () => downloadTaskFile(row), type: 'primary' })
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')) { if (row.fail_count > 0 && hasAuth('device_task:download_fail_data')) {
actions.push({ actions.push({
label: '失败数据', label: '失败数据',
@@ -755,7 +612,6 @@
type: 'danger' type: 'danger'
}) })
} }
return actions return actions
} }
</script> </script>

View File

@@ -2,7 +2,6 @@
<ArtTableFullScreen> <ArtTableFullScreen>
<div class="task-detail-page" id="table-full-screen"> <div class="task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card"> <ElCard shadow="never" class="art-table-card">
<!-- 页面头部 -->
<div class="detail-header"> <div class="detail-header">
<ElButton @click="goBack"> <ElButton @click="goBack">
<template #icon> <template #icon>
@@ -10,35 +9,59 @@
</template> </template>
返回 返回
</ElButton> </ElButton>
<h2 class="detail-title">任务详情</h2> <h2 class="detail-title">{{ taskTitle }}</h2>
</div> </div>
<!-- 使用 DetailPage 组件显示任务信息 -->
<DetailPage v-if="taskDetail" :sections="detailSections" :data="taskDetail" /> <DetailPage v-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<!-- 失败记录 --> <div v-if="taskDetail?.fail_count && taskDetail.fail_count > 0" class="result-section">
<div class="failure-section" v-if="taskDetail?.fail_count && taskDetail.fail_count > 0">
<ElDivider content-position="left"> <ElDivider content-position="left">
<span class="section-title">失败记录 ({{ taskDetail.fail_count }})</span> <span class="section-title"
>{{ resultAction }}失败记录 ({{ taskDetail.fail_count }})</span
>
</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 v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" /> <ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备" min-width="180" /> <ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<ElTableColumn prop="reason" label="失败原因" min-width="300" /> <template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}失败原因`" min-width="300" />
</ElTable> </ElTable>
</div> </div>
<!-- 跳过记录 --> <div v-if="taskDetail?.skip_count && taskDetail.skip_count > 0" class="result-section">
<div class="skipped-section" v-if="taskDetail?.skip_count && taskDetail.skip_count > 0">
<ElDivider content-position="left"> <ElDivider content-position="left">
<span class="section-title">跳过记录 ({{ taskDetail.skip_count }})</span> <span class="section-title"
>{{ resultAction }}跳过记录 ({{ taskDetail.skip_count }})</span
>
</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 v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" /> <ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备" min-width="180" /> <ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<ElTableColumn prop="reason" label="跳过原因" min-width="300" /> <template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}跳过原因`" min-width="300" />
</ElTable>
</div>
<div v-if="warningCount > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">{{ resultAction }}警告记录 ({{ warningCount }})</span>
</ElDivider>
<ElTable :data="warningItems" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" label="警告原因" min-width="300" />
</ElTable> </ElTable>
</div> </div>
</ElCard> </ElCard>
@@ -47,16 +70,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, h } from 'vue' import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { CardService, DeviceService } from '@/api/modules' import { CardService, DeviceService } from '@/api/modules'
import { ElDivider, ElIcon, ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus' import { ElDivider, ElIcon, ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus'
import type { TagProps } from 'element-plus' import type { TagProps } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue' import { ArrowLeft } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import type { IotCardImportTaskDetail } from '@/types/api/card' import type { IotCardImportTaskDetail } from '@/types/api/card'
import type { DeviceImportTaskDetail } from '@/types/api/device' import type { DeviceImportTaskDetail, DeviceImportTaskOperationType } from '@/types/api/device'
import type { DetailSection } from '@/components/common/DetailPage.vue' import type { DetailSection } from '@/components/common/DetailPage.vue'
import DetailPage from '@/components/common/DetailPage.vue' import DetailPage from '@/components/common/DetailPage.vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
@@ -72,12 +94,53 @@
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
const taskDetail = ref<TaskDetail | null>(null) const taskDetail = ref<TaskDetail | null>(null)
const loading = ref(false)
const taskType = ref<TaskType>('card') const taskType = ref<TaskType>('card')
const loading = ref(false)
const getOperationLabel = (operationType?: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
case 'import':
return '导入设备'
default:
return '设备任务'
}
}
const deviceTask = computed(() =>
taskType.value === 'device' ? (taskDetail.value as DeviceImportTaskDetail | null) : null
)
const taskTitle = computed(() =>
taskType.value === 'device'
? `${getOperationLabel(deviceTask.value?.operation_type)}任务详情`
: 'ICCID导入任务详情'
)
const resultAction = computed(() => {
if (taskType.value !== 'device') return '导入'
switch (deviceTask.value?.operation_type) {
case 'assign_shop':
return '分配'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '回收'
default:
return '导入'
}
})
const warningCount = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_count || 0 : 0
)
const warningItems = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_items || [] : []
)
// 获取状态标签类型
const getStatusType = (status?: number): TagProps['type'] => { const getStatusType = (status?: number): TagProps['type'] => {
if (!status) return 'info'
switch (status) { switch (status) {
case 1: case 1:
return 'info' return 'info'
@@ -92,8 +155,54 @@
} }
} }
// 详情页配置
const detailSections = computed((): DetailSection[] => { const detailSections = computed((): DetailSection[] => {
const deviceFields: DetailSection['fields'] =
taskType.value === 'device'
? [
{
label: '操作名称',
prop: 'operation_name',
formatter: (value: string) =>
value || getOperationLabel(deviceTask.value?.operation_type)
},
{
label: '目标 ID',
prop: 'target_id',
formatter: (value: number | null) => String(value ?? '-')
}
]
: []
const cardFields: DetailSection['fields'] =
taskType.value === 'card'
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: string) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []
return [ return [
{ {
title: '任务基本信息', title: '任务基本信息',
@@ -101,69 +210,44 @@
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' }, { label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ {
label: '任务类型', label: '任务类型',
render: () => { render: () =>
return h( h(
ElTag, ElTag,
{ type: taskType.value === 'device' ? 'warning' : 'primary', size: 'small' }, { type: taskType.value === 'device' ? 'warning' : 'primary', size: 'small' },
() => (taskType.value === 'device' ? '设备导入' : 'ICCID导入') () =>
taskType.value === 'device'
? getOperationLabel(deviceTask.value?.operation_type)
: 'ICCID导入'
) )
}
}, },
...deviceFields,
{ label: '批次号', prop: 'batch_no', formatter: (value: string) => value || '-' }, { label: '批次号', prop: 'batch_no', formatter: (value: string) => value || '-' },
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' }, { label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
{ { label: '操作人', prop: 'creator_name', formatter: (value: string) => value || '-' },
label: '操作人',
prop: 'creator_name',
formatter: (value: string) => value || '-'
},
{ {
label: '任务状态', label: '任务状态',
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h(ElTag, { type: getStatusType(data.status) }, () => data.status_text) h(
} ElTag,
{ type: getStatusType(data.status) },
() => (data as DeviceImportTaskDetail).status_name || data.status_text || '-'
)
}, },
...(taskType.value === 'card' ...cardFields,
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: any) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []),
{ {
label: '创建时间', label: '创建时间',
prop: 'created_at', prop: 'created_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-') formatter: (value: string) => (value ? formatDateTime(value) : '-')
}, },
{ {
label: '开始处理时间', label: '开始处理时间',
prop: 'started_at', prop: 'started_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-') formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
}, },
{ {
label: '完成时间', label: '完成时间',
prop: 'completed_at', prop: 'completed_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-') formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
}, },
...(taskDetail.value?.error_message ...(taskDetail.value?.error_message
? [ ? [
@@ -171,13 +255,12 @@
label: '错误信息', label: '错误信息',
prop: 'error_message', prop: 'error_message',
fullWidth: true, fullWidth: true,
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h( h(
'span', 'span',
{ style: { color: 'var(--el-color-danger)' } }, { style: { color: 'var(--el-color-danger)' } },
data.error_message || '' data.error_message || ''
) )
}
} }
] ]
: []) : [])
@@ -189,64 +272,57 @@
fields: [ fields: [
{ {
label: '总数', label: '总数',
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h( h(
'span', 'span',
{ {
style: { style: { fontSize: '16px', fontWeight: 'bold', color: 'var(--el-color-primary)' }
fontSize: '16px',
fontWeight: 'bold',
color: 'var(--el-color-primary)'
}
}, },
String(data.total_count || 0) String(data.total_count || 0)
) )
}
}, },
{ {
label: '成功数', label: '成功数',
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h(ElTag, { type: 'success' }, () => String(data.success_count || 0)) h(ElTag, { type: 'success' }, () => String(data.success_count || 0))
}
}, },
{ {
label: '失败数', label: '失败数',
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0)) h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0))
}
}, },
{ {
label: '跳过数', label: '跳过数',
render: (data: TaskDetail) => { render: (data: TaskDetail) =>
return h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0)) h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0))
} },
} ...(taskType.value === 'device'
? [
{
label: '警告数',
render: (data: TaskDetail) =>
h(ElTag, { type: 'info' }, () =>
String((data as DeviceImportTaskDetail).warning_count || 0)
)
}
]
: [])
] ]
} }
] ]
}) })
// 返回列表 const goBack = () => router.back()
const goBack = () => {
router.back()
}
// 获取任务详情
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 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
// 设置任务类型
if (queryTaskType) {
taskType.value = queryTaskType
}
if (taskType.value === 'device' && !isPlatformAccount.value) { if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情') ElMessage.error('当前账号无权查看设备任务详情')
goBack() goBack()
@@ -256,28 +332,22 @@
loading.value = true loading.value = true
try { try {
if (taskType.value === 'device') { if (taskType.value === 'device') {
// 获取设备导入任务详情
const res = await DeviceService.getImportTaskDetail(Number(taskId)) const res = await DeviceService.getImportTaskDetail(Number(taskId))
if (res.code === 0) { if (res.code === 0) taskDetail.value = res.data
taskDetail.value = res.data
}
} else { } else {
// 获取ICCID导入任务详情
const res = await CardService.getIotCardImportTaskDetail(Number(taskId)) const res = await CardService.getIotCardImportTaskDetail(Number(taskId))
if (res.code === 0) { if (res.code === 0) taskDetail.value = res.data
taskDetail.value = res.data
}
} }
} catch (error) { } catch (error) {
console.error(error) console.error('获取任务详情失败:', error)
console.log('获取任务详情失败') ElMessage.error('获取任务详情失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
onMounted(() => { onMounted(() => {
getTaskDetail() void getTaskDetail()
}) })
</script> </script>
@@ -285,8 +355,8 @@
.task-detail-page { .task-detail-page {
.detail-header { .detail-header {
display: flex; display: flex;
gap: 16px;
align-items: center; align-items: center;
gap: 16px;
padding-bottom: 16px; padding-bottom: 16px;
.detail-title { .detail-title {
@@ -297,8 +367,7 @@
} }
} }
.failure-section, .result-section {
.skipped-section {
margin-top: 20px; margin-top: 20px;
.section-title { .section-title {

View File

@@ -269,9 +269,22 @@
<ElFormItem class="main-wallet-filter__actions"> <ElFormItem class="main-wallet-filter__actions">
<ElButton type="primary" @click="handleMainWalletSearch">搜索</ElButton> <ElButton type="primary" @click="handleMainWalletSearch">搜索</ElButton>
<ElButton @click="handleMainWalletReset">重置</ElButton> <ElButton @click="handleMainWalletReset">重置</ElButton>
<ElButton
v-if="hasAuth('agent_wallet_transaction:export')"
@click="mainWalletExportDialogVisible = true"
>
导出
</ElButton>
</ElFormItem> </ElFormItem>
</div> </div>
</ElForm> </ElForm>
<ExportTaskCreateDialog
v-model="mainWalletExportDialogVisible"
scene="agent_wallet_transaction"
:query="mainWalletExportQuery"
confirm-permission="agent_wallet_transaction:export"
title="导出代理主钱包流水"
/>
<ArtTable <ArtTable
ref="mainWalletTableRef" ref="mainWalletTableRef"
row-key="id" row-key="id"
@@ -508,6 +521,7 @@
} from '@/config/constants/commission' } from '@/config/constants/commission'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError' import { normalizeApiError } from '@/utils/business/apiError'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'AgentCommission' }) defineOptions({ name: 'AgentCommission' })
@@ -619,10 +633,10 @@
return return
} }
if (value <= 0) { if (value <= 0) {
callback(new Error('实际信用额度必须大于0')) callback(new Error('实际信用额度必须大于0'))
return return
} }
callback() callback()
}, },
@@ -649,6 +663,17 @@
date_range: [], date_range: [],
asset_identifier: '' asset_identifier: ''
}) })
const mainWalletExportDialogVisible = ref(false)
const mainWalletExportQuery = computed(() => {
const [startDate, endDate] = mainWalletSearchForm.date_range || []
return {
shop_id: currentShop.value?.shop_id,
transaction_type: mainWalletSearchForm.transaction_type,
start_date: startDate || undefined,
end_date: endDate || undefined,
asset_identifier: mainWalletSearchForm.asset_identifier.trim() || undefined
}
})
// 列配置 // 列配置
const columnOptions = [ const columnOptions = [
@@ -878,10 +903,6 @@
} }
]) ])
onMounted(() => {
getTableData()
})
// 获取代理商资金汇总列表 // 获取代理商资金汇总列表
const getTableData = async () => { const getTableData = async () => {
loading.value = true loading.value = true
@@ -905,6 +926,14 @@
} }
} }
const loadInitialData = async () => {
await getTableData()
const shopId = Number(route.query.shop_id)
if (!shopId) return
const targetShop = summaryList.value.find((item) => item.shop_id === shopId)
if (targetShop) showDetail(targetShop)
}
// 搜索 // 搜索
const handleSearch = () => { const handleSearch = () => {
pagination.page = 1 pagination.page = 1
@@ -953,6 +982,10 @@
loadCommissionRecords() loadCommissionRecords()
} }
onMounted(() => {
void loadInitialData()
})
// 获取操作按钮 // 获取操作按钮
const getActions = (row: ShopFundSummaryItem) => { const getActions = (row: ShopFundSummaryItem) => {
const actions: any[] = [] const actions: any[] = []

View File

@@ -23,20 +23,24 @@
import { onMounted, reactive } from 'vue' import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { AssetService } from '@/api/modules' import { AssetService } from '@/api/modules'
import type { AssetType } from '@/types/api' import type { AssetType, ExpiringAssetType } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'ExpiringAssetStats' }) defineOptions({ name: 'ExpiringAssetStats' })
const router = useRouter() const router = useRouter()
const counts = reactive<Record<AssetType, number>>({ card: 0, device: 0 }) const counts = reactive<Record<AssetType, number>>({ card: 0, device: 0 })
const apiAssetTypes: Record<AssetType, ExpiringAssetType> = {
card: 'iot_card',
device: 'device'
}
const loadCount = async (assetType: AssetType) => { const loadCount = async (assetType: AssetType) => {
try { try {
const response = await AssetService.getExpiringAssets({ const response = await AssetService.getExpiringAssets({
asset_type: assetType, asset_type: apiAssetTypes[assetType],
page: 1, page: 1,
size: 1 page_size: 1
}) })
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0 if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
} catch { } catch {
@@ -47,7 +51,7 @@
const goToList = (assetType?: AssetType) => { const goToList = (assetType?: AssetType) => {
router.push({ router.push({
path: RoutesAlias.ExpiringAssets, path: RoutesAlias.ExpiringAssets,
query: assetType ? { asset_type: assetType } : {} query: assetType ? { asset_type: apiAssetTypes[assetType] } : {}
}) })
} }

View File

@@ -19,6 +19,11 @@ export const buildAgentRechargeActions = (
options: BuildAgentRechargeActionsOptions options: BuildAgentRechargeActionsOptions
): AgentRechargeAction[] => { ): AgentRechargeAction[] => {
const actions: AgentRechargeAction[] = [] const actions: AgentRechargeAction[] = []
const hasApprovalRecord =
row.approval_provider === 'wecom' ||
row.approval_source === 'wecom' ||
row.approval_source === 'legacy' ||
(row.approval_instance_id !== undefined && row.approval_instance_id !== null)
if ( if (
row.payment_method === 'offline' && row.payment_method === 'offline' &&
@@ -33,6 +38,7 @@ export const buildAgentRechargeActions = (
} }
if ( if (
!hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING && row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' && row.payment_method === 'offline' &&
options.hasAuth('agent_recharge:confirm_payment') options.hasAuth('agent_recharge:confirm_payment')
@@ -44,7 +50,11 @@ export const buildAgentRechargeActions = (
}) })
} }
if (row.status === AgentRechargeStatus.PENDING && options.hasAuth('agent_recharge:reject')) { if (
!hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING &&
options.hasAuth('agent_recharge:reject')
) {
actions.push({ actions.push({
label: '拒绝', label: '拒绝',
handler: () => options.onReject(row), handler: () => options.onReject(row),

View File

@@ -68,26 +68,12 @@
2: 'success', // 已支付 2: 'success', // 已支付
3: 'success', // 已完成 3: 'success', // 已完成
4: 'info', // 已关闭 4: 'info', // 已关闭
5: 'danger', // 已退款 5: 'danger', // 已退款
6: 'danger' // 已驳回 6: 'danger' // 已驳回
} }
return statusMap[status] || 'info' return statusMap[status] || 'info'
} }
// 获取状态文本(优先使用 status_name否则使用本地映射
const getStatusText = (status: AgentRechargeStatus, statusName?: string): string => {
if (statusName) return statusName
const statusMap: Record<AgentRechargeStatus, string> = {
1: '待支付',
2: '已支付',
3: '已完成',
4: '已关闭',
5: '已退款',
6: '已驳回'
}
return statusMap[status] || '-'
}
// 获取支付方式文本 // 获取支付方式文本
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
const methodMap: Record<AgentRechargePaymentMethod, string> = { const methodMap: Record<AgentRechargePaymentMethod, string> = {
@@ -97,6 +83,12 @@
return methodMap[method] || method return methodMap[method] || method
} }
const getApprovalProviderText = (data: AgentRecharge) => {
if (data.approval_provider === 'wecom' || data.approval_source === 'wecom') return '企微'
if (data.approval_source === 'legacy') return '历史审批'
return data.approval_provider || data.approval_source || '-'
}
// 详情配置 // 详情配置
const detailSections = computed((): DetailSection[] => [ const detailSections = computed((): DetailSection[] => [
{ {
@@ -104,6 +96,7 @@
fields: [ fields: [
{ label: '充值单号', prop: 'recharge_no' }, { label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
{ {
label: '充值金额', label: '充值金额',
formatter: (_, data) => formatCurrency(data.amount) formatter: (_, data) => formatCurrency(data.amount)
@@ -111,9 +104,7 @@
{ {
label: '状态', label: '状态',
render: (data) => render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () => h(ElTag, { type: getStatusType(data.status) }, () => data.status_name || '-')
getStatusText(data.status, data.status_name)
)
}, },
{ {
label: '驳回原因', label: '驳回原因',
@@ -123,6 +114,30 @@
} }
] ]
}, },
{
title: '企微审批信息',
fields: [
{ label: '审批渠道', formatter: (_, data) => getApprovalProviderText(data) },
{
label: '审批状态',
formatter: (_, data) => data.approval_status_name || '-'
},
{
label: '当前审批人摘要',
formatter: (_, data) => data.current_approver_summary || '-',
fullWidth: true
}
]
},
{
title: '业务处理结果',
fields: [
{
label: '处理状态',
formatter: (_, data) => data.processing_status_name || '-'
}
]
},
{ {
title: '支付信息', title: '支付信息',
fields: [ fields: [

View File

@@ -24,6 +24,9 @@
v-if="hasAuth('agent_recharge:create')" v-if="hasAuth('agent_recharge:create')"
>创建充值订单</ElButton >创建充值订单</ElButton
> >
<ElButton v-if="hasAuth('agent_recharge:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
@@ -66,7 +69,7 @@
placeholder="请输入充值金额(元)" placeholder="请输入充值金额(元)"
/> />
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)"> <div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
最小: ¥0.01最大: 不限制 最小: ¥0.01最大: ¥1,000,000.00
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem label="支付方式" prop="payment_method"> <ElFormItem label="支付方式" prop="payment_method">
@@ -129,6 +132,15 @@
</template> </template>
</ElDialog> </ElDialog>
<!-- 导出任务对话框 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="agent_recharge"
:query="exportQuery"
confirm-permission="agent_recharge:export"
title="导出代理充值"
/>
<!-- 确认线下支付对话框 --> <!-- 确认线下支付对话框 -->
<ElDialog <ElDialog
v-model="confirmPayDialogVisible" v-model="confirmPayDialogVisible"
@@ -245,6 +257,7 @@
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { buildAgentRechargeActions } from './agentRechargeActions' import { buildAgentRechargeActions } from './agentRechargeActions'
import { formatRejectionReason } from './agentRechargeDisplay' import { formatRejectionReason } from './agentRechargeDisplay'
@@ -260,6 +273,7 @@
const rejectLoading = ref(false) const rejectLoading = ref(false)
const tableRef = ref() const tableRef = ref()
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const exportDialogVisible = ref(false)
const confirmPayDialogVisible = ref(false) const confirmPayDialogVisible = ref(false)
const rejectDialogVisible = ref(false) const rejectDialogVisible = ref(false)
const currentRecharge = ref<AgentRecharge | null>(null) const currentRecharge = ref<AgentRecharge | null>(null)
@@ -336,8 +350,11 @@
placeholder: '请选择状态', placeholder: '请选择状态',
options: [ options: [
{ label: '待支付', value: 1 }, { label: '待支付', value: 1 },
{ label: '已完成', value: 2 }, { label: '已支付', value: 2 },
{ label: '已取消', value: 3 } { label: '已完成', value: 3 },
{ label: '已关闭', value: 4 },
{ label: '已退款', value: 5 },
{ label: '已驳回', value: 6 }
], ],
config: { config: {
clearable: true clearable: true
@@ -370,16 +387,19 @@
{ label: '店铺名称', prop: 'shop_name' }, { label: '店铺名称', prop: 'shop_name' },
{ label: '充值金额', prop: 'amount' }, { label: '充值金额', prop: 'amount' },
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' },
{ label: '提交人', prop: 'submitter_name' }, { label: '提交人', prop: 'submitter_name' },
{ label: '审批状态', prop: 'approval_status' }, { label: '审批状态', prop: 'approval_status' },
{ label: '当前审批人摘要', prop: 'current_approver_summary' }, { label: '当前审批人摘要', prop: 'current_approver_summary' },
{ label: '业务处理状态', prop: 'processing_status_name' }, { label: '业务处理状态', prop: 'processing_status_name' },
{ label: '支付方式', prop: 'payment_method' }, { label: '支付方式', prop: 'payment_method' },
{ label: '支付通道', prop: 'payment_channel' }, { label: '支付通道', prop: 'payment_channel' },
{ label: '运营备注', prop: 'remark' },
{ label: '驳回原因', prop: 'rejection_reason' }, { label: '驳回原因', prop: 'rejection_reason' },
{ label: '创建时间', prop: 'created_at' }, { label: '创建时间', prop: 'created_at' },
{ label: '支付时间', prop: 'paid_at' }, { label: '支付时间', prop: 'paid_at' },
{ label: '完成时间', prop: 'completed_at' } { label: '完成时间', prop: 'completed_at' },
{ label: '更新时间', prop: 'updated_at' }
] ]
const createFormRef = ref<FormInstance>() const createFormRef = ref<FormInstance>()
@@ -387,6 +407,7 @@
const rejectFormRef = ref<FormInstance>() const rejectFormRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>() const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const MIN_RECHARGE_AMOUNT = 0.01 const MIN_RECHARGE_AMOUNT = 0.01
const MAX_RECHARGE_AMOUNT = 1_000_000
const createRules = computed<FormRules>(() => { const createRules = computed<FormRules>(() => {
const rules: FormRules = { const rules: FormRules = {
@@ -402,6 +423,10 @@
callback(new Error(`充值金额最小为 ¥${MIN_RECHARGE_AMOUNT.toFixed(2)}`)) callback(new Error(`充值金额最小为 ¥${MIN_RECHARGE_AMOUNT.toFixed(2)}`))
return return
} }
if (Number(value) > MAX_RECHARGE_AMOUNT) {
callback(new Error(`充值金额最大为 ¥${MAX_RECHARGE_AMOUNT.toFixed(2)}`))
return
}
callback() callback()
}, },
trigger: 'blur' trigger: 'blur'
@@ -464,26 +489,12 @@
2: 'success', // 已支付 2: 'success', // 已支付
3: 'success', // 已完成 3: 'success', // 已完成
4: 'info', // 已关闭 4: 'info', // 已关闭
5: 'danger', // 已退款 5: 'danger', // 已退款
6: 'danger' // 已驳回 6: 'danger' // 已驳回
} }
return statusMap[status] || 'info' return statusMap[status] || 'info'
} }
// 获取状态文本(优先使用 status_name否则使用本地映射
const getStatusText = (status: AgentRechargeStatus, statusName?: string): string => {
if (statusName) return statusName
const statusMap: Record<AgentRechargeStatus, string> = {
1: '待支付',
2: '已支付',
3: '已完成',
4: '已关闭',
5: '已退款',
6: '已驳回'
}
return statusMap[status] || '-'
}
// 获取支付方式文本 // 获取支付方式文本
const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => { const getPaymentMethodText = (method: AgentRechargePaymentMethod): string => {
const methodMap: Record<AgentRechargePaymentMethod, string> = { const methodMap: Record<AgentRechargePaymentMethod, string> = {
@@ -530,9 +541,17 @@
label: '状态', label: '状态',
width: 100, width: 100,
formatter: (row: AgentRecharge) => { formatter: (row: AgentRecharge) => {
return h(ElTag, { type: getStatusType(row.status) }, () => return h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '-')
getStatusText(row.status, row.status_name) }
) },
{
prop: 'approval_provider',
label: '审批渠道',
width: 110,
formatter: (row: AgentRecharge) => {
if (row.approval_provider === 'wecom' || row.approval_source === 'wecom') return '企微'
if (row.approval_source === 'legacy') return '历史审批'
return row.approval_provider || row.approval_source || '-'
} }
}, },
{ {
@@ -605,6 +624,12 @@
label: '完成时间', label: '完成时间',
width: 180, width: 180,
formatter: (row: AgentRecharge) => (row.completed_at ? formatDateTime(row.completed_at) : '-') formatter: (row: AgentRecharge) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: AgentRecharge) => formatDateTime(row.updated_at)
} }
]) ])
@@ -711,6 +736,13 @@
getTableData() getTableData()
} }
const exportQuery = computed(() => ({
shop_id: searchForm.shop_id,
status: searchForm.status,
start_date: searchForm.start_date || searchForm.dateRange?.[0],
end_date: searchForm.end_date || searchForm.dateRange?.[1]
}))
// 刷新表格 // 刷新表格
const handleRefresh = () => { const handleRefresh = () => {
getTableData() getTableData()

View File

@@ -162,6 +162,34 @@
return `¥${(amount / 100).toFixed(2)}` return `¥${(amount / 100).toFixed(2)}`
} }
const getApprovalProviderText = (item: Refund) => {
const provider = item.approval_provider || item.approval_source || item.approval?.source
if (provider === 'wecom') return '企微'
if (provider === 'legacy') return '历史审批'
if (provider === 'none') return '无'
return provider || '-'
}
const getRefundApprovalStatusText = (item: Refund) => {
return item.approval_status_name || item.approval?.status_name || item.approval?.status || '-'
}
const isHiddenIdKey = (key: string) => {
const normalizedKey = key.toLowerCase()
return normalizedKey === 'id' || normalizedKey === 'userid' || normalizedKey.endsWith('_id')
}
const sanitizeStructuredValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item))
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !isHiddenIdKey(key))
.map(([key, item]) => [key, sanitizeStructuredValue(item)])
)
}
const formatStructuredValue = (value: unknown) => { const formatStructuredValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return '-' if (value === undefined || value === null || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -171,7 +199,7 @@
} }
const renderStructuredValue = (value: unknown) => const renderStructuredValue = (value: unknown) =>
h('pre', { class: 'structured-value' }, formatStructuredValue(value)) h('pre', { class: 'structured-value' }, formatStructuredValue(sanitizeStructuredValue(value)))
const renderTimeline = (timeline: unknown) => { const renderTimeline = (timeline: unknown) => {
if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-') if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-')
@@ -181,7 +209,7 @@
{ class: 'approval-timeline' }, { class: 'approval-timeline' },
timeline.map((item: any) => { timeline.map((item: any) => {
const title = item.status_name || item.status || item.content || '审批节点' const title = item.status_name || item.status || item.content || '审批节点'
const operator = item.operator_name || item.operator || '' const operator = item.operator_name || ''
const time = item.time || item.timestamp || '' const time = item.time || item.timestamp || ''
const comment = item.comment || item.content || '' const comment = item.comment || item.content || ''
return h('div', { class: 'approval-timeline__item' }, [ return h('div', { class: 'approval-timeline__item' }, [
@@ -207,6 +235,8 @@
{ label: '订单号', prop: 'order_no' }, { label: '订单号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' }, { label: '资产标识符', prop: 'asset_identifier' },
{ label: '资产类型', prop: 'asset_type' }, { label: '资产类型', prop: 'asset_type' },
{ label: '退款状态', prop: 'status_name', formatter: (value) => value || '-' },
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
{ {
label: '申请退款金额', label: '申请退款金额',
formatter: (_, data) => formatCurrency(data.requested_refund_amount) formatter: (_, data) => formatCurrency(data.requested_refund_amount)
@@ -225,6 +255,12 @@
formatter: (value) => value || '-', formatter: (value) => value || '-',
fullWidth: true fullWidth: true
}, },
{
label: '拒绝原因',
prop: 'reject_reason',
formatter: (value) => value || '-',
fullWidth: true
},
{ {
label: '备注', label: '备注',
prop: 'remark', prop: 'remark',
@@ -258,17 +294,30 @@
formatter: (_, data) => (data.commission_deducted ? '是' : '否') formatter: (_, data) => (data.commission_deducted ? '是' : '否')
}, },
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) }, { label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{
label: '审批时间',
prop: 'processed_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) } { label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) }
] ]
}, },
{ {
title: '企微审批信息', title: '企微审批信息',
fields: [ fields: [
{ label: '审批来源', prop: 'approval.source' }, { label: '审批渠道', formatter: (_, data) => getApprovalProviderText(data) },
{
label: '审批来源',
formatter: (_, data) => data.approval?.source || data.approval_source || '-'
},
{ label: '审批单号', prop: 'approval.sp_no' }, { label: '审批单号', prop: 'approval.sp_no' },
{ {
label: '审批状态', label: '审批状态',
formatter: (_, data) => data.approval?.status_name || data.approval?.status || '-' formatter: (_, data) => getRefundApprovalStatusText(data)
},
{
label: '当前审批人摘要',
formatter: (_, data) => data.current_approver_summary || '-'
}, },
{ label: '模板版本', prop: 'approval.template_version' }, { label: '模板版本', prop: 'approval.template_version' },
{ {

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showCreateDialog" v-if="hasAuth('refund:create')" <ElButton type="primary" @click="showCreateDialog" v-if="hasAuth('refund:create')"
>创建退款申请</ElButton >创建退款申请</ElButton
> >
<ElButton v-if="hasAuth('refund:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template> </template>
</ArtTableHeader> </ArtTableHeader>
@@ -48,6 +51,15 @@
<!-- 创建退款申请对话框 --> <!-- 创建退款申请对话框 -->
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" /> <CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
<!-- 导出任务对话框 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="refund"
:query="exportQuery"
confirm-permission="refund:export"
title="导出退款"
/>
<!-- 退款凭证预览 --> <!-- 退款凭证预览 -->
<PaymentVoucherDialog <PaymentVoucherDialog
:file-keys="refundVoucherFileKeys" :file-keys="refundVoucherFileKeys"
@@ -147,7 +159,6 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns' import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format' import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { import {
getApprovalStatusText,
getCurrentApproverSummaryText, getCurrentApproverSummaryText,
getProcessingStatusText getProcessingStatusText
} from '@/utils/business/approvalSummary' } from '@/utils/business/approvalSummary'
@@ -155,6 +166,7 @@
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue' import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue' import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
@@ -170,6 +182,7 @@
const tableRef = ref() const tableRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>() const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const exportDialogVisible = ref(false)
const resubmitDialogVisible = ref(false) const resubmitDialogVisible = ref(false)
const currentRefund = ref<Refund | null>(null) const currentRefund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([]) const refundVoucherFileKeys = ref<string[]>([])
@@ -269,6 +282,7 @@
{ label: '实际退款金额', prop: 'approved_refund_amount' }, { label: '实际退款金额', prop: 'approved_refund_amount' },
{ label: '实收金额', prop: 'actual_received_amount' }, { label: '实收金额', prop: 'actual_received_amount' },
{ label: '状态', prop: 'status' }, { label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' },
{ label: '提交人', prop: 'submitter_name' }, { label: '提交人', prop: 'submitter_name' },
{ label: '审批状态', prop: 'approval_status' }, { label: '审批状态', prop: 'approval_status' },
{ label: '当前审批人摘要', prop: 'current_approver_summary' }, { label: '当前审批人摘要', prop: 'current_approver_summary' },
@@ -279,7 +293,8 @@
{ label: '资产重置', prop: 'asset_reset' }, { label: '资产重置', prop: 'asset_reset' },
{ label: '佣金扣除', prop: 'commission_deducted' }, { label: '佣金扣除', prop: 'commission_deducted' },
{ label: '创建时间', prop: 'created_at' }, { label: '创建时间', prop: 'created_at' },
{ label: '审批时间', prop: 'processed_at' } { label: '审批时间', prop: 'processed_at' },
{ label: '更新时间', prop: 'updated_at' }
] ]
const resubmitFormRef = ref<FormInstance>() const resubmitFormRef = ref<FormInstance>()
@@ -320,17 +335,6 @@
return statusMap[status] || 'info' return statusMap[status] || 'info'
} }
// 获取状态文本
const getStatusText = (status: RefundStatus): string => {
const statusMap: Record<RefundStatus, string> = {
1: '待审批',
2: '已通过',
3: '已拒绝',
4: '已退回'
}
return statusMap[status] || '-'
}
// 动态列配置 // 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ {
@@ -404,7 +408,17 @@
label: '状态', label: '状态',
width: 100, width: 100,
formatter: (row: Refund) => { formatter: (row: Refund) => {
return h(ElTag, { type: getStatusType(row.status) }, () => getStatusText(row.status)) return h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || '-')
}
},
{
prop: 'approval_provider',
label: '审批渠道',
width: 110,
formatter: (row: Refund) => {
if (row.approval_provider === 'wecom' || row.approval_source === 'wecom') return '企微'
if (row.approval_source === 'legacy') return '历史审批'
return row.approval_provider || row.approval_source || '-'
} }
}, },
{ {
@@ -418,7 +432,7 @@
prop: 'approval_status', prop: 'approval_status',
label: '审批状态', label: '审批状态',
width: 120, width: 120,
formatter: (row: Refund) => getApprovalStatusText(row) formatter: (row: Refund) => row.approval_status_name || '-'
}, },
{ {
prop: 'current_approver_summary', prop: 'current_approver_summary',
@@ -483,6 +497,12 @@
label: '审批时间', label: '审批时间',
width: 180, width: 180,
formatter: (row: Refund) => (row.processed_at ? formatDateTime(row.processed_at) : '-') formatter: (row: Refund) => (row.processed_at ? formatDateTime(row.processed_at) : '-')
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: Refund) => formatDateTime(row.updated_at)
} }
]) ])
@@ -564,6 +584,13 @@
} }
} }
const exportQuery = computed(() => ({
status: searchForm.status,
order_id: searchForm.order_id,
shop_id: searchForm.shop_id,
asset_identifier: searchForm.asset_identifier?.trim() || undefined
}))
// 重置搜索 // 重置搜索
const handleReset = () => { const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState }) Object.assign(searchForm, { ...initialSearchState })

View File

@@ -1,277 +0,0 @@
<template>
<ArtTableFullScreen>
<div class="notification-center-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="page-header">
<div>
<h2>通知中心</h2>
<p>查看余额预警临期提醒审批结果和系统告警</p>
</div>
<ElButton
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
:loading="markingAllRead"
@click="handleMarkAllRead"
>
全部已读
</ElButton>
</div>
</template>
<div class="filters">
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
<ElOption label="临期提醒" value="expiry" />
<ElOption label="审批" value="approval" />
<ElOption label="同步" value="sync" />
<ElOption label="系统" value="system" />
</ElSelect>
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
</ElSelect>
<ElSelect v-model="filters.severity" clearable placeholder="严重级别" @change="reload">
<ElOption label="提示" value="info" />
<ElOption label="警告" value="warning" />
<ElOption label="严重" value="error" />
<ElOption label="严重" value="critical" />
</ElSelect>
<ElSelect v-model="filters.is_read" clearable placeholder="已读状态" @change="reload">
<ElOption label="未读" :value="false" />
<ElOption label="已读" :value="true" />
</ElSelect>
<ElButton @click="loadNotifications">刷新</ElButton>
</div>
<ElTable v-loading="loading" :data="notifications" row-key="id" class="notification-table">
<ElTableColumn label="状态" width="90">
<template #default="scope">
<span :class="['read-dot', { unread: !isRead(scope.row) }]" />
{{ isRead(scope.row) ? '已读' : '未读' }}
</template>
</ElTableColumn>
<ElTableColumn prop="title" label="通知标题" min-width="200" show-overflow-tooltip />
<ElTableColumn label="分类" width="120">
<template #default="scope">{{ getCategoryLabel(scope.row.category) }}</template>
</ElTableColumn>
<ElTableColumn label="严重级别" width="110">
<template #default="scope">
<ElTag :type="getSeverityType(scope.row.severity)">
{{ getSeverityLabel(scope.row.severity) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="body" label="通知内容" min-width="300" show-overflow-tooltip />
<ElTableColumn prop="read_at" label="已读时间" width="180" />
<ElTableColumn prop="created_at" label="时间" width="180" />
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="scope">
<ElButton
v-if="hasAuth(NOTIFICATION_PERMISSIONS.read)"
link
type="primary"
@click="handleNotificationClick(scope.row)"
>
查看
</ElButton>
<span v-else>-</span>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="page"
v-model:page-size="pageSize"
layout="total, sizes, prev, pager, next"
:total="total"
@current-change="loadNotifications"
@size-change="reload"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { NotificationService } from '@/api/modules'
import type { NotificationItem, NotificationQueryParams } from '@/types/api'
import { useNotificationStore } from '@/store/modules/notification'
import { useAuth } from '@/composables/useAuth'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
defineOptions({ name: 'NotificationCenter' })
const router = useRouter()
const notificationStore = useNotificationStore()
const { hasAuth } = useAuth()
const notifications = ref<NotificationItem[]>([])
const loading = ref(false)
const markingAllRead = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const typeOptions = ref<string[]>([])
const filters = reactive<NotificationQueryParams>({
category: undefined,
type: undefined,
severity: undefined,
is_read: undefined
})
const isRead = (item: NotificationItem) => item.is_read
const getCategoryLabel = (category?: string | null) =>
({
balance: '余额预警',
expiry: '临期提醒',
expiring: '临期提醒',
approval: '审批',
sync: '同步/系统',
system: '同步/系统'
})[category || ''] ||
category ||
'-'
const getSeverityLabel = (severity?: string | null) =>
({ info: '提示', warning: '警告', error: '严重', critical: '严重' })[severity || ''] ||
severity ||
'-'
const getSeverityType = (severity?: string | null) => {
if (severity === 'error') return 'danger'
if (severity === 'warning') return 'warning'
if (severity === 'critical') return 'danger'
return 'info'
}
const loadNotifications = async () => {
loading.value = true
try {
const response = await NotificationService.getNotifications({
page: page.value,
page_size: pageSize.value,
...filters
})
if (response.code !== 0 || !response.data) {
ElMessage.error(response.msg || '获取通知列表失败')
return
}
notifications.value = response.data.items || []
total.value = response.data.total || 0
typeOptions.value = Array.from(
new Set(
notifications.value
.map((item) => item.type)
.filter((type): type is string => Boolean(type))
)
)
await notificationStore.refreshUnreadCount()
} catch (error: any) {
ElMessage.error(error?.message || '获取通知列表失败')
} finally {
loading.value = false
}
}
const reload = () => {
page.value = 1
void loadNotifications()
}
const handleMarkAllRead = async () => {
markingAllRead.value = true
try {
const response = await notificationStore.markAllRead()
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
else await loadNotifications()
} catch (error: any) {
ElMessage.error(error?.message || '全部已读失败')
} finally {
markingAllRead.value = false
}
}
const handleNotificationClick = async (item: NotificationItem) => {
try {
const targetResponse = await NotificationService.getTarget(item.id)
const readResponse = await notificationStore.markRead(item.id)
if (readResponse.code !== 0) {
ElMessage.error(readResponse.msg || '标记已读失败')
return
}
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.body || '该通知暂无可跳转目标')
await loadNotifications()
return
}
if (!navigateNotificationTarget(router, targetResponse.data)) {
ElMessage.info(item.body || '该通知暂无可跳转目标')
}
await loadNotifications()
} catch (error: any) {
ElMessage.error(error?.message || '处理通知失败')
}
}
onMounted(() => {
void loadNotifications()
})
</script>
<style scoped lang="scss">
.notification-center-page {
.page-header,
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.page-header {
justify-content: space-between;
h2 {
margin: 0;
font-size: 20px;
}
p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
}
}
.filters {
flex-wrap: wrap;
margin-bottom: 16px;
.el-select {
width: 160px;
}
}
.read-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
vertical-align: middle;
background: var(--el-border-color);
border-radius: 50%;
&.unread {
background: var(--el-color-danger);
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -1,446 +0,0 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="page-header">
<div>
<h2>批量订购套餐</h2>
<p>同一批次只能选择一种支付方式系统按任务结果逐行处理订单</p>
</div>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
tag="a"
href="/templates/bulk-purchase-template.csv"
download="批量订购套餐模板.csv"
>
下载模板
</ElButton>
</div>
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="套餐 ID" prop="package_id">
<ElInputNumber v-model="form.package_id" :min="1" controls-position="right" />
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElRadioGroup v-model="form.payment_method" @change="handlePaymentMethodChange">
<ElRadio value="wallet">代理钱包</ElRadio>
<ElRadio value="offline">线下支付</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="订单文件" prop="orderFile">
<VoucherUpload
ref="orderUploadRef"
v-model="orderFileKeys"
voucher-name="订单文件"
:max-count="1"
purpose="batch_purchase"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV最多 1000 行,最大 10MB"
@uploading-change="orderFileUploading = $event"
@change="formRef?.validateField('orderFile')"
/>
</ElFormItem>
<ElFormItem
v-if="form.payment_method === 'offline'"
label="整批支付凭证"
prop="voucherFile"
>
<VoucherUpload
ref="voucherUploadRef"
v-model="voucherFileKeys"
voucher-name="整批支付凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('voucherFile')"
/>
</ElFormItem>
<ElAlert
v-if="submitting || voucherUploading || orderFileUploading"
type="info"
:closable="false"
show-icon
:title="
voucherUploading
? '支付凭证上传中,请稍候'
: orderFileUploading
? '订单文件上传中,请稍候'
: '批量订购文件上传及任务创建中,请勿重复提交'
"
/>
<div class="form-actions">
<ElButton
type="primary"
:loading="submitting || voucherUploading || orderFileUploading"
:disabled="
voucherUploading || orderFileUploading || !hasAuth(BULK_PURCHASE_PERMISSIONS.create)
"
@click="submitTask"
>
创建批量订购任务
</ElButton>
<ElButton v-if="taskDetail" @click="resetTask">重新创建</ElButton>
</div>
</ElForm>
</ElCard>
<ElCard
v-if="taskDetail && hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
shadow="never"
class="art-table-card task-card"
>
<template #header>
<div class="task-header">
<span>任务结果</span>
<ElTag :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
</template>
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{
taskDetail.task_no || taskDetail.task_id
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.failed_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="总金额">{{
formatAmount(taskDetail.total_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功金额">{{
formatAmount(taskDetail.success_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败金额">{{
formatAmount(taskDetail.failed_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="items-toolbar">
<ElSelect v-model="itemStatus" clearable placeholder="筛选行状态" @change="reloadItems">
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="处理中" value="processing" />
</ElSelect>
<ElButton @click="reloadItems">刷新结果</ElButton>
</div>
<ElTable
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)"
v-loading="itemsLoading"
:data="items"
border
>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{
scope.row.row_number ?? scope.row.line ?? '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn prop="package_code" label="套餐编码" min-width="150" />
<ElTableColumn label="状态" width="110">
<template #default="scope">{{
scope.row.status_name || scope.row.status || '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="错误原因" min-width="240" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.error_summary || scope.row.error_reason || scope.row.error_code || '-' }}
</template>
</ElTableColumn>
</ElTable>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="itemsTotal"
@current-change="loadItems"
@size-change="reloadItems"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElInputNumber,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BulkPurchaseService } from '@/api/modules'
import type {
BulkPurchaseItem,
BulkPurchasePaymentMethod,
BulkPurchaseTask
} from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'BulkPurchase' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
const voucherFileKeys = ref<string[]>([])
const voucherUploading = ref(false)
const itemStatus = ref<string | undefined>()
const items = ref<BulkPurchaseItem[]>([])
const itemsLoading = ref(false)
const itemsPage = ref(1)
const itemsSize = ref(20)
const itemsTotal = ref(0)
const requestId = ref('')
const form = reactive<{
package_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
package_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
package_id: [{ required: true, message: '请输入套餐 ID', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
orderFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (orderFileKeys.value.length === 0) callback(new Error('请上传订单文件'))
else callback()
},
trigger: 'change'
}
],
voucherFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (form.payment_method === 'offline' && voucherFileKeys.value.length === 0) {
callback(new Error('线下支付必须上传整批支付凭证'))
} else callback()
},
trigger: 'change'
}
]
}))
const polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: 'bulk-purchase-active-task',
fetchTask: async (taskId) => {
const res = await BulkPurchaseService.getTask(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取批量订购任务失败')
return res.data
}
})
const taskDetail = polling.task
const getRequestId = () => {
if (requestId.value) return requestId.value
requestId.value =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
return requestId.value
}
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
if (value === 'wallet') {
voucherFileKeys.value = []
voucherUploadRef.value?.clearFiles()
formRef.value?.clearValidate('voucherFile')
}
}
const submitTask = async () => {
if (
!hasAuth(BULK_PURCHASE_PERMISSIONS.create) ||
submitting.value ||
voucherUploading.value ||
orderFileUploading.value
)
return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.package_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = {
file_key: orderFileKeys.value[0],
package_id: form.package_id,
payment_method: form.payment_method,
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
}
const res = await BulkPurchaseService.createTask(data)
if (res.code !== 0 || !res.data?.task_id) {
ElMessage.error(res.msg || '创建批量订购任务失败')
return
}
ElMessage.success(res.data.message || '批量订购任务已创建')
requestId.value = ''
await router.replace({
path: route.path,
query: { task_id: String(res.data.task_id) }
})
await polling.start(res.data.task_id)
await loadItems()
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
submitting.value = false
}
}
const loadItems = async () => {
const taskId = taskDetail.value?.id || taskDetail.value?.task_id
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
items.value = taskDetail.value?.items || []
}
const reloadItems = () => {
itemsPage.value = 1
void loadItems()
}
const resetTask = () => {
polling.clear()
requestId.value = ''
items.value = []
itemsTotal.value = 0
itemsPage.value = 1
router.replace({ path: route.path })
}
const getStatusName = (status: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return names[status] || '-'
}
const getStatusType = (status: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const formatAmount = (amount?: number) => (amount === undefined ? '-' : formatMoney(amount))
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
watch(taskDetail, () => {
itemsPage.value = 1
void loadItems()
})
onMounted(() => {
const routeTaskId = Number(route.query.task_id)
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
})
</script>
<style scoped lang="scss">
.bulk-purchase-page {
.page-header,
.task-header,
.form-actions,
.items-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
.page-header p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
}
.create-form {
max-width: 760px;
}
.task-card {
margin-top: 16px;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
justify-content: flex-start;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -274,7 +274,7 @@
}} }}
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem label="生效条件"> <ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect <ElSelect
v-model="packageForm.expiry_base_override" v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件" placeholder="请选择生效条件"
@@ -352,7 +352,8 @@
PackageResponse PackageResponse
} from '@/types/api' } from '@/types/api'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText } from '@/config/constants' import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import { import {
mergeGrantPackageCandidates, mergeGrantPackageCandidates,
type GrantPackageCandidate type GrantPackageCandidate
@@ -362,6 +363,8 @@
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
@@ -378,11 +381,11 @@
// 套餐表单 // 套餐表单
const packageForm = ref<{ const packageForm = ref<{
package_id?: number package_id?: number
allocation_id?: number
package_name?: string package_name?: string
package_code?: string package_code?: string
original_cost_price?: number original_cost_price?: number
cost_price_yuan: number cost_price_yuan: number
allocation_id?: number
expiry_base_override: ExpiryBaseSelection expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null default_expiry_base_name?: string | null
@@ -509,6 +512,7 @@
packageDialogType.value = 'add' packageDialogType.value = 'add'
packageForm.value = { packageForm.value = {
package_id: undefined, package_id: undefined,
allocation_id: undefined,
package_name: undefined, package_name: undefined,
package_code: undefined, package_code: undefined,
original_cost_price: undefined, original_cost_price: undefined,
@@ -530,11 +534,11 @@
packageDialogType.value = 'edit' packageDialogType.value = 'edit'
packageForm.value = { packageForm.value = {
package_id: row.package_id, package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name, package_name: row.package_name,
package_code: row.package_code, package_code: row.package_code,
original_cost_price: row.cost_price / 100, original_cost_price: row.cost_price / 100,
cost_price_yuan: row.cost_price / 100, cost_price_yuan: row.cost_price / 100,
allocation_id: row.allocation_id,
expiry_base_override: row.expiry_base_override ?? 'default', expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default', initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name, default_expiry_base_name: row.default_expiry_base_name,
@@ -619,8 +623,12 @@
const expiryBaseChanged = const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override packageForm.value.initial_expiry_base_override
if (expiryBaseChanged && !packageForm.value.allocation_id) { if (
ElMessage.error('该套餐分配记录缺少ID无法更新生效条件') expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return return
} }

View File

@@ -29,12 +29,22 @@
:marginTop="10" :marginTop="10"
:pagination="false" :pagination="false"
:actions="getActions" :actions="getActions"
:actionsWidth="150" :actionsWidth="240"
:inlineActionsCount="2" :inlineActionsCount="3"
> >
<template #default> <template #default>
<ElTableColumn prop="package_name" label="套餐名称" minWidth="150" /> <ElTableColumn
<ElTableColumn prop="package_code" label="套餐编码" minWidth="120" /> prop="package_name"
label="套餐名称"
minWidth="220"
show-overflow-tooltip
/>
<ElTableColumn
prop="package_code"
label="套餐编码"
minWidth="180"
show-overflow-tooltip
/>
<ElTableColumn label="成本价" width="120"> <ElTableColumn label="成本价" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span> <span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
@@ -81,16 +91,18 @@
@closed="handlePackageDialogClosed" @closed="handlePackageDialogClosed"
> >
<ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="140px"> <ElForm ref="packageFormRef" :model="packageForm" :rules="packageRules" label-width="140px">
<ElFormItem label="选择套餐" prop="package_id" v-if="packageDialogType === 'add'"> <ElFormItem label="选择套餐" prop="package_ids" v-if="packageDialogType === 'add'">
<ElSelect <ElSelect
v-model="packageForm.package_id" v-model="packageForm.package_ids"
placeholder="请选择套餐" placeholder="请选择套餐(可多选)"
style="width: 100%" style="width: 100%"
filterable filterable
remote remote
:remote-method="searchAvailablePackages" :remote-method="searchAvailablePackages"
:loading="packageLoading" :loading="packageLoading"
clearable clearable
multiple
:multiple-limit="100"
> >
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId"> <template v-if="availablePackages.length === 0 && !packageLoading && seriesId">
<ElOption disabled value="" label="该系列没有可选套餐" /> <ElOption disabled value="" label="该系列没有可选套餐" />
@@ -105,6 +117,33 @@
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem
v-if="packageDialogType === 'add' && packageForm.packages.length"
label="套餐配置"
prop="packages"
>
<div class="package-config-list">
<div
v-for="pkg in packageForm.packages"
:key="pkg.package_id"
class="package-config-item"
>
<div class="package-config-name" :title="pkg.package_name || ''">
{{ pkg.package_name || '套餐名称不可用' }}
</div>
<ElInputNumber
v-model="pkg.cost_price_yuan"
:min="pkg.original_cost_price || 0"
:max="getPackageCostPriceMax(pkg)"
:precision="2"
:step="0.01"
:controls="false"
placeholder="成本价(元)"
/>
</div>
</div>
</ElFormItem>
<ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'"> <ElFormItem label="套餐名称" v-if="packageDialogType === 'edit'">
<span>{{ packageForm.package_name }}</span> <span>{{ packageForm.package_name }}</span>
</ElFormItem> </ElFormItem>
@@ -127,7 +166,7 @@
}} }}
</div> </div>
</ElFormItem> </ElFormItem>
<ElFormItem label="生效条件"> <ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect <ElSelect
v-model="packageForm.expiry_base_override" v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件" placeholder="请选择生效条件"
@@ -161,6 +200,49 @@
</div> </div>
</template> </template>
</ElDialog> </ElDialog>
<ElDialog
v-model="expiryDialogVisible"
title="修改生效条件"
width="460px"
:close-on-click-modal="false"
>
<ElForm label-width="130px">
<ElFormItem label="套餐名称">
<span class="ellipsis-value" :title="expiryForm.package_name || ''">
{{ expiryForm.package_name || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐编码">
<span class="ellipsis-value" :title="expiryForm.package_code || ''">
{{ expiryForm.package_code || '-' }}
</span>
</ElFormItem>
<ElFormItem label="套餐默认生效条件">
{{ expiryForm.default_expiry_base_name || '-' }}
</ElFormItem>
<ElFormItem label="覆盖生效条件">
<ElSelect v-model="expiryForm.expiry_base_override" style="width: 100%">
<ElOption label="跟随套餐默认" value="default" />
<ElOption label="购买即生效" value="from_purchase" />
<ElOption label="实名激活时生效" value="from_activation" />
</ElSelect>
</ElFormItem>
<ElFormItem label="最终生效条件">
{{ expiryForm.effective_expiry_base_name || '-' }}
</ElFormItem>
<div class="form-tip">仅影响后续新订单不影响已购买套餐</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="expiryDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="expirySubmitLoading" @click="saveExpiryBase">
保存
</ElButton>
</div>
</template>
</ElDialog>
</div> </div>
</ArtTableFullScreen> </ArtTableFullScreen>
</template> </template>
@@ -196,6 +278,7 @@
PackageAllocationExpiryBaseOverride PackageAllocationExpiryBaseOverride
} from '@/types/api' } from '@/types/api'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import { import {
mergeGrantPackageCandidates, mergeGrantPackageCandidates,
type GrantPackageCandidate type GrantPackageCandidate
@@ -206,6 +289,7 @@
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
@@ -221,33 +305,61 @@
const packageDialogVisible = ref(false) const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add') const packageDialogType = ref<'add' | 'edit'>('add')
const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default' type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type PackageFormState = { type PackageCostFormItem = {
package_id?: number package_id: number
package_name?: string package_name?: string
package_code?: string package_code?: string
cost_price_yuan: number cost_price_yuan: number
original_cost_price?: number original_cost_price?: number
suggested_retail_price?: number suggested_retail_price?: number
}
type PackageFormState = {
package_id?: number
allocation_id?: number allocation_id?: number
package_ids: number[]
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
expiry_base_override: ExpiryBaseSelection expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null default_expiry_base_name?: string | null
expiry_base_override_name?: string | null expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null effective_expiry_base_name?: string | null
packages: PackageCostFormItem[]
}
type ExpiryFormState = {
package_id?: number
allocation_id?: number
package_name?: string
package_code?: string
default_expiry_base_name?: string | null
expiry_base_override: ExpiryBaseSelection
expiry_base_override_name?: string | null
effective_expiry_base_name?: string | null
} }
const createDefaultPackageForm = (): PackageFormState => ({ const createDefaultPackageForm = (): PackageFormState => ({
package_id: undefined, package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined, package_name: undefined,
package_code: undefined, package_code: undefined,
cost_price_yuan: 0, cost_price_yuan: 0,
expiry_base_override: 'default', expiry_base_override: 'default',
initial_expiry_base_override: 'default' initial_expiry_base_override: 'default',
packages: []
}) })
const packageForm = ref<PackageFormState>(createDefaultPackageForm()) const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const assignPackageForm = (values: Partial<PackageFormState> = {}) => { const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values) Object.assign(packageForm.value, createDefaultPackageForm(), values)
@@ -271,11 +383,30 @@
} }
const packageRules = computed<FormRules>(() => ({ const packageRules = computed<FormRules>(() => ({
package_id: [ package_ids: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' } {
validator: (_rule, value, callback) => {
if (packageDialogType.value !== 'add') {
callback()
return
}
if (!Array.isArray(value) || value.length === 0) {
callback(new Error('请至少选择一个套餐'))
} else if (value.length > 100) {
callback(new Error('最多选择100个套餐'))
} else {
callback()
}
},
trigger: 'change'
}
], ],
cost_price_yuan: [ cost_price_yuan: [
{ required: true, message: '请输入成本价', trigger: 'blur' }, {
required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{ {
validator: (rule, value, callback) => { validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') { if (value === undefined || value === null || value === '') {
@@ -294,6 +425,14 @@
const getActions = (row: GrantPackageInfo) => { const getActions = (row: GrantPackageInfo) => {
const actions: any[] = [] const actions: any[] = []
if (canUpdateExpiryBase) {
actions.push({
label: '修改生效条件',
handler: () => showExpiryBaseDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:edit_packages_list')) { if (hasAuth('series_grants:edit_packages_list')) {
actions.push({ actions.push({
label: '编辑', label: '编辑',
@@ -338,7 +477,7 @@
const showAddPackageDialog = () => { const showAddPackageDialog = () => {
packageDialogType.value = 'add' packageDialogType.value = 'add'
assignPackageForm() assignPackageForm({ package_ids: [], packages: [] })
loadAvailablePackages() loadAvailablePackages()
packageDialogVisible.value = true packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate()) nextTick(() => packageFormRef.value?.clearValidate())
@@ -350,12 +489,12 @@
assignPackageForm({ assignPackageForm({
package_id: row.package_id, package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name, package_name: row.package_name,
package_code: row.package_code, package_code: row.package_code,
cost_price_yuan: row.cost_price / 100, cost_price_yuan: row.cost_price / 100,
original_cost_price: packagePricing.original_cost_price, original_cost_price: packagePricing.original_cost_price,
suggested_retail_price: packagePricing.suggested_retail_price, suggested_retail_price: packagePricing.suggested_retail_price,
allocation_id: row.allocation_id,
expiry_base_override: row.expiry_base_override ?? 'default', expiry_base_override: row.expiry_base_override ?? 'default',
initial_expiry_base_override: row.expiry_base_override ?? 'default', initial_expiry_base_override: row.expiry_base_override ?? 'default',
default_expiry_base_name: row.default_expiry_base_name, default_expiry_base_name: row.default_expiry_base_name,
@@ -366,25 +505,43 @@
nextTick(() => packageFormRef.value?.clearValidate()) nextTick(() => packageFormRef.value?.clearValidate())
} }
const showExpiryBaseDialog = (row: GrantPackageInfo) => {
if (row.allocation_id === undefined || row.allocation_id === null) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
expiryForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
default_expiry_base_name: row.default_expiry_base_name,
expiry_base_override: row.expiry_base_override ?? 'default',
expiry_base_override_name: row.expiry_base_override_name,
effective_expiry_base_name: row.effective_expiry_base_name
}
expiryDialogVisible.value = true
}
const loadAvailablePackages = async (packageName?: string) => { const loadAvailablePackages = async (packageName?: string) => {
if (!seriesId.value) return if (!seriesId.value) return
packageLoading.value = true packageLoading.value = true
try { try {
const pageSize = 100 const pageSize = 100
const allPackages: any[] = [] const allPackages: any[] = []
let page = 1 let page = 1
let total = 0 let total = 0
do { do {
const params: any = { page, page_size: pageSize, series_id: seriesId.value } const params: any = { page, page_size: pageSize, series_id: seriesId.value }
if (packageName) params.package_name = packageName if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params) const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break if (res.code !== 0) break
allPackages.push(...(res.data.items || [])) allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length total = res.data.total || allPackages.length
page += 1 page += 1
} while (allPackages.length < total) } while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value) availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
} catch (error) { } catch (error) {
console.error('加载套餐选项失败:', error) console.error('加载套餐选项失败:', error)
} finally { } finally {
@@ -400,6 +557,50 @@
} }
} }
const createPackageCostFormItem = (packageId: number): PackageCostFormItem => {
const selectedPackage = availablePackages.value.find((pkg) => pkg.id === packageId)
const originalCostPrice = selectedPackage?.cost_price ? selectedPackage.cost_price / 100 : 0
return {
package_id: packageId,
package_name: selectedPackage?.package_name,
package_code: selectedPackage?.package_code,
cost_price_yuan: originalCostPrice,
original_cost_price: originalCostPrice,
suggested_retail_price:
selectedPackage?.suggested_retail_price !== undefined &&
selectedPackage?.suggested_retail_price !== null
? selectedPackage.suggested_retail_price / 100
: undefined
}
}
const getPackageCostPriceMax = (pkg?: {
original_cost_price?: number
suggested_retail_price?: number | null
}) => {
if (pkg?.suggested_retail_price !== undefined && pkg.suggested_retail_price !== null) {
return Number((pkg.suggested_retail_price * 1.5).toFixed(2))
}
if (pkg?.original_cost_price !== undefined && pkg.original_cost_price !== null) {
return Number((pkg.original_cost_price * 1.5).toFixed(2))
}
return undefined
}
watch(
() => packageForm.value.package_ids,
(packageIds) => {
if (packageDialogType.value !== 'add') return
const existingItems = new Map(
packageForm.value.packages.map((item) => [item.package_id, item])
)
packageForm.value.packages = packageIds.map(
(packageId) => existingItems.get(packageId) || createPackageCostFormItem(packageId)
)
},
{ deep: true }
)
watch( watch(
() => packageForm.value.package_id, () => packageForm.value.package_id,
(packageId) => { (packageId) => {
@@ -436,19 +637,51 @@
} }
const costPrice = Math.round(currentCostPriceYuan * 100) const costPrice = Math.round(currentCostPriceYuan * 100)
if (packageDialogType.value === 'add') { if (packageDialogType.value === 'add') {
if (packageForm.value.packages.length === 0) {
ElMessage.error('请至少选择一个套餐')
return
}
const packages: GrantPackageItem[] = packageForm.value.packages.map((pkg) => {
const currentCostPrice = Number(pkg.cost_price_yuan)
if (!Number.isFinite(currentCostPrice) || currentCostPrice < 0) {
throw new Error(`套餐 ${pkg.package_name || pkg.package_id} 的成本价无效`)
}
const maxCostPrice = getPackageCostPriceMax(pkg)
if (maxCostPrice !== undefined && currentCostPrice > maxCostPrice) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能高于 ¥${maxCostPrice.toFixed(2)}`
)
}
if (
pkg.original_cost_price !== undefined &&
currentCostPrice < pkg.original_cost_price
) {
throw new Error(
`套餐 ${pkg.package_name || pkg.package_id} 的成本价不能低于 ¥${pkg.original_cost_price.toFixed(2)}`
)
}
return {
package_id: pkg.package_id,
cost_price: Math.round(currentCostPrice * 100)
}
})
await ShopSeriesGrantService.manageGrantPackages(grantId.value, { await ShopSeriesGrantService.manageGrantPackages(grantId.value, {
expiry_base_override: expiry_base_override:
packageForm.value.expiry_base_override === 'default' packageForm.value.expiry_base_override === 'default'
? null ? null
: packageForm.value.expiry_base_override, : packageForm.value.expiry_base_override,
packages: [{ package_id: packageForm.value.package_id!, cost_price: costPrice }] packages
}) })
} else { } else {
const expiryBaseChanged = const expiryBaseChanged =
packageForm.value.expiry_base_override !== packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override packageForm.value.initial_expiry_base_override
if (expiryBaseChanged && !packageForm.value.allocation_id) { if (
ElMessage.error('该套餐分配记录缺少ID无法更新生效条件') expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return return
} }
@@ -477,13 +710,39 @@
packageDialogVisible.value = false packageDialogVisible.value = false
await fetchPackageList() await fetchPackageList()
} catch (error) { } catch (error) {
console.error(error) ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally { } finally {
submitLoading.value = false submitLoading.value = false
} }
}) })
} }
const saveExpiryBase = async () => {
if (expiryForm.value.allocation_id === undefined || expiryForm.value.allocation_id === null) {
return
}
expirySubmitLoading.value = true
try {
const response = await ShopPackageAllocationService.updateShopPackageAllocationExpiryBase(
expiryForm.value.allocation_id,
{
expiry_base_override:
expiryForm.value.expiry_base_override === 'default'
? null
: expiryForm.value.expiry_base_override
}
)
if (response.code === 0) {
ElMessage.success('生效条件更新成功')
expiryDialogVisible.value = false
await fetchPackageList()
}
} finally {
expirySubmitLoading.value = false
}
}
const handleDeletePackage = (row: GrantPackageInfo) => { const handleDeletePackage = (row: GrantPackageInfo) => {
import('element-plus').then(({ ElMessageBox }) => { import('element-plus').then(({ ElMessageBox }) => {
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', { ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', {
@@ -572,6 +831,38 @@
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
.package-config-list {
width: 100%;
}
.package-config-item {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.package-config-name,
.ellipsis-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-config-name {
flex: 1;
}
.ellipsis-value {
display: block;
width: 260px;
}
.dialog-footer { .dialog-footer {
text-align: right; text-align: right;
} }

View File

@@ -44,16 +44,13 @@
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<ElForm label-width="120px"> <ElForm label-width="120px">
<ElFormItem label="配置 Key">
<ElInput :model-value="currentConfig?.config_key || '-'" disabled />
</ElFormItem>
<ElFormItem label="配置说明"> <ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span> <span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem> </ElFormItem>
<ElFormItem label="配置值"> <ElFormItem label="配置值">
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods"> <ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method"> <ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ method }} {{ paymentMethodLabels[method] }}
</ElCheckbox> </ElCheckbox>
</ElCheckboxGroup> </ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" /> <ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
@@ -89,7 +86,6 @@
placeholder="请输入配置值" placeholder="请输入配置值"
/> />
</ElFormItem> </ElFormItem>
<div v-if="valueHint" class="config-hint">{{ valueHint }}</div>
</ElForm> </ElForm>
<template #footer> <template #footer>
@@ -103,6 +99,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue' import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules' import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types' import type { SearchFormItem } from '@/types'
@@ -120,6 +117,7 @@
defineOptions({ name: 'SystemConfigs' }) defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
const route = useRoute()
const moduleOptions = [ const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' }, { label: '运营商回调配置', value: 'carrier_callback' },
@@ -149,14 +147,18 @@
const editNumber = ref<number | null>(null) const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([]) const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay'] const paymentMethods: AllowedPaymentMethod[] = ['wallet', 'wechat', 'alipay']
const paymentMethodLabels: Record<AllowedPaymentMethod, string> = {
wallet: '钱包',
wechat: '微信',
alipay: '支付宝'
}
const targetConfigHandled = ref(false)
const pagination = reactive({ page: 1, page_size: 20, total: 0 }) const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const columnOptions = [ const columnOptions = [
{ label: '配置 Key', prop: 'config_key' },
{ label: '模块', prop: 'module' }, { label: '模块', prop: 'module' },
{ label: '配置说明', prop: 'description' }, { label: '配置说明', prop: 'description' },
{ label: '配置值', prop: 'value' }, { label: '配置值', prop: 'value' },
{ label: '控件', prop: 'control' },
{ label: '注册状态', prop: 'registered' }, { label: '注册状态', prop: 'registered' },
{ label: '只读', prop: 'readonly' }, { label: '只读', prop: 'readonly' },
{ label: '敏感', prop: 'sensitive' }, { label: '敏感', prop: 'sensitive' },
@@ -167,7 +169,6 @@
moduleOptions.find((item) => item.value === module)?.label || module moduleOptions.find((item) => item.value === module)?.label || module
const { columnChecks, columns } = useCheckedColumns(() => [ const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'config_key', label: '配置 Key', minWidth: 220, showOverflowTooltip: true },
{ {
prop: 'module', prop: 'module',
label: '模块', label: '模块',
@@ -178,10 +179,10 @@
{ {
prop: 'value', prop: 'value',
label: '配置值', label: '配置值',
minWidth: 150, minWidth: 260,
formatter: (row: SystemConfigItem) => row.value || '-' showOverflowTooltip: true,
formatter: (row: SystemConfigItem) => formatConfigValue(row)
}, },
{ prop: 'control', label: '控件', width: 100 },
{ {
prop: 'registered', prop: 'registered',
label: '注册状态', label: '注册状态',
@@ -216,7 +217,8 @@
) )
const isPaymentConfig = computed(() => const isPaymentConfig = computed(() =>
[PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes( [PAYMENT_CONFIG_KEYS.card, PAYMENT_CONFIG_KEYS.device].includes(
currentConfig.value?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS] currentConfig.value
?.config_key as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
) )
) )
const isIntegerConfig = computed( const isIntegerConfig = computed(
@@ -233,14 +235,18 @@
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json' !isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
) )
const valueHint = computed(() => { const isPaymentConfigKey = (configKey: string) =>
if (!currentConfig.value) return '' Object.values(PAYMENT_CONFIG_KEYS).includes(
const { min, max } = currentConfig.value configKey as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
if (min !== null && max !== null) return `取值范围:${min}${max}` )
if (min !== null) return `最小值:${min}`
if (max !== null) return `最大值:${max}` const formatConfigValue = (config: SystemConfigItem) => {
return '' if (isPaymentConfigKey(config.config_key)) {
}) const methods = parsePaymentMethods(config.value)
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
}
return config.value || '-'
}
const getSubmittedValue = () => { const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value) if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
@@ -293,6 +299,12 @@
if (res.code === 0) { if (res.code === 0) {
configList.value = res.data.list || [] configList.value = res.data.list || []
pagination.total = res.data.total || 0 pagination.total = res.data.total || 0
const targetKey = String(route.query.config_key || '')
const targetConfig = configList.value.find((config) => config.config_key === targetKey)
if (targetKey && targetConfig && !targetConfigHandled.value) {
targetConfigHandled.value = true
showEditDialog(targetConfig)
}
} }
} catch (error) { } catch (error) {
console.error('获取系统配置失败:', error) console.error('获取系统配置失败:', error)
@@ -370,10 +382,5 @@
<style scoped lang="scss"> <style scoped lang="scss">
.system-configs-page { .system-configs-page {
.config-hint {
margin: -12px 0 0 120px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
} }
</style> </style>

View File

@@ -0,0 +1,244 @@
<template>
<ElDialog
v-model="visible"
:title="isEdit ? '编辑企微应用' : '新增企微应用'"
width="50%"
destroy-on-close
@closed="resetForm"
>
<div class="dialog-description">应用凭据由平台管理员维护保存后可在应用列表测试连接</div>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="130px" class="application-form">
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="企业 ID" prop="corp_id">
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="AgentID" prop="agent_id">
<ElInputNumber
v-model="form.agent_id"
:min="1"
controls-position="right"
class="full-width"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="应用名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入应用名称" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="Secret" prop="secret">
<ElInput
v-model="form.secret"
type="password"
show-password
placeholder="请输入应用 Secret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">回调配置</ElDivider>
<ElRow :gutter="24">
<ElCol :span="24">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput
v-model="form.callback_token"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入回调 Token"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput
v-model="form.encoding_aes_key"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入 43 位 EncodingAESKey"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
v-if="isEdit"
v-permission="JULY_PERMISSIONS.wecom.application"
:loading="testing"
@click="testApplication"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
:loading="saving"
@click="saveApplication"
>
保存应用
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationFormDialog' })
const props = defineProps<{
modelValue: boolean
application: WecomApplication | null
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isEdit = computed(() => Boolean(props.application?.id))
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const form = reactive({
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
})
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入 AgentID', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
})
const fillForm = (application: WecomApplication | null) => {
Object.assign(
form,
application
? {
corp_id: application.corp_id,
agent_id: application.agent_id,
name: application.name,
secret: application.secret || '',
callback_token: application.callback_token || '',
encoding_aes_key: application.encoding_aes_key || '',
enabled: application.status === 1
}
: {
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
}
)
}
const resetForm = () => {
fillForm(null)
formRef.value?.clearValidate()
}
const saveApplication = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const response = await WecomService.saveApplication({
corp_id: form.corp_id,
agent_id: form.agent_id,
name: form.name,
secret: form.secret,
callback_token: form.callback_token,
encoding_aes_key: form.encoding_aes_key,
status: form.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('企微应用保存成功')
emit('success')
visible.value = false
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!props.application?.id) return
testing.value = true
try {
const response = await WecomService.testApplication(props.application.id)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
watch(
() => [props.modelValue, props.application] as const,
([opened]) => {
if (opened) fillForm(props.application)
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.dialog-description {
margin-bottom: 20px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.application-form {
padding-right: 8px;
}
.full-width {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -1,253 +0,0 @@
<template>
<div class="wecom-application-detail-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">{{ isEdit ? '编辑企微应用' : '新增企微应用' }}</div>
<div class="page-description"
>应用凭据由平台管理员维护保存后可在应用列表测试连接</div
>
</div>
<ElButton @click="goBack">返回应用列表</ElButton>
</div>
</template>
<ElForm
ref="formRef"
:model="form"
:rules="rules"
label-width="140px"
class="application-form"
>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="企业 ID" prop="corp_id">
<ElInput v-model="form.corp_id" placeholder="请输入企业微信 CorpID" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="AgentID" prop="agent_id">
<ElInputNumber
v-model="form.agent_id"
:min="1"
controls-position="right"
class="full-width"
/>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="应用名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入应用名称" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="Secret" prop="secret">
<ElInput
v-model="form.secret"
type="password"
show-password
placeholder="请输入应用 Secret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">回调配置</ElDivider>
<ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="回调 Token" prop="callback_token">
<ElInput v-model="form.callback_token" placeholder="请输入回调 Token" />
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="EncodingAESKey" prop="encoding_aes_key">
<ElInput v-model="form.encoding_aes_key" placeholder="请输入 43 位 EncodingAESKey" />
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="启用状态">
<ElSwitch v-model="form.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<div class="form-actions">
<ElButton @click="goBack">取消</ElButton>
<ElButton
v-if="isEdit"
v-permission="JULY_PERMISSIONS.wecom.application"
:loading="testing"
@click="testApplication"
>
测试连接
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
:loading="saving"
@click="saveApplication"
>
保存应用
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
defineOptions({ name: 'WecomApplicationDetail' })
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const testing = ref(false)
const applicationId = computed(() => Number(route.params.id) || 0)
const isEdit = computed(() => applicationId.value > 0)
const form = reactive({
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
})
const rules = reactive<FormRules>({
corp_id: [{ required: true, message: '请输入企业 ID', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入 AgentID', trigger: 'change' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
secret: [{ required: true, message: '请输入 Secret', trigger: 'blur' }]
})
const fillForm = (application?: WecomApplication) => {
Object.assign(
form,
application
? {
corp_id: application.corp_id,
agent_id: application.agent_id,
name: application.name,
secret: application.secret || '',
callback_token: application.callback_token || '',
encoding_aes_key: application.encoding_aes_key || '',
enabled: application.status === 1
}
: {
corp_id: '',
agent_id: 0,
name: '',
secret: '',
callback_token: '',
encoding_aes_key: '',
enabled: true
}
)
}
const loadApplication = async () => {
if (!isEdit.value) {
fillForm()
return
}
const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code === 0) {
const application = response.data.items?.find((item) => item.id === applicationId.value)
if (application) fillForm(application)
else {
ElMessage.error('未找到对应的企微应用')
goBack()
}
}
}
const saveApplication = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const response = await WecomService.saveApplication({
corp_id: form.corp_id,
agent_id: form.agent_id,
name: form.name,
secret: form.secret,
callback_token: form.callback_token,
encoding_aes_key: form.encoding_aes_key,
status: form.enabled ? 1 : 0
})
if (response.code === 0) {
ElMessage.success('企微应用保存成功')
await router.push(RoutesAlias.WecomApplications)
}
} finally {
saving.value = false
}
}
const testApplication = async () => {
if (!applicationId.value) return
testing.value = true
try {
const response = await WecomService.testApplication(applicationId.value)
if (response.code === 0) {
if (response.data.success) ElMessage.success('连接成功,已取得 access_token')
else ElMessage.warning('连接失败,请检查应用凭据')
}
} finally {
testing.value = false
}
}
const goBack = () => void router.push(RoutesAlias.WecomApplications)
onMounted(() => void loadApplication())
</script>
<style scoped lang="scss">
.wecom-application-detail-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.application-form {
max-width: 1000px;
}
.full-width {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
}
</style>

View File

@@ -1,107 +1,128 @@
<template> <template>
<div class="wecom-applications-page"> <ArtTableFullScreen>
<ElCard shadow="never"> <div id="table-full-screen" class="wecom-applications-page">
<template #header> <ElCard shadow="never" class="art-table-card">
<div class="page-header"> <ArtTableHeader
<div> :column-list="columnOptions"
<div class="page-title">企微应用管理</div> v-model:columns="columnChecks"
<div class="page-description">管理企业微信应用凭据并查看连接和默认发起人状态</div> @refresh="loadApplications"
</div> >
<ElButton <template #left>
v-permission="JULY_PERMISSIONS.wecom.application"
type="primary"
@click="goToCreate"
>
新增应用
</ElButton>
</div>
</template>
<ElTable v-loading="loading" :data="applications" border>
<ElTableColumn prop="name" label="应用名称" min-width="160" show-overflow-tooltip />
<ElTableColumn label="凭据状态" width="110">
<template #default="scope">
<ElTag :type="scope.row.credentials_set ? 'success' : 'warning'" size="small">
{{ scope.row.credentials_set ? '已完整' : '未完整' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="启用状态" width="100">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="默认发起人" min-width="150">
<template #default="scope">{{ scope.row.default_creator_name || '未设置' }}</template>
</ElTableColumn>
<ElTableColumn label="最近连接" min-width="170">
<template #default="scope">{{ formatDate(scope.row.last_connected_at) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="300" fixed="right">
<template #default="scope">
<ElButton <ElButton
v-permission="JULY_PERMISSIONS.wecom.application" v-permission="JULY_PERMISSIONS.wecom.application"
link
type="primary" type="primary"
@click="testApplication(scope.row.id)" @click="openCreateDialog"
> >
测试连接 新增应用
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
link
type="primary"
@click="goToMembers(scope.row.id)"
>
成员管理
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
@click="goToDetail(scope.row.id)"
>
编辑配置
</ElButton> </ElButton>
</template> </template>
</ElTableColumn> </ArtTableHeader>
</ElTable>
<div class="pagination-wrapper"> <ArtTable
<ElPagination ref="tableRef"
v-model:current-page="pagination.page" row-key="id"
v-model:page-size="pagination.page_size" :loading="loading"
:data="applications"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total" :total="pagination.total"
:page-sizes="[20, 50, 100]" :margin-top="10"
layout="total, sizes, prev, pager, next" :actions="getActions"
:actions-width="160"
:inline-actions-count="1"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="loadApplications" @current-change="loadApplications"
/> >
</div> <template #default>
</ElCard> <ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</div> </template>
</ArtTable>
</ElCard>
</div>
<WecomApplicationFormDialog
v-model="applicationDialogVisible"
:application="editingApplication"
@success="handleApplicationSaved"
/>
</ArtTableFullScreen>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { h, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules' import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants' import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api' import type { WecomApplication } from '@/types/api'
import { formatDateTime } from '@/utils/business/format' import { formatDateTime } from '@/utils/business/format'
import WecomApplicationFormDialog from './components/WecomApplicationFormDialog.vue'
defineOptions({ name: 'WecomApplications' }) defineOptions({ name: 'WecomApplications' })
const router = useRouter() const router = useRouter()
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false) const loading = ref(false)
const applications = ref<WecomApplication[]>([]) const applications = ref<WecomApplication[]>([])
const applicationDialogVisible = ref(false)
const editingApplication = ref<WecomApplication | null>(null)
const pagination = reactive({ page: 1, page_size: 20, total: 0 }) const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-') const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const columnOptions = [
{ label: '应用名称', prop: 'name' },
{ label: '凭据状态', prop: 'credentials_set' },
{ label: '启用状态', prop: 'status' },
{ label: '默认发起人', prop: 'default_creator_name' },
{ label: '最近连接', prop: 'last_connected_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'name',
label: '应用名称',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'credentials_set',
label: '凭据状态',
width: 110,
formatter: (row: WecomApplication) =>
h(ElTag, { type: row.credentials_set ? 'success' : 'warning', size: 'small' }, () =>
row.credentials_set ? '已完整' : '未完整'
)
},
{
prop: 'status',
label: '启用状态',
width: 100,
formatter: (row: WecomApplication) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'default_creator_name',
label: '默认发起人',
minWidth: 150,
formatter: (row: WecomApplication) => row.default_creator_name || '未设置'
},
{
prop: 'last_connected_at',
label: '最近连接',
width: 180,
formatter: (row: WecomApplication) => formatDate(row.last_connected_at)
}
])
const loadApplications = async () => { const loadApplications = async () => {
loading.value = true loading.value = true
try { try {
@@ -124,8 +145,20 @@
void loadApplications() void loadApplications()
} }
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`) const openCreateDialog = () => {
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`) editingApplication.value = null
applicationDialogVisible.value = true
}
const openEditDialog = (application: WecomApplication) => {
editingApplication.value = application
applicationDialogVisible.value = true
}
const handleApplicationSaved = () => {
void loadApplications()
}
const goToMembers = (id: number) => const goToMembers = (id: number) =>
void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } }) void router.push({ path: RoutesAlias.WecomMembers, query: { application_id: String(id) } })
@@ -138,34 +171,31 @@
} }
} }
const getActions = (row: WecomApplication) => {
const actions: any[] = []
if (hasAuth(JULY_PERMISSIONS.wecom.application)) {
actions.push({
label: '测试连接',
handler: () => testApplication(row.id),
type: 'primary'
})
actions.push({
label: '编辑配置',
handler: () => openEditDialog(row),
type: 'primary'
})
}
if (hasAuth(JULY_PERMISSIONS.wecom.member)) {
actions.push({
label: '成员管理',
handler: () => goToMembers(row.id),
type: 'primary'
})
}
return actions
}
onMounted(() => void loadApplications()) onMounted(() => void loadApplications())
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss"></style>
.wecom-applications-page {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -1,167 +1,94 @@
<template> <template>
<div class="wecom-members-page"> <ArtTableFullScreen>
<ElCard shadow="never"> <div id="table-full-screen" class="wecom-members-page">
<template #header> <ArtSearchBar
<div class="page-header"> v-model:filter="searchForm"
<div> :items="searchFormItems"
<div class="page-title">企微成员管理</div> :show-expand="false"
<div class="page-description" @reset="handleReset"
>同步应用可见成员设置默认审批发起人并完成平台账号绑定</div @search="handleSearch"
> />
</div>
<ElButton @click="goToApplications">返回应用列表</ElButton>
</div>
</template>
<ElForm inline class="search-form" @submit.prevent> <ElCard v-loading="loading" shadow="never" class="art-table-card members-card">
<ElFormItem label="企微应用"> <div class="members-toolbar">
<ElSelect <div class="toolbar-actions">
v-model="selectedApplicationId"
filterable
class="application-select"
placeholder="请选择企微应用"
@change="handleApplicationChange"
>
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="成员搜索">
<ElInput
v-model="keyword"
clearable
placeholder="姓名"
class="keyword-input"
@keyup.enter="handleSearch"
/>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
<ElButton @click="handleReset">重置</ElButton>
</ElFormItem>
</ElForm>
<div class="toolbar">
<div class="selected-application">
当前应用<span>{{ selectedApplication?.name || '未选择' }}</span>
<ElTag v-if="selectedApplication?.default_creator_name" type="success" size="small">
默认发起人{{ selectedApplication.default_creator_name }}
</ElTag>
</div>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedApplicationId"
:loading="syncing"
@click="syncMembers"
>
同步成员
</ElButton>
</div>
<ElTable
v-loading="loading"
:data="members"
row-key="userid"
highlight-current-row
border
@row-click="selectMember"
>
<ElTableColumn label="选择" width="70" align="center">
<template #default="scope">
<ElRadio v-model="selectedUserid" :label="scope.row.userid">
<span class="sr-only">选择 {{ scope.row.name }}</span>
</ElRadio>
</template>
</ElTableColumn>
<ElTableColumn prop="name" label="成员姓名" min-width="150" />
<ElTableColumn prop="synced_at" label="同步时间" min-width="180" />
<ElTableColumn label="操作" width="230" fixed="right">
<template #default="scope">
<ElButton <ElButton
v-permission="JULY_PERMISSIONS.wecom.member" v-permission="JULY_PERMISSIONS.wecom.member"
link :disabled="!selectedApplicationId"
:loading="syncing"
type="primary" type="primary"
@click.stop="setDefaultCreator(scope.row)" @click="syncMembers"
> >
设为默认发起人 同步成员
</ElButton> </ElButton>
<ElButton <ElButton @click="goToApplications">返回应用列表</ElButton>
v-permission="JULY_PERMISSIONS.wecom.binding" </div>
link </div>
@click.stop="selectMember(scope.row)"
>
绑定账号
</ElButton>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper"> <ElEmpty v-if="!members.length && !loading" description="暂无企微成员" />
<ElPagination <div v-else class="member-grid">
v-model:current-page="pagination.page" <ElCard v-for="member in members" :key="member.userid" shadow="hover" class="member-card">
v-model:page-size="pagination.page_size" <div class="member-card__header">
:total="pagination.total" <div class="member-card__identity">
:page-sizes="[20, 50, 100]" <ElAvatar :size="36" class="member-card__avatar">
layout="total, sizes, prev, pager, next" {{ getMemberInitial(member.name) }}
@size-change="handleSizeChange" </ElAvatar>
@current-change="loadMembers" <div class="member-card__name" :title="member.name">
/> {{ member.name || '-' }}
</div> </div>
</ElCard> </div>
<ElTag v-if="isDefaultCreator(member)" type="success" size="small">
默认发起人
</ElTag>
<ElTag
v-else
v-permission="JULY_PERMISSIONS.wecom.member"
class="member-card__action-tag"
type="primary"
effect="plain"
size="small"
role="button"
tabindex="0"
@click.stop="setDefaultCreator(member)"
@keydown.enter.stop="setDefaultCreator(member)"
>
设为默认发起人
</ElTag>
</div>
<ElCard shadow="never" class="operation-card"> <div class="member-card__info">
<template #header>当前成员操作</template> <span class="member-card__label">同步时间</span>
<ElAlert <span>{{ formatDateTime(member.synced_at) }}</span>
v-if="selectedMember" </div>
:title="`已选择:${selectedMember.name}`" </ElCard>
type="info" </div>
:closable="false"
/>
<ElEmpty v-else description="请先在上方列表选择成员" :image-size="70" />
<div class="operation-row"> <div class="pagination-wrapper">
<span class="operation-label">绑定平台账号</span> <ElPagination
<ElInputNumber v-model:current-page="pagination.page"
v-model="bindingAccountId" v-model:page-size="pagination.page_size"
:min="1" :total="pagination.total"
:disabled="!selectedMember" :page-sizes="[20, 50, 100]"
controls-position="right" layout="total, sizes, prev, pager, next"
placeholder="账号 ID" @size-change="handleSizeChange"
/> @current-change="loadMembers"
<ElButton />
v-permission="JULY_PERMISSIONS.wecom.binding" </div>
type="primary" </ElCard>
:disabled="!selectedMember || !bindingAccountId" </div>
:loading="binding" </ArtTableFullScreen>
@click="bindAccount"
>
绑定账号
</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.member"
:disabled="!selectedMember"
:loading="savingDefault"
@click="saveSelectedDefaultCreator"
>
保存为默认发起人
</ElButton>
</div>
</ElCard>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { AccountService, WecomService } from '@/api/modules' import type { SearchFormItem } from '@/types'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants' import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias' import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication, WecomMember } from '@/types/api' import type { WecomApplication, WecomMember } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomMembers' }) defineOptions({ name: 'WecomMembers' })
@@ -170,30 +97,65 @@
const loading = ref(false) const loading = ref(false)
const syncing = ref(false) const syncing = ref(false)
const savingDefault = ref(false) const savingDefault = ref(false)
const binding = ref(false) const pendingDefaultUserid = ref('')
const applications = ref<WecomApplication[]>([]) const applications = ref<WecomApplication[]>([])
const members = ref<WecomMember[]>([]) const members = ref<WecomMember[]>([])
const selectedApplicationId = ref<number>() const searchForm = reactive({
const selectedUserid = ref('') application_id: undefined as number | undefined,
const bindingAccountId = ref<number>() keyword: ''
const keyword = ref('') })
const pagination = reactive({ page: 1, page_size: 20, total: 0 }) const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const selectedApplicationId = computed(() => searchForm.application_id)
const selectedApplication = computed(() => const selectedApplication = computed(() =>
applications.value.find((application) => application.id === selectedApplicationId.value) applications.value.find((application) => application.id === selectedApplicationId.value)
) )
const selectedMember = computed(() =>
members.value.find((member) => member.userid === selectedUserid.value) const searchFormItems = computed<SearchFormItem[]>(() => [
) {
label: '企微应用',
prop: 'application_id',
type: 'select',
options: applications.value.map((application) => ({
label: application.name,
value: application.id
})),
onChange: () => {
pagination.page = 1
void loadMembers()
},
config: {
filterable: true,
clearable: false,
placeholder: '请选择企微应用'
}
},
{
label: '成员搜索',
prop: 'keyword',
type: 'input',
config: {
clearable: true,
placeholder: '请输入成员姓名'
}
}
])
const isDefaultCreator = (member: WecomMember) =>
member.userid === selectedApplication.value?.default_creator_userid
const getMemberInitial = (name: string) => name.trim().slice(0, 1) || '-'
const loadApplications = async () => { const loadApplications = async () => {
const response = await WecomService.getApplications({ page: 1, page_size: 100 }) const response = await WecomService.getApplications({ page: 1, page_size: 100 })
if (response.code !== 0) return if (response.code !== 0) return
applications.value = response.data.items || [] applications.value = response.data.items || []
const queryApplicationId = Number(route.query.application_id) const queryApplicationId = Number(route.query.application_id)
selectedApplicationId.value = const preferredApplication =
applications.value.find((application) => application.id === queryApplicationId)?.id || applications.value.find((application) => application.id === queryApplicationId) ||
applications.value[0]?.id applications.value.find((application) => application.id === searchForm.application_id) ||
applications.value[0]
searchForm.application_id = preferredApplication?.id
} }
const loadMembers = async () => { const loadMembers = async () => {
@@ -207,33 +169,25 @@
const response = await WecomService.getMembers(selectedApplicationId.value, { const response = await WecomService.getMembers(selectedApplicationId.value, {
page: pagination.page, page: pagination.page,
page_size: pagination.page_size, page_size: pagination.page_size,
keyword: keyword.value.trim() || undefined keyword: searchForm.keyword.trim() || undefined
}) })
if (response.code === 0) { if (response.code === 0) {
members.value = response.data.items || [] members.value = response.data.items || []
pagination.total = response.data.total || 0 pagination.total = response.data.total || 0
if (!members.value.some((member) => member.userid === selectedUserid.value)) {
selectedUserid.value = ''
}
} }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const handleApplicationChange = () => {
pagination.page = 1
selectedUserid.value = ''
void loadMembers()
}
const handleSearch = () => { const handleSearch = () => {
pagination.page = 1 pagination.page = 1
void loadMembers() void loadMembers()
} }
const handleReset = () => { const handleReset = () => {
keyword.value = '' searchForm.keyword = ''
searchForm.application_id = applications.value[0]?.id
pagination.page = 1 pagination.page = 1
void loadMembers() void loadMembers()
} }
@@ -244,10 +198,6 @@
void loadMembers() void loadMembers()
} }
const selectMember = (member: WecomMember) => {
selectedUserid.value = member.userid
}
const syncMembers = async () => { const syncMembers = async () => {
if (!selectedApplicationId.value) return if (!selectedApplicationId.value) return
syncing.value = true syncing.value = true
@@ -263,49 +213,40 @@
} }
const setDefaultCreator = async (member: WecomMember) => { const setDefaultCreator = async (member: WecomMember) => {
if (!selectedApplicationId.value) return if (!selectedApplicationId.value || savingDefault.value) return
selectedUserid.value = member.userid
await saveSelectedDefaultCreator()
}
const saveSelectedDefaultCreator = async () => { try {
if (!selectedApplicationId.value || !selectedMember.value) { await ElMessageBox.confirm(
ElMessage.warning('请选择要设置的成员') `确定将 ${member.name} 设置为当前应用的默认发起人吗?`,
'确认设置默认发起人',
{
confirmButtonText: '确定设置',
cancelButtonText: '取消',
type: 'warning'
}
)
} catch {
return return
} }
savingDefault.value = true savingDefault.value = true
pendingDefaultUserid.value = member.userid
try { try {
const response = await WecomService.setDefaultCreator( const response = await WecomService.setDefaultCreator(
selectedApplicationId.value, selectedApplicationId.value,
selectedMember.value.userid member.userid
) )
if (response.code === 0) { if (response.code === 0) {
ElMessage.success('默认发起人已保存') ElMessage.success('默认发起人已保存')
const application = selectedApplication.value const application = selectedApplication.value
if (application) { if (application) {
application.default_creator_userid = selectedMember.value.userid application.default_creator_userid = member.userid
application.default_creator_name = selectedMember.value.name application.default_creator_name = member.name
} }
} }
} finally { } finally {
savingDefault.value = false savingDefault.value = false
} pendingDefaultUserid.value = ''
}
const bindAccount = async () => {
if (!selectedApplicationId.value || !selectedMember.value || !bindingAccountId.value) {
ElMessage.warning('请选择成员并输入账号 ID')
return
}
binding.value = true
try {
const response = await AccountService.bindWecom(bindingAccountId.value, {
application_id: selectedApplicationId.value,
userid: selectedMember.value.userid
})
if (response.code === 0) ElMessage.success('账号企微绑定成功')
} finally {
binding.value = false
} }
} }
@@ -319,105 +260,152 @@
<style scoped lang="scss"> <style scoped lang="scss">
.wecom-members-page { .wecom-members-page {
.page-header, .members-card {
.toolbar, min-height: 0;
.operation-row { overflow: hidden;
:deep(> .el-card__body) {
box-sizing: border-box;
min-height: 0;
overflow-y: auto;
padding-bottom: 24px;
}
}
.members-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.member-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding-bottom: 4px;
}
.member-card {
min-width: 0;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
:deep(.el-card__body) {
display: flex;
height: auto;
min-height: 108px;
flex-direction: column;
justify-content: space-between;
gap: 10px;
overflow: visible;
}
}
.member-card__header,
.member-card__info {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.page-header, .member-card__header {
.toolbar {
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 12px;
} }
.page-title { .member-card__identity {
color: var(--el-text-color-primary); display: flex;
font-size: 16px; min-width: 0;
flex: 1;
align-items: center;
gap: 10px;
}
.member-card__avatar {
flex-shrink: 0;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
font-weight: 600; font-weight: 600;
} }
.page-description { .member-card__name {
margin-top: 6px; min-width: 0;
color: var(--el-text-color-secondary); flex: 1;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-card__info {
justify-content: space-between;
gap: 16px;
color: var(--el-text-color-regular);
font-size: 13px; font-size: 13px;
span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.search-form { .member-card__label {
margin-top: 4px; color: var(--el-text-color-secondary);
padding: 14px 16px 0;
background: var(--el-fill-color-light);
border-radius: 4px;
} }
.application-select { .member-card__action-tag {
width: 280px; cursor: pointer;
} transition: opacity 0.2s ease;
.keyword-input { &:hover {
width: 220px; opacity: 0.8;
}
.toolbar {
margin: 20px 0 12px;
.selected-application {
color: var(--el-text-color-secondary);
span {
color: var(--el-text-color-primary);
font-weight: 600;
}
.el-tag {
margin-left: 10px;
}
} }
} }
.pagination-wrapper { .pagination-wrapper {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: 16px; margin-top: 20px;
} }
.operation-card { @media (max-width: 1400px) {
margin-top: 16px; .member-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
} }
.operation-row { @media (max-width: 1024px) {
flex-wrap: wrap; .member-grid {
gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px; }
} }
.operation-label { @media (max-width: 640px) {
color: var(--el-text-color-primary); .member-grid {
font-weight: 500; grid-template-columns: 1fr;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 768px) {
.page-header,
.toolbar {
align-items: flex-start;
flex-direction: column;
} }
.application-select, .members-toolbar {
.keyword-input { justify-content: flex-start;
width: 100%; }
.pagination-wrapper {
justify-content: center;
overflow-x: auto;
} }
} }
} }

View File

@@ -1,158 +1,240 @@
<template> <template>
<div class="wecom-scenes-page"> <ArtTableFullScreen>
<ElCard shadow="never"> <div id="table-full-screen" class="wecom-scenes-page">
<template #header> <ElCard shadow="never" class="art-table-card">
<div class="page-header"> <ArtTableHeader
<div> :column-list="columnOptions"
<div class="page-title">企微审批场景</div> v-model:columns="columnChecks"
<div class="page-description" @refresh="loadData"
>分别维护退款和线下代充值审批模板并提交后由后端校验控件映射</div >
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
@click="createScene"
> >
新增场景配置
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
row-key="business_type"
:loading="loading"
:data="scenes"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:margin-top="10"
:actions="getActions"
:actions-width="140"
@size-change="handleSizeChange"
@current-change="handleCurrentPageChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<ElDialog
v-model="editorVisible"
:title="editing ? '编辑审批场景' : '新增审批场景'"
width="55%"
class="wecom-scene-dialog"
destroy-on-close
>
<ElForm :model="sceneForm" label-width="80px" class="scene-editor-form">
<div class="dialog-section">
<div class="dialog-section__header">
<div class="dialog-section__title">基础配置</div>
<div class="dialog-section__description">选择审批业务和对应的企业微信模板</div>
</div> </div>
<ElButton v-permission="JULY_PERMISSIONS.wecom.scene" type="primary" @click="createScene">
新增场景配置 <ElRow :gutter="24">
<ElCol :xs="24" :sm="12">
<ElFormItem label="业务类型" required>
<ElSelect
v-model="sceneForm.business_type"
:disabled="editing"
class="full-width"
@change="handleBusinessTypeChange"
>
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="企微应用">
<ElSelect
v-model="sceneForm.application_id"
clearable
filterable
class="full-width"
>
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID">
<template #append>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
:loading="inspectingTemplate"
@click="syncTemplate()"
>
同步模板
</ElButton>
</template>
</ElInput>
<div v-if="templateName" class="template-name">当前模板{{ templateName }}</div>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="12">
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
</ElCol>
</ElRow>
</div>
<div class="dialog-section mapping-section">
<div class="dialog-section__header mapping-heading">
<div>
<div class="dialog-section__title">控件映射</div>
<div class="dialog-section__description">将业务字段绑定到企业微信模板控件</div>
</div>
<ElTag v-if="!templateControls.length" type="info" effect="plain" class="mapping-tip">
请先填写模板 ID 并点击同步模板再选择模板控件
</ElTag>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="mapping in mappingRows" :key="mapping.key" class="mapping-row">
<div class="mapping-row__grid">
<div class="mapping-field">
<span class="mapping-field__label">业务字段</span>
<ElSelect
v-model="mapping.business_field"
filterable
clearable
:loading="fieldsLoading"
placeholder="选择业务字段"
>
<ElOption
v-for="field in businessFields"
:key="field.code"
:label="field.name"
:value="field.code"
/>
</ElSelect>
</div>
<div class="mapping-field">
<span class="mapping-field__label">模板控件</span>
<ElInput
:model-value="mapping.control_title"
readonly
placeholder="模板控件名称"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件 ID</span>
<ElInput
:model-value="mapping.control_id"
readonly
placeholder="选择模板控件后自动填充"
/>
</div>
<div class="mapping-field">
<span class="mapping-field__label">控件类型</span>
<ElInput
:model-value="mapping.control_type"
readonly
placeholder="同步模板后自动填充"
/>
</div>
<div class="mapping-field mapping-field--option">
<span class="mapping-field__label">选择项映射</span>
<ElInput v-model="mapping.option_mapping" placeholder="JSON可为空对象" />
</div>
</div>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
</div>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="editorVisible = false">取消</ElButton>
<ElButton @click="createScene">清空表单</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
:loading="saving"
@click="saveScene"
>
保存并校验
</ElButton> </ElButton>
</div> </div>
</template> </template>
</ElDialog>
<div class="scene-layout"> </ArtTableFullScreen>
<div class="scene-list">
<div class="section-heading">场景配置列表</div>
<ElTable
v-loading="loading"
:data="scenes"
row-key="business_type"
highlight-current-row
border
@row-click="selectScene"
>
<ElTableColumn prop="business_type_name" label="业务类型" min-width="150" />
<ElTableColumn
prop="template_name"
label="模板名称"
min-width="180"
show-overflow-tooltip
/>
<ElTableColumn label="状态" width="90">
<template #default="scope">
<ElTag :type="scope.row.status === 1 ? 'success' : 'info'" size="small">
{{ scope.row.status_name }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="last_verified_at" label="最近校验" min-width="170" />
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="scope">
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
link
type="primary"
@click.stop="selectScene(scope.row)"
>
编辑
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<ElCard shadow="never" class="editor-card">
<template #header>
<div class="editor-header">
<span>{{ editing ? '编辑审批场景' : '新增审批场景' }}</span>
<ElTag v-if="editing" size="small" type="info">{{ sceneForm.business_type }}</ElTag>
</div>
</template>
<ElForm :model="sceneForm" label-width="110px">
<ElFormItem label="业务类型" required>
<ElSelect v-model="sceneForm.business_type" :disabled="editing" class="full-width">
<ElOption label="退款审批" value="refund_approval" />
<ElOption label="线下代充值审批" value="offline_recharge_approval" />
</ElSelect>
</ElFormItem>
<ElFormItem label="企微应用">
<ElSelect v-model="sceneForm.application_id" clearable filterable class="full-width">
<ElOption
v-for="application in applications"
:key="application.id"
:label="application.name"
:value="application.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="模板 ID" required>
<ElInput v-model="sceneForm.template_id" placeholder="请输入企微后台模板 ID" />
</ElFormItem>
<ElFormItem label="启用状态">
<ElSwitch v-model="sceneForm.enabled" active-text="启用" inactive-text="禁用" />
</ElFormItem>
<ElDivider content-position="left">控件映射</ElDivider>
<div class="mapping-heading">
<span>业务字段与模板控件的对应关系</span>
<ElButton text type="primary" @click="addMapping()">新增映射</ElButton>
</div>
<div v-if="mappingRows.length" class="mapping-list">
<div v-for="(mapping, index) in mappingRows" :key="mapping.key" class="mapping-row">
<ElInput v-model="mapping.business_field" placeholder="业务字段,如 refund_no" />
<ElInput v-model="mapping.control_id" placeholder="控件 ID" />
<ElInput v-model="mapping.control_type" placeholder="控件类型,如 Text" />
<ElInput
v-model="mapping.option_mapping"
placeholder="选择项映射 JSON可为空对象"
/>
<ElButton text type="danger" @click="removeMapping(index)">删除</ElButton>
</div>
</div>
<ElEmpty v-else description="暂无控件映射,请新增一条" :image-size="60" />
<div class="editor-actions">
<ElButton @click="createScene">清空表单</ElButton>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.scene"
type="primary"
:loading="saving"
@click="saveScene"
>
保存并校验
</ElButton>
</div>
</ElForm>
</ElCard>
</div>
</ElCard>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElTag } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules' import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants' import { JULY_PERMISSIONS } from '@/config/constants'
import type { import type {
WecomApplication, WecomApplication,
WecomBusinessField,
WecomBusinessType, WecomBusinessType,
WecomScene, WecomScene,
WecomSceneControlMapping WecomSceneControlMapping,
WecomTemplateControl
} from '@/types/api' } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomScenes' }) defineOptions({ name: 'WecomScenes' })
interface MappingEditor { interface MappingEditor {
key: number key: number
business_field: string business_field: string
control_title: string
control_id: string control_id: string
control_type: string control_type: string
required: boolean
option_mapping: string option_mapping: string
} }
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const fieldsLoading = ref(false)
const inspectingTemplate = ref(false)
const editorVisible = ref(false)
const applications = ref<WecomApplication[]>([]) const applications = ref<WecomApplication[]>([])
const businessFields = ref<WecomBusinessField[]>([])
const templateControls = ref<WecomTemplateControl[]>([])
const templateName = ref('')
const scenes = ref<WecomScene[]>([]) const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>() const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1) const nextMappingKey = ref(1)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const sceneForm = reactive({ const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType, business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined, application_id: undefined as number | undefined,
@@ -162,19 +244,69 @@
const mappingRows = ref<MappingEditor[]>([]) const mappingRows = ref<MappingEditor[]>([])
const editing = computed(() => Boolean(selectedBusinessType.value)) const editing = computed(() => Boolean(selectedBusinessType.value))
const addMapping = (mapping?: Partial<MappingEditor>) => { const columnOptions = [
mappingRows.value.push({ { label: '业务类型', prop: 'business_type_name' },
key: nextMappingKey.value++, { label: '模板名称', prop: 'template_name' },
business_field: mapping?.business_field || '', { label: '状态', prop: 'status' },
control_id: mapping?.control_id || '', { label: '最近校验', prop: 'last_verified_at' }
control_type: mapping?.control_type || '', ]
option_mapping: mapping?.option_mapping || '{}'
}) const formatDate = (value?: string | null) => (value ? formatDateTime(value) : '-')
const loadBusinessFields = async (businessType: WecomBusinessType) => {
fieldsLoading.value = true
try {
const response = await WecomService.getBusinessFields(businessType)
if (response.code === 0) {
businessFields.value = response.data.items || []
}
} finally {
fieldsLoading.value = false
}
} }
const removeMapping = (index: number) => mappingRows.value.splice(index, 1) const handleBusinessTypeChange = () => {
businessFields.value = []
mappingRows.value.forEach((mapping) => {
mapping.business_field = ''
})
void loadBusinessFields(sceneForm.business_type)
}
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'business_type_name',
label: '业务类型',
minWidth: 180
},
{
prop: 'template_name',
label: '模板名称',
minWidth: 220,
showOverflowTooltip: true
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: WecomScene) =>
h(
ElTag,
{ type: row.status === 1 ? 'success' : 'info', size: 'small' },
() => row.status_name
)
},
{
prop: 'last_verified_at',
label: '最近校验',
width: 180,
formatter: (row: WecomScene) => formatDate(row.last_verified_at)
}
])
const fillEditor = (scene?: WecomScene) => { const fillEditor = (scene?: WecomScene) => {
templateControls.value = []
templateName.value = ''
if (!scene) { if (!scene) {
selectedBusinessType.value = undefined selectedBusinessType.value = undefined
Object.assign(sceneForm, { Object.assign(sceneForm, {
@@ -184,7 +316,6 @@
enabled: true enabled: true
}) })
mappingRows.value = [] mappingRows.value = []
addMapping()
return return
} }
selectedBusinessType.value = scene.business_type selectedBusinessType.value = scene.business_type
@@ -197,41 +328,124 @@
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({ mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++, key: nextMappingKey.value++,
business_field: mapping.business_field, business_field: mapping.business_field,
control_title: '',
control_id: mapping.control_id, control_id: mapping.control_id,
control_type: mapping.control_type, control_type: mapping.control_type,
required: true,
option_mapping: JSON.stringify(mapping.option_mapping || {}) option_mapping: JSON.stringify(mapping.option_mapping || {})
})) }))
} }
const selectScene = (scene: WecomScene) => fillEditor(scene) const createScene = () => {
const createScene = () => fillEditor() fillEditor()
editorVisible.value = true
void loadBusinessFields(sceneForm.business_type)
}
const editScene = (scene: WecomScene) => {
fillEditor(scene)
editorVisible.value = true
void loadBusinessFields(scene.business_type)
void syncTemplate(false)
}
const syncMappingRows = (controls: WecomTemplateControl[]) => {
const existingMappings = new Map(
mappingRows.value.map((mapping) => [mapping.control_id, mapping])
)
mappingRows.value = controls.map((control) => {
const existing = existingMappings.get(control.id)
return {
key: existing?.key || nextMappingKey.value++,
business_field: existing?.business_field || '',
control_title: control.title,
control_id: control.id,
control_type: control.type,
required: control.required,
option_mapping: existing?.option_mapping || '{}'
}
})
}
const syncTemplate = async (showMessage = true) => {
if (!sceneForm.application_id) {
ElMessage.warning('请先选择企微应用')
return
}
if (!sceneForm.template_id.trim()) {
ElMessage.warning('请输入模板 ID')
return
}
inspectingTemplate.value = true
try {
const response = await WecomService.inspectTemplate(sceneForm.application_id, {
template_id: sceneForm.template_id.trim()
})
if (response.code === 0) {
templateControls.value = response.data.controls || []
templateName.value = response.data.name || ''
syncMappingRows(templateControls.value)
if (showMessage) {
ElMessage.success(`模板控件已同步,共 ${templateControls.value.length}`)
}
}
} finally {
inspectingTemplate.value = false
}
}
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
try { try {
const [applicationResponse, sceneResponse] = await Promise.all([ const [applicationResponse, sceneResponse] = await Promise.all([
WecomService.getApplications({ page: 1, page_size: 100 }), WecomService.getApplications({ page: 1, page_size: 100 }),
WecomService.getScenes({ page: 1, page_size: 100 }) WecomService.getScenes({
page: pagination.page,
page_size: pagination.page_size
})
]) ])
if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || [] if (applicationResponse.code === 0) applications.value = applicationResponse.data.items || []
if (sceneResponse.code === 0) { if (sceneResponse.code === 0) {
scenes.value = sceneResponse.data.items || [] scenes.value = sceneResponse.data.items || []
const current = scenes.value.find( pagination.total = sceneResponse.data.total || 0
(scene) => scene.business_type === selectedBusinessType.value if (selectedBusinessType.value) {
) const current = scenes.value.find(
fillEditor(current || scenes.value[0]) (scene) => scene.business_type === selectedBusinessType.value
)
if (current) fillEditor(current)
}
} }
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const handleSizeChange = (size: number) => {
pagination.page_size = size
pagination.page = 1
void loadData()
}
const handleCurrentPageChange = (page: number) => {
pagination.page = page
void loadData()
}
const parseMapping = (): WecomSceneControlMapping[] | undefined => { const parseMapping = (): WecomSceneControlMapping[] | undefined => {
const result: WecomSceneControlMapping[] = [] const result: WecomSceneControlMapping[] = []
for (const mapping of mappingRows.value) { for (const mapping of mappingRows.value) {
if (!mapping.business_field && !mapping.control_id && !mapping.control_type) continue if (!mapping.business_field) {
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) { if (mapping.required) {
ElMessage.warning('请完整填写控件映射字段') ElMessage.warning(
`请为必填控件“${mapping.control_title || mapping.control_id}”选择业务字段`
)
return
}
continue
}
if (!mapping.control_id || !mapping.control_type) {
ElMessage.warning(`控件“${mapping.control_title || mapping.control_id}”信息不完整`)
return return
} }
let optionMapping: Record<string, string> let optionMapping: Record<string, string>
@@ -271,6 +485,7 @@
}) })
if (response.code === 0) { if (response.code === 0) {
ElMessage.success('场景保存并校验成功') ElMessage.success('场景保存并校验成功')
editorVisible.value = false
await loadData() await loadData()
} }
} finally { } finally {
@@ -278,98 +493,164 @@
} }
} }
const getActions = (row: WecomScene) => {
if (!hasAuth(JULY_PERMISSIONS.wecom.scene)) return []
return [
{
label: '编辑',
handler: () => editScene(row),
type: 'primary' as const
}
]
}
onMounted(() => void loadData()) onMounted(() => void loadData())
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.wecom-scenes-page { .full-width {
.page-header, width: 100%;
.editor-header, }
.mapping-heading,
.editor-actions { .scene-editor-form {
display: flex; padding-bottom: 12px;
align-items: center;
.dialog-section {
padding: 18px 20px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
} }
.page-header, .dialog-section + .dialog-section {
.editor-header, margin-top: 18px;
.mapping-heading { }
.dialog-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
margin-bottom: 16px;
} }
.page-title { .dialog-section__title {
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 600;
} }
.page-description { .dialog-section__description {
margin-top: 6px; margin-top: 4px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
font-size: 13px; font-size: 12px;
} }
.scene-layout { .mapping-tip {
display: grid; flex-shrink: 0;
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr); margin-top: 0;
gap: 16px; white-space: nowrap;
} }
.section-heading { .template-name {
margin-bottom: 12px; margin-top: 6px;
color: var(--el-text-color-primary); overflow: hidden;
font-weight: 600; color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
} }
.editor-card { .mapping-section {
min-width: 0; padding-bottom: 12px;
}
.full-width {
width: 100%;
} }
.mapping-heading { .mapping-heading {
margin-bottom: 12px; margin-bottom: 0;
color: var(--el-text-color-secondary);
font-size: 13px;
} }
.mapping-list { .mapping-list {
display: flex; display: flex;
max-height: 420px;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
overflow-y: auto;
padding: 0 4px 4px;
} }
.mapping-row { .mapping-row {
display: grid; padding: 14px;
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto; border: 1px solid var(--el-border-color-lighter);
gap: 8px; border-radius: 6px;
align-items: center; margin-top: 6px;
background: var(--el-fill-color-blank);
} }
.editor-actions { .mapping-row__grid {
display: grid;
grid-template-columns:
minmax(120px, 1.1fr) minmax(140px, 1.3fr) minmax(120px, 1fr) minmax(100px, 0.8fr)
minmax(140px, 1.2fr);
gap: 12px;
}
.mapping-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.mapping-field__label {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.dialog-footer {
display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
margin-top: 24px;
}
@media (max-width: 1200px) {
.scene-layout {
grid-template-columns: 1fr;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.page-header { .dialog-section {
align-items: flex-start; padding: 14px;
flex-direction: column;
} }
.mapping-row { .dialog-section__header {
align-items: stretch;
flex-direction: column;
gap: 8px;
}
.mapping-tip {
align-self: flex-start;
white-space: normal;
}
.mapping-row__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.mapping-field--option {
grid-column: 1 / -1;
}
}
}
@media (max-width: 1200px) {
.mapping-row__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mapping-field--option {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
:deep(.wecom-scene-dialog) {
width: calc(100vw - 32px) !important;
} }
} }
</style> </style>