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

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
import { BaseService } from '../BaseService'
import type {
CancelExportTaskApiResponse,
CreateExportTaskApiResponse,
CreateExportTaskRequest,
ExportTaskDetail,
ExportTaskDetailApiResponse,
ExportTaskListApiResponse,
ExportTaskQueryParams
@@ -21,9 +19,4 @@ export class ExportTaskService extends BaseService {
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
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,
NotificationListResponse,
NotificationQueryParams,
NotificationReadRequest,
NotificationReadAllRequest,
NotificationReadAllResponse,
NotificationReadResponse,
@@ -29,10 +30,9 @@ export class NotificationService extends BaseService {
}
static markRead(id: number): Promise<BaseResponse<NotificationReadResponse>> {
return this.put<BaseResponse<NotificationReadResponse>>(
`/api/admin/notifications/${id}/read`,
{}
)
return this.put<BaseResponse<NotificationReadResponse>>(`/api/admin/notifications/${id}/read`, {
id
} satisfies NotificationReadRequest)
}
static markAllRead(

View File

@@ -7,6 +7,7 @@ import type {
WecomApplicationQueryParams,
WecomApplicationRequest,
WecomApplicationResponse,
WecomBusinessFieldListResponse,
WecomBusinessType,
WecomMemberListResponse,
WecomMemberQueryParams,
@@ -14,7 +15,9 @@ import type {
WecomSceneQueryParams,
WecomSceneRequest,
WecomSceneResponse,
WecomSyncMembersApiResponse
WecomSyncMembersApiResponse,
WecomTemplateDetailResponse,
WecomTemplateInspectRequest
} from '@/types/api'
export class WecomService extends BaseService {
@@ -38,14 +41,8 @@ export class WecomService extends BaseService {
)
}
static getMembers(
id: number,
params?: WecomMemberQueryParams
): Promise<WecomMemberListResponse> {
return this.get<WecomMemberListResponse>(
`/api/admin/wecom/applications/${id}/members`,
params
)
static getMembers(id: number, params?: WecomMemberQueryParams): Promise<WecomMemberListResponse> {
return this.get<WecomMemberListResponse>(`/api/admin/wecom/applications/${id}/members`, params)
}
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)
}
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(
accountId: number,
data: WecomAccountBindingRequest

View File

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

View File

@@ -85,13 +85,22 @@
}
)
const getNormalizedQuery = () => {
const query: Record<string, unknown> = {}
const getNormalizedQuery = (): Record<string, string> => {
const query: Record<string, string> = {}
Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') 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

View File

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

View File

@@ -158,15 +158,6 @@
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) {

View File

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

View File

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

View File

@@ -432,6 +432,9 @@
"standaloneCardList": "IoT Card Management",
"iotCardTask": "IoT Card Tasks",
"deviceTask": "Device Tasks",
"deviceBatchAllocation": "Device Batch Allocation",
"bulkPurchase": "Bulk Package Purchase",
"bulkPurchaseDetail": "Bulk Purchase Task Detail",
"orderPackageInvalidateTask": "Order Package Void Tasks",
"orderPackageInvalidateTaskDetail": "Order Package Void Task Detail",
"taskDetail": "Task Details",
@@ -452,6 +455,11 @@
"exportDevice": "Export Devices",
"exportIotCard": "Export IOT Cards",
"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",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"

View File

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

View File

@@ -1,7 +1,7 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
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也可以是字符串比如'用户列表'
*/
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',
@@ -466,6 +441,28 @@ export const asyncRoutes: AppRouteRecord[] = [
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',
@@ -500,6 +497,18 @@ export const asyncRoutes: AppRouteRecord[] = [
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',
@@ -540,6 +549,56 @@ export const asyncRoutes: AppRouteRecord[] = [
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',
name: 'ExportTaskDetail',
@@ -585,17 +644,6 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
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']
}
},
// 企业微信应用配置详情
{
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',

View File

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

View File

@@ -37,6 +37,7 @@ export interface AgentRecharge {
remark?: string // 运营备注
submitter_name?: string | null // 提交人名称
submitter_id?: number | null
approval_source?: 'none' | 'legacy' | 'wecom' | null // 审批来源
approval_provider?: 'wecom' | null
approval_instance_id?: number | 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 ExpiryLevel = string
export type ExpiryLevel = 'red' | 'purple' | 'pink' | string
export type ExpiringAssetType = 'iot_card' | 'device'
export interface ExpiringAssetItem {
asset_type: AssetType
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
shop_name?: string | null
package_name?: string | null
estimated_final_expires_at: string
days_until_final_expiry: number
expiry_level?: ExpiryLevel | null
expiry_level_name?: string | null
can_renew: boolean
is_expiring: boolean
is_priority: boolean
package_id?: number
package_name: string
package_usage_id?: number
shop_id?: number | null
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 {
asset_type?: AssetType
asset_type?: ExpiringAssetType
keyword?: string
shop_id?: number
package_id?: number
@@ -97,7 +120,7 @@ export interface ExpiringAssetQueryParams {
expires_from?: string
expires_to?: string
page?: number
size?: number
page_size?: number
}
// 批量更新资产实名认证策略请求

View File

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

View File

@@ -69,7 +69,7 @@ export interface ExportTaskListResponse {
export interface CreateExportTaskRequest {
scene: ExportTaskScene
format: ExportTaskFormat
query?: Record<string, unknown>
query?: Record<string, string>
}
export interface CreateExportTaskResponse {
@@ -85,15 +85,6 @@ export interface ExportTaskDetail extends ExportTaskItem {
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 CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse>
export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail>
export type CancelExportTaskApiResponse = BaseResponse<CancelExportTaskResponse>

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,37 @@ export interface WecomSceneRequest {
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 {
application_id: number
userid: string
@@ -85,11 +116,13 @@ export type WecomSceneListResponse = BaseResponse<PaginationData<WecomScene>>
export type WecomApplicationResponse = BaseResponse<WecomApplication>
export type WecomSceneResponse = BaseResponse<WecomScene>
export type WecomSyncMembersApiResponse = BaseResponse<WecomSyncMembersResponse>
export type WecomTemplateDetailResponse = BaseResponse<WecomTemplateDetail>
export type WecomBusinessFieldListResponse = BaseResponse<WecomBusinessFieldList>
export interface WecomMemberQueryParams extends PaginationParams {
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 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 }
}
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
}

View File

@@ -213,6 +213,75 @@
</div>
</template>
</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>
</div>
</ArtTableFullScreen>
@@ -237,11 +306,16 @@
import { useAuth } from '@/composables/useAuth'
import { AccountService } from '@/api/modules/account'
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 { PlatformRole } from '@/types/api'
import type { PlatformRole, WecomApplication, WecomMember } from '@/types/api'
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 缓存控制
@@ -252,6 +326,7 @@
const dialogType = ref('add')
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const wecomBindingDialogVisible = ref(false)
const loading = ref(false)
const currentAccountId = ref<number>(0)
const currentAccountName = ref<string>('')
@@ -262,6 +337,21 @@
const roleToAdd = ref<number | undefined>(undefined) // 单选时使用
const leftRoleFilter = 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)
@@ -541,6 +631,14 @@
const getActions = (row: any) => {
const actions: any[] = []
if (hasAuth(JULY_PERMISSIONS.wecom.binding)) {
actions.push({
label: '绑定账号',
handler: () => showWecomBindingDialog(row),
type: 'primary'
})
}
if (hasAuth('account:patch_role')) {
actions.push({
label: '分配角色',
@@ -568,6 +666,75 @@
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>()

View File

@@ -10,7 +10,11 @@
</div>
<div class="card-header-right">
<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"
link
@click="emit('showSpeedLimit')"

View File

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

View File

@@ -1,39 +1,66 @@
<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">
<ElFormItem label="固定档位" prop="code">
<ElSelect v-model="form.code" style="width: 100%">
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
<ElSelect
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>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
<ElButton
type="primary"
@click="handleConfirm"
:loading="confirmLoading"
:disabled="confirmLoading"
>
确认设置
</ElButton>
</template>
</ElDialog>
</template>
<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 type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
confirmLoading?: boolean
}
interface Emits {
(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 formRef = ref<FormInstance>()
const loading = ref(false)
const speedTiers = [
{ code: -1, label: '不限速' },
{ code: 0, label: '0kbps' },
@@ -59,10 +86,25 @@
})
// 监听对话框打开,重置表单
watch(visible, (newVal) => {
if (newVal) {
form.code = 3
}
const setLayoutScrollLock = (locked: boolean) => {
document
.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 = () => {
@@ -70,6 +112,7 @@
}
const handleConfirm = async () => {
if (props.confirmLoading) return
if (!formRef.value) return
try {
@@ -83,4 +126,28 @@
}
</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-mode="showSwitchModeDialog"
@show-set-wifi="showSetWiFiDialog"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-realname-policy="showRealnamePolicyDialog"
@show-speed-limit="speedLimitDialogVisible = true"
@show-update-realname-status="showUpdateRealnameStatusDialog"
@enable-binding-card="handleEnableBindingCard"
@disable-binding-card="handleDisableBindingCard"
@@ -174,7 +174,11 @@
:current-realname-status="bindingCardRealnameStatusValue"
@success="handleBindingCardRealnameStatusSuccess"
/>
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
<SpeedLimitDialog
v-model="speedLimitDialogVisible"
:confirm-loading="speedTierSubmitting"
@confirm="handleSpeedTierConfirm"
/>
</div>
</template>
@@ -183,7 +187,10 @@
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
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 {
formatRemainingTime,
FrontendRateLimitError,
@@ -204,9 +211,9 @@
SwitchModeDialog,
WiFiConfigDialog,
PackageRechargeDialog,
DailyRecordsDialog,
OrderHistoryDialog,
SpeedLimitDialog
DailyRecordsDialog,
OrderHistoryDialog,
SpeedLimitDialog
} from './components/dialogs'
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
@@ -235,6 +242,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
// ========== 组件引用 ==========
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
@@ -294,6 +302,7 @@
// 绑定卡实名状态更新
const bindingCardRealnameStatusDialogVisible = ref(false)
const speedLimitDialogVisible = ref(false)
const speedTierSubmitting = ref(false)
const bindingCardRealnameStatusIccid = ref('')
const bindingCardRealnameStatusValue = ref<number>(0)
@@ -806,12 +815,34 @@
}
}
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
if (!cardInfo.value?.iccid) return
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
if (response.code === 0) {
if (!hasAuth(JULY_PERMISSIONS.speedTier.update)) {
ElMessage.error('暂无设置限速权限')
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}`)
speedLimitDialogVisible.value = false
await handleRefresh()
} catch (error) {
console.error('设置限速失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
speedTierSubmitting.value = false
}
}
</script>

View File

@@ -17,29 +17,6 @@
@refresh="handleRefresh"
>
<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
type="primary"
:disabled="!selectedDevices.length"
@@ -2299,8 +2276,8 @@
return
}
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
const successCount = res.data?.success_count ?? assetIds.length
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
batchRealnamePolicyDialogVisible.value = false
selectedDevices.value = []
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 () => {
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 () => {
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条
const loadPackageSeriesList = async (seriesName?: string) => {
seriesLoading.value = true
@@ -2680,24 +2617,6 @@
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 request: BatchSetDeviceSeriesBindingRequest = {
selection_type: seriesBindingForm.selection_type,

View File

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

View File

@@ -5,7 +5,7 @@
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="true"
label-width="85"
label-width="100px"
@reset="handleReset"
@search="handleSearch"
/>
@@ -17,7 +17,10 @@
@refresh="handleRefresh"
>
<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>
</ArtTableHeader>
@@ -47,11 +50,12 @@
<script setup lang="ts">
import { h, onMounted, reactive, ref, computed } from 'vue'
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 type {
AssetType,
ExpiringAssetItem,
ExpiringAssetSummary,
ExpiringAssetType,
ExpiringAssetQueryParams,
PackageResponse,
ShopResponse
@@ -72,6 +76,12 @@
const shopOptions = ref<ShopResponse[]>([])
const packageOptions = ref<PackageResponse[]>([])
const items = ref<ExpiringAssetItem[]>([])
const summary = reactive<ExpiringAssetSummary>({
card_count: 0,
device_count: 0,
total_count: 0,
window_days: 15
})
const searchForm = reactive<ExpiringAssetQueryParams>({
asset_type: undefined,
keyword: '',
@@ -91,7 +101,7 @@
type: 'select',
config: { clearable: true, placeholder: '全部' },
options: [
{ label: '网卡', value: 'card' },
{ label: '物联网卡', value: 'iot_card' },
{ label: '设备', value: 'device' }
]
},
@@ -137,13 +147,25 @@
label: '最小剩余天数',
prop: 'days_min',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最小天数'
}
},
{
label: '最大剩余天数',
prop: 'days_max',
type: 'input',
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
config: {
clearable: true,
type: 'number',
min: 0,
max: 15,
placeholder: '最大天数'
}
},
{
label: '预计到期起始',
@@ -170,15 +192,15 @@
]
const getExpiryClass = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'expiry-critical'
if (level === 'warning' || level === '4_7') return 'expiry-warning'
if (level === 'notice' || level === '8_15') return 'expiry-notice'
if (level === 'red') return 'expiry-critical'
if (level === 'purple') return 'expiry-warning'
if (level === 'pink') return 'expiry-notice'
return ''
}
const getExpiryTagType = (level?: string | null) => {
if (level === 'critical' || level === '0_3') return 'danger'
if (level === 'warning' || level === '4_7') return 'warning'
if (level === 'red') return 'danger'
if (level === 'purple') return 'warning'
return 'info'
}
@@ -215,7 +237,7 @@
prop: 'asset_type',
label: '资产类型',
width: 100,
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'iot_card' ? '物联网卡' : '设备')
},
{
prop: 'identifier',
@@ -246,7 +268,7 @@
h(
'span',
{ class: getExpiryClass(row.expiry_level) },
formatDateTime(row.estimated_final_expires_at)
formatDateTime(row.estimated_final_expires_at) || '-'
)
},
{
@@ -254,10 +276,8 @@
label: '剩余天数',
width: 100,
formatter: (row: ExpiringAssetItem) =>
h(
ElTag,
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
() => `${row.days_until_final_expiry}`
h(ElTag, { type: getExpiryTagType(row.expiry_level), size: 'small' }, () =>
row.days_until_final_expiry == null ? '-' : `${row.days_until_final_expiry}`
)
},
{
@@ -271,15 +291,43 @@
const loadAssets = async () => {
loading.value = true
try {
const response = await AssetService.getExpiringAssets({
...searchForm,
const toInteger = (value: unknown) => {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
return Number.isInteger(numberValue) ? numberValue : undefined
}
const params: ExpiringAssetQueryParams = {
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) {
items.value = response.data.items || []
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 {
loading.value = false
}
@@ -318,21 +366,20 @@
const goToAsset = (row: ExpiringAssetItem) => {
router.push({
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) => [
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
...(row.can_renew
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
: [])
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) }
]
onMounted(() => {
const assetType = route.query.asset_type
if (assetType === 'card' || assetType === 'device')
searchForm.asset_type = assetType as AssetType
if (assetType === 'card' || assetType === 'iot_card' || assetType === 'device') {
searchForm.asset_type = assetType === 'card' ? 'iot_card' : (assetType as ExpiringAssetType)
}
void Promise.all([loadAssets(), searchShops(), searchPackages()])
})
</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>
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
</div>
</div>
@@ -41,7 +40,7 @@
<script setup lang="ts">
import { computed, h, onMounted } from 'vue'
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 DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
@@ -104,22 +103,11 @@
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[] => [
{
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{ label: '任务ID', prop: 'task_id', formatter: (value: number) => String(value ?? '-') },
{
label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
@@ -269,35 +257,6 @@
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(() => {
loadTaskDetail()
})

View File

@@ -25,7 +25,7 @@
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
:actionsWidth="120"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -41,7 +41,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
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 { useAuth } from '@/composables/useAuth'
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 () => {
loading.value = true
try {
@@ -240,8 +246,8 @@
page_size: pagination.pageSize,
scene: currentScene.value,
status: searchForm.status,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined
start_time: toRfc3339(searchForm.start_time),
end_time: toRfc3339(searchForm.end_time, true)
})
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 actions: any[] = []
const permissions = getRowPermissions(row)
@@ -381,13 +361,6 @@
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
}

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

View File

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

View File

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

View File

@@ -23,20 +23,24 @@
import { onMounted, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { AssetService } from '@/api/modules'
import type { AssetType } from '@/types/api'
import type { AssetType, ExpiringAssetType } from '@/types/api'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'ExpiringAssetStats' })
const router = useRouter()
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) => {
try {
const response = await AssetService.getExpiringAssets({
asset_type: assetType,
asset_type: apiAssetTypes[assetType],
page: 1,
size: 1
page_size: 1
})
if (response.code === 0 && response.data) counts[assetType] = response.data.total || 0
} catch {
@@ -47,7 +51,7 @@
const goToList = (assetType?: AssetType) => {
router.push({
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
): 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 (
row.payment_method === 'offline' &&
@@ -33,6 +38,7 @@ export const buildAgentRechargeActions = (
}
if (
!hasApprovalRecord &&
row.status === AgentRechargeStatus.PENDING &&
row.payment_method === 'offline' &&
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({
label: '拒绝',
handler: () => options.onReject(row),

View File

@@ -68,26 +68,12 @@
2: 'success', // 已支付
3: 'success', // 已完成
4: 'info', // 已关闭
5: 'danger', // 已退款
6: 'danger' // 已驳回
5: 'danger', // 已退款
6: 'danger' // 已驳回
}
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 methodMap: Record<AgentRechargePaymentMethod, string> = {
@@ -97,6 +83,12 @@
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[] => [
{
@@ -104,6 +96,7 @@
fields: [
{ label: '充值单号', prop: 'recharge_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
{
label: '充值金额',
formatter: (_, data) => formatCurrency(data.amount)
@@ -111,9 +104,7 @@
{
label: '状态',
render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () =>
getStatusText(data.status, data.status_name)
)
h(ElTag, { type: getStatusType(data.status) }, () => data.status_name || '-')
},
{
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: '支付信息',
fields: [

View File

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

View File

@@ -162,6 +162,34 @@
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) => {
if (value === undefined || value === null || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -171,7 +199,7 @@
}
const renderStructuredValue = (value: unknown) =>
h('pre', { class: 'structured-value' }, formatStructuredValue(value))
h('pre', { class: 'structured-value' }, formatStructuredValue(sanitizeStructuredValue(value)))
const renderTimeline = (timeline: unknown) => {
if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-')
@@ -181,7 +209,7 @@
{ class: 'approval-timeline' },
timeline.map((item: any) => {
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 comment = item.comment || item.content || ''
return h('div', { class: 'approval-timeline__item' }, [
@@ -207,6 +235,8 @@
{ label: '订单号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '退款状态', prop: 'status_name', formatter: (value) => value || '-' },
{ label: '提交人', prop: 'submitter_name', formatter: (value) => value || '-' },
{
label: '申请退款金额',
formatter: (_, data) => formatCurrency(data.requested_refund_amount)
@@ -225,6 +255,12 @@
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '拒绝原因',
prop: 'reject_reason',
formatter: (value) => value || '-',
fullWidth: true
},
{
label: '备注',
prop: 'remark',
@@ -258,17 +294,30 @@
formatter: (_, data) => (data.commission_deducted ? '是' : '否')
},
{ 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) }
]
},
{
title: '企微审批信息',
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: '审批状态',
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' },
{

View File

@@ -21,6 +21,9 @@
<ElButton type="primary" @click="showCreateDialog" v-if="hasAuth('refund:create')"
>创建退款申请</ElButton
>
<ElButton v-if="hasAuth('refund:export')" @click="exportDialogVisible = true">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -48,6 +51,15 @@
<!-- 创建退款申请对话框 -->
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
<!-- 导出任务对话框 -->
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="refund"
:query="exportQuery"
confirm-permission="refund:export"
title="导出退款"
/>
<!-- 退款凭证预览 -->
<PaymentVoucherDialog
:file-keys="refundVoucherFileKeys"
@@ -147,7 +159,6 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import {
getApprovalStatusText,
getCurrentApproverSummaryText,
getProcessingStatusText
} from '@/utils/business/approvalSummary'
@@ -155,6 +166,7 @@
import { RoutesAlias } from '@/router/routesAlias'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import { useAuth } from '@/composables/useAuth'
@@ -170,6 +182,7 @@
const tableRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDialogVisible = ref(false)
const exportDialogVisible = ref(false)
const resubmitDialogVisible = ref(false)
const currentRefund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([])
@@ -269,6 +282,7 @@
{ label: '实际退款金额', prop: 'approved_refund_amount' },
{ label: '实收金额', prop: 'actual_received_amount' },
{ label: '状态', prop: 'status' },
{ label: '审批渠道', prop: 'approval_provider' },
{ label: '提交人', prop: 'submitter_name' },
{ label: '审批状态', prop: 'approval_status' },
{ label: '当前审批人摘要', prop: 'current_approver_summary' },
@@ -279,7 +293,8 @@
{ label: '资产重置', prop: 'asset_reset' },
{ label: '佣金扣除', prop: 'commission_deducted' },
{ label: '创建时间', prop: 'created_at' },
{ label: '审批时间', prop: 'processed_at' }
{ label: '审批时间', prop: 'processed_at' },
{ label: '更新时间', prop: 'updated_at' }
]
const resubmitFormRef = ref<FormInstance>()
@@ -320,17 +335,6 @@
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(() => [
{
@@ -404,7 +408,17 @@
label: '状态',
width: 100,
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',
label: '审批状态',
width: 120,
formatter: (row: Refund) => getApprovalStatusText(row)
formatter: (row: Refund) => row.approval_status_name || '-'
},
{
prop: 'current_approver_summary',
@@ -483,6 +497,12 @@
label: '审批时间',
width: 180,
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 = () => {
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>
</ElFormItem>
<ElFormItem label="生效条件">
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
@@ -352,7 +352,8 @@
PackageResponse
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { getEnableStatusText } from '@/config/constants'
import { getEnableStatusText, JULY_PERMISSIONS } from '@/config/constants'
import { useAuth } from '@/composables/useAuth'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
@@ -362,6 +363,8 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false)
const submitLoading = ref(false)
@@ -378,11 +381,11 @@
// 套餐表单
const packageForm = ref<{
package_id?: number
allocation_id?: number
package_name?: string
package_code?: string
original_cost_price?: number
cost_price_yuan: number
allocation_id?: number
expiry_base_override: ExpiryBaseSelection
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
@@ -509,6 +512,7 @@
packageDialogType.value = 'add'
packageForm.value = {
package_id: undefined,
allocation_id: undefined,
package_name: undefined,
package_code: undefined,
original_cost_price: undefined,
@@ -530,11 +534,11 @@
packageDialogType.value = 'edit'
packageForm.value = {
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
original_cost_price: row.cost_price / 100,
cost_price_yuan: row.cost_price / 100,
allocation_id: row.allocation_id,
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,
@@ -619,8 +623,12 @@
const expiryBaseChanged =
packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override
if (expiryBaseChanged && !packageForm.value.allocation_id) {
ElMessage.error('该套餐分配记录缺少ID无法更新生效条件')
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}

View File

@@ -29,12 +29,22 @@
:marginTop="10"
:pagination="false"
:actions="getActions"
:actionsWidth="150"
:inlineActionsCount="2"
:actionsWidth="240"
:inlineActionsCount="3"
>
<template #default>
<ElTableColumn prop="package_name" label="套餐名称" minWidth="150" />
<ElTableColumn prop="package_code" label="套餐编码" minWidth="120" />
<ElTableColumn
prop="package_name"
label="套餐名称"
minWidth="220"
show-overflow-tooltip
/>
<ElTableColumn
prop="package_code"
label="套餐编码"
minWidth="180"
show-overflow-tooltip
/>
<ElTableColumn label="成本价" width="120">
<template #default="{ row }">
<span class="amount-value">¥{{ (row.cost_price / 100).toFixed(2) }}</span>
@@ -81,16 +91,18 @@
@closed="handlePackageDialogClosed"
>
<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
v-model="packageForm.package_id"
placeholder="请选择套餐"
v-model="packageForm.package_ids"
placeholder="请选择套餐(可多选)"
style="width: 100%"
filterable
remote
:remote-method="searchAvailablePackages"
:loading="packageLoading"
clearable
multiple
:multiple-limit="100"
>
<template v-if="availablePackages.length === 0 && !packageLoading && seriesId">
<ElOption disabled value="" label="该系列没有可选套餐" />
@@ -105,6 +117,33 @@
</ElSelect>
</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'">
<span>{{ packageForm.package_name }}</span>
</ElFormItem>
@@ -127,7 +166,7 @@
}}
</div>
</ElFormItem>
<ElFormItem label="生效条件">
<ElFormItem v-if="packageDialogType === 'add' || canUpdateExpiryBase" label="生效条件">
<ElSelect
v-model="packageForm.expiry_base_override"
placeholder="请选择生效条件"
@@ -161,6 +200,49 @@
</div>
</template>
</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>
</ArtTableFullScreen>
</template>
@@ -196,6 +278,7 @@
PackageAllocationExpiryBaseOverride
} from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { JULY_PERMISSIONS } from '@/config/constants'
import {
mergeGrantPackageCandidates,
type GrantPackageCandidate
@@ -206,6 +289,7 @@
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const canUpdateExpiryBase = hasAuth(JULY_PERMISSIONS.seriesGrants.updateExpiryBase)
const loading = ref(false)
const submitLoading = ref(false)
@@ -221,33 +305,61 @@
const packageDialogVisible = ref(false)
const packageDialogType = ref<'add' | 'edit'>('add')
const expiryDialogVisible = ref(false)
const expirySubmitLoading = ref(false)
type ExpiryBaseSelection = PackageAllocationExpiryBaseOverride | 'default'
type PackageFormState = {
package_id?: number
type PackageCostFormItem = {
package_id: number
package_name?: string
package_code?: string
cost_price_yuan: number
original_cost_price?: number
suggested_retail_price?: number
}
type PackageFormState = {
package_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
initial_expiry_base_override: ExpiryBaseSelection
default_expiry_base_name?: string | null
expiry_base_override_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 => ({
package_id: undefined,
allocation_id: undefined,
package_ids: [],
package_name: undefined,
package_code: undefined,
cost_price_yuan: 0,
expiry_base_override: 'default',
initial_expiry_base_override: 'default'
initial_expiry_base_override: 'default',
packages: []
})
const packageForm = ref<PackageFormState>(createDefaultPackageForm())
const expiryForm = ref<ExpiryFormState>({ expiry_base_override: 'default' })
const assignPackageForm = (values: Partial<PackageFormState> = {}) => {
Object.assign(packageForm.value, createDefaultPackageForm(), values)
@@ -271,11 +383,30 @@
}
const packageRules = computed<FormRules>(() => ({
package_id: [
{ required: packageDialogType.value === 'add', message: '请选择套餐', trigger: 'change' }
package_ids: [
{
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: [
{ required: true, message: '请输入成本价', trigger: 'blur' },
{
required: packageDialogType.value === 'edit',
message: '请输入成本价',
trigger: 'blur'
},
{
validator: (rule, value, callback) => {
if (value === undefined || value === null || value === '') {
@@ -294,6 +425,14 @@
const getActions = (row: GrantPackageInfo) => {
const actions: any[] = []
if (canUpdateExpiryBase) {
actions.push({
label: '修改生效条件',
handler: () => showExpiryBaseDialog(row),
type: 'primary'
})
}
if (hasAuth('series_grants:edit_packages_list')) {
actions.push({
label: '编辑',
@@ -338,7 +477,7 @@
const showAddPackageDialog = () => {
packageDialogType.value = 'add'
assignPackageForm()
assignPackageForm({ package_ids: [], packages: [] })
loadAvailablePackages()
packageDialogVisible.value = true
nextTick(() => packageFormRef.value?.clearValidate())
@@ -350,12 +489,12 @@
assignPackageForm({
package_id: row.package_id,
allocation_id: row.allocation_id,
package_name: row.package_name,
package_code: row.package_code,
cost_price_yuan: row.cost_price / 100,
original_cost_price: packagePricing.original_cost_price,
suggested_retail_price: packagePricing.suggested_retail_price,
allocation_id: row.allocation_id,
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,
@@ -366,25 +505,43 @@
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) => {
if (!seriesId.value) return
packageLoading.value = true
try {
const pageSize = 100
const allPackages: any[] = []
let page = 1
let total = 0
do {
const params: any = { page, page_size: pageSize, series_id: seriesId.value }
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
const pageSize = 100
const allPackages: any[] = []
let page = 1
let total = 0
do {
const params: any = { page, page_size: pageSize, series_id: seriesId.value }
if (packageName) params.package_name = packageName
const res = await PackageManageService.getPackages(params)
if (res.code !== 0) break
allPackages.push(...(res.data.items || []))
total = res.data.total || allPackages.length
page += 1
} while (allPackages.length < total)
availablePackages.value = mergeGrantPackageCandidates(allPackages, packageList.value)
} catch (error) {
console.error('加载套餐选项失败:', error)
} 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(
() => packageForm.value.package_id,
(packageId) => {
@@ -436,19 +637,51 @@
}
const costPrice = Math.round(currentCostPriceYuan * 100)
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, {
expiry_base_override:
packageForm.value.expiry_base_override === 'default'
? null
: packageForm.value.expiry_base_override,
packages: [{ package_id: packageForm.value.package_id!, cost_price: costPrice }]
packages
})
} else {
const expiryBaseChanged =
packageForm.value.expiry_base_override !==
packageForm.value.initial_expiry_base_override
if (expiryBaseChanged && !packageForm.value.allocation_id) {
ElMessage.error('该套餐分配记录缺少ID无法更新生效条件')
if (
expiryBaseChanged &&
(packageForm.value.allocation_id === undefined ||
packageForm.value.allocation_id === null)
) {
ElMessage.error('该套餐分配记录缺少套餐分配 ID无法修改生效条件')
return
}
@@ -477,13 +710,39 @@
packageDialogVisible.value = false
await fetchPackageList()
} catch (error) {
console.error(error)
ElMessage.error(error instanceof Error ? error.message : '保存套餐失败')
} finally {
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) => {
import('element-plus').then(({ ElMessageBox }) => {
ElMessageBox.confirm(`确定删除套餐 ${row.package_name} 的授权吗?`, '删除确认', {
@@ -572,6 +831,38 @@
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 {
text-align: right;
}

View File

@@ -44,16 +44,13 @@
:close-on-click-modal="false"
>
<ElForm label-width="120px">
<ElFormItem label="配置 Key">
<ElInput :model-value="currentConfig?.config_key || '-'" disabled />
</ElFormItem>
<ElFormItem label="配置说明">
<span>{{ currentConfig?.description || '-' }}</span>
</ElFormItem>
<ElFormItem label="配置值">
<ElCheckboxGroup v-if="isPaymentConfig" v-model="editPaymentMethods">
<ElCheckbox v-for="method in paymentMethods" :key="method" :label="method">
{{ method }}
{{ paymentMethodLabels[method] }}
</ElCheckbox>
</ElCheckboxGroup>
<ElSwitch v-else-if="isBooleanConfig" v-model="editBoolean" />
@@ -89,7 +86,6 @@
placeholder="请输入配置值"
/>
</ElFormItem>
<div v-if="valueHint" class="config-hint">{{ valueHint }}</div>
</ElForm>
<template #footer>
@@ -103,6 +99,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElTag } from 'element-plus'
import { SystemConfigService } from '@/api/modules'
import type { SearchFormItem } from '@/types'
@@ -120,6 +117,7 @@
defineOptions({ name: 'SystemConfigs' })
const { hasAuth } = useAuth()
const route = useRoute()
const moduleOptions = [
{ label: '运营商回调配置', value: 'carrier_callback' },
@@ -149,14 +147,18 @@
const editNumber = ref<number | null>(null)
const editPaymentMethods = ref<AllowedPaymentMethod[]>([])
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 columnOptions = [
{ label: '配置 Key', prop: 'config_key' },
{ label: '模块', prop: 'module' },
{ label: '配置说明', prop: 'description' },
{ label: '配置值', prop: 'value' },
{ label: '控件', prop: 'control' },
{ label: '注册状态', prop: 'registered' },
{ label: '只读', prop: 'readonly' },
{ label: '敏感', prop: 'sensitive' },
@@ -167,7 +169,6 @@
moduleOptions.find((item) => item.value === module)?.label || module
const { columnChecks, columns } = useCheckedColumns(() => [
{ prop: 'config_key', label: '配置 Key', minWidth: 220, showOverflowTooltip: true },
{
prop: 'module',
label: '模块',
@@ -178,10 +179,10 @@
{
prop: 'value',
label: '配置值',
minWidth: 150,
formatter: (row: SystemConfigItem) => row.value || '-'
minWidth: 260,
showOverflowTooltip: true,
formatter: (row: SystemConfigItem) => formatConfigValue(row)
},
{ prop: 'control', label: '控件', width: 100 },
{
prop: 'registered',
label: '注册状态',
@@ -216,7 +217,8 @@
)
const isPaymentConfig = computed(() =>
[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(
@@ -233,14 +235,18 @@
!isBooleanConfig.value && !isIntegerConfig.value && currentConfig.value?.value_type === 'json'
)
const valueHint = computed(() => {
if (!currentConfig.value) return ''
const { min, max } = currentConfig.value
if (min !== null && max !== null) return `取值范围:${min}${max}`
if (min !== null) return `最小值:${min}`
if (max !== null) return `最大值:${max}`
return ''
})
const isPaymentConfigKey = (configKey: string) =>
Object.values(PAYMENT_CONFIG_KEYS).includes(
configKey as (typeof PAYMENT_CONFIG_KEYS)[keyof typeof PAYMENT_CONFIG_KEYS]
)
const formatConfigValue = (config: SystemConfigItem) => {
if (isPaymentConfigKey(config.config_key)) {
const methods = parsePaymentMethods(config.value)
return methods.length ? methods.map((method) => paymentMethodLabels[method]).join('、') : '-'
}
return config.value || '-'
}
const getSubmittedValue = () => {
if (isPaymentConfig.value) return JSON.stringify(editPaymentMethods.value)
@@ -293,6 +299,12 @@
if (res.code === 0) {
configList.value = res.data.list || []
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) {
console.error('获取系统配置失败:', error)
@@ -370,10 +382,5 @@
<style scoped lang="scss">
.system-configs-page {
.config-hint {
margin: -12px 0 0 120px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</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>
<div class="wecom-applications-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微应用管理</div>
<div class="page-description">管理企业微信应用凭据并查看连接和默认发起人状态</div>
</div>
<ElButton
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">
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-applications-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadApplications"
>
<template #left>
<ElButton
v-permission="JULY_PERMISSIONS.wecom.application"
link
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>
</template>
</ElTableColumn>
</ElTable>
</ArtTableHeader>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="applications"
:current-page="pagination.page"
:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
:margin-top="10"
:actions="getActions"
:actions-width="160"
:inline-actions-count="1"
@size-change="handleSizeChange"
@current-change="loadApplications"
/>
</div>
</ElCard>
</div>
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
<WecomApplicationFormDialog
v-model="applicationDialogVisible"
:application="editingApplication"
@success="handleApplicationSaved"
/>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useRouter } from 'vue-router'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import type { WecomApplication } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import WecomApplicationFormDialog from './components/WecomApplicationFormDialog.vue'
defineOptions({ name: 'WecomApplications' })
const router = useRouter()
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
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 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 () => {
loading.value = true
try {
@@ -124,8 +145,20 @@
void loadApplications()
}
const goToCreate = () => void router.push(`${RoutesAlias.WecomApplications}/create`)
const goToDetail = (id: number) => void router.push(`${RoutesAlias.WecomApplicationDetail}/${id}`)
const openCreateDialog = () => {
editingApplication.value = null
applicationDialogVisible.value = true
}
const openEditDialog = (application: WecomApplication) => {
editingApplication.value = application
applicationDialogVisible.value = true
}
const handleApplicationSaved = () => {
void loadApplications()
}
const goToMembers = (id: number) =>
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())
</script>
<style scoped lang="scss">
.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>
<style scoped lang="scss"></style>

View File

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

View File

@@ -1,158 +1,240 @@
<template>
<div class="wecom-scenes-page">
<ElCard shadow="never">
<template #header>
<div class="page-header">
<div>
<div class="page-title">企微审批场景</div>
<div class="page-description"
>分别维护退款和线下代充值审批模板并提交后由后端校验控件映射</div
<ArtTableFullScreen>
<div id="table-full-screen" class="wecom-scenes-page">
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:column-list="columnOptions"
v-model:columns="columnChecks"
@refresh="loadData"
>
<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>
<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>
</div>
</template>
<div class="scene-layout">
<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>
</ElDialog>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { WecomService } from '@/api/modules'
import { JULY_PERMISSIONS } from '@/config/constants'
import type {
WecomApplication,
WecomBusinessField,
WecomBusinessType,
WecomScene,
WecomSceneControlMapping
WecomSceneControlMapping,
WecomTemplateControl
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'WecomScenes' })
interface MappingEditor {
key: number
business_field: string
control_title: string
control_id: string
control_type: string
required: boolean
option_mapping: string
}
const { hasAuth } = useAuth()
const tableRef = ref()
const loading = ref(false)
const saving = ref(false)
const fieldsLoading = ref(false)
const inspectingTemplate = ref(false)
const editorVisible = ref(false)
const applications = ref<WecomApplication[]>([])
const businessFields = ref<WecomBusinessField[]>([])
const templateControls = ref<WecomTemplateControl[]>([])
const templateName = ref('')
const scenes = ref<WecomScene[]>([])
const selectedBusinessType = ref<WecomBusinessType>()
const nextMappingKey = ref(1)
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const sceneForm = reactive({
business_type: 'refund_approval' as WecomBusinessType,
application_id: undefined as number | undefined,
@@ -162,19 +244,69 @@
const mappingRows = ref<MappingEditor[]>([])
const editing = computed(() => Boolean(selectedBusinessType.value))
const addMapping = (mapping?: Partial<MappingEditor>) => {
mappingRows.value.push({
key: nextMappingKey.value++,
business_field: mapping?.business_field || '',
control_id: mapping?.control_id || '',
control_type: mapping?.control_type || '',
option_mapping: mapping?.option_mapping || '{}'
})
const columnOptions = [
{ label: '业务类型', prop: 'business_type_name' },
{ label: '模板名称', prop: 'template_name' },
{ label: '状态', prop: 'status' },
{ label: '最近校验', prop: 'last_verified_at' }
]
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) => {
templateControls.value = []
templateName.value = ''
if (!scene) {
selectedBusinessType.value = undefined
Object.assign(sceneForm, {
@@ -184,7 +316,6 @@
enabled: true
})
mappingRows.value = []
addMapping()
return
}
selectedBusinessType.value = scene.business_type
@@ -197,41 +328,124 @@
mappingRows.value = (scene.control_mapping || []).map((mapping) => ({
key: nextMappingKey.value++,
business_field: mapping.business_field,
control_title: '',
control_id: mapping.control_id,
control_type: mapping.control_type,
required: true,
option_mapping: JSON.stringify(mapping.option_mapping || {})
}))
}
const selectScene = (scene: WecomScene) => fillEditor(scene)
const createScene = () => fillEditor()
const createScene = () => {
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 () => {
loading.value = true
try {
const [applicationResponse, sceneResponse] = await Promise.all([
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 (sceneResponse.code === 0) {
scenes.value = sceneResponse.data.items || []
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
fillEditor(current || scenes.value[0])
pagination.total = sceneResponse.data.total || 0
if (selectedBusinessType.value) {
const current = scenes.value.find(
(scene) => scene.business_type === selectedBusinessType.value
)
if (current) fillEditor(current)
}
}
} finally {
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 result: WecomSceneControlMapping[] = []
for (const mapping of mappingRows.value) {
if (!mapping.business_field && !mapping.control_id && !mapping.control_type) continue
if (!mapping.business_field || !mapping.control_id || !mapping.control_type) {
ElMessage.warning('请完整填写控件映射字段')
if (!mapping.business_field) {
if (mapping.required) {
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
}
let optionMapping: Record<string, string>
@@ -271,6 +485,7 @@
})
if (response.code === 0) {
ElMessage.success('场景保存并校验成功')
editorVisible.value = false
await loadData()
}
} 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())
</script>
<style scoped lang="scss">
.wecom-scenes-page {
.page-header,
.editor-header,
.mapping-heading,
.editor-actions {
display: flex;
align-items: center;
.full-width {
width: 100%;
}
.scene-editor-form {
padding-bottom: 12px;
.dialog-section {
padding: 18px 20px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-blank);
}
.page-header,
.editor-header,
.mapping-heading {
.dialog-section + .dialog-section {
margin-top: 18px;
}
.dialog-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.page-title {
.dialog-section__title {
color: var(--el-text-color-primary);
font-size: 16px;
font-size: 15px;
font-weight: 600;
}
.page-description {
margin-top: 6px;
.dialog-section__description {
margin-top: 4px;
color: var(--el-text-color-secondary);
font-size: 13px;
font-size: 12px;
}
.scene-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(480px, 1fr);
gap: 16px;
.mapping-tip {
flex-shrink: 0;
margin-top: 0;
white-space: nowrap;
}
.section-heading {
margin-bottom: 12px;
color: var(--el-text-color-primary);
font-weight: 600;
.template-name {
margin-top: 6px;
overflow: hidden;
color: var(--el-text-color-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-card {
min-width: 0;
}
.full-width {
width: 100%;
.mapping-section {
padding-bottom: 12px;
}
.mapping-heading {
margin-bottom: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
margin-bottom: 0;
}
.mapping-list {
display: flex;
max-height: 420px;
flex-direction: column;
gap: 10px;
gap: 12px;
overflow-y: auto;
padding: 0 4px 4px;
}
.mapping-row {
display: grid;
grid-template-columns: 1.05fr 1fr 0.8fr 1.3fr auto;
gap: 8px;
align-items: center;
padding: 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
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;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 1200px) {
.scene-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-header {
align-items: flex-start;
flex-direction: column;
.dialog-section {
padding: 14px;
}
.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;
}
.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>