新增: 轮询管理
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m17s

This commit is contained in:
sexygoat
2026-03-30 14:59:56 +08:00
parent dbe6070207
commit a05717c2ed
28 changed files with 6865 additions and 103 deletions

77
src/api/modules/alert.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* 轮询告警管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PollingAlertRule,
PollingAlertHistory,
CreatePollingAlertRuleRequest,
UpdatePollingAlertRuleRequest,
PollingAlertHistoryQueryParams
} from '@/types/api/alert'
export class PollingAlertService extends BaseService {
/**
* 获取告警规则列表
*/
static getPollingAlertRules(): Promise<BaseResponse<{ items: PollingAlertRule[] }>> {
return this.get<BaseResponse<{ items: PollingAlertRule[] }>>('/api/admin/polling-alert-rules')
}
/**
* 获取告警规则详情
*/
static getPollingAlertRuleById(id: number): Promise<BaseResponse<PollingAlertRule>> {
return this.get<BaseResponse<PollingAlertRule>>(`/api/admin/polling-alert-rules/${id}`)
}
/**
* 创建告警规则
*/
static createPollingAlertRule(
data: CreatePollingAlertRuleRequest
): Promise<BaseResponse<PollingAlertRule>> {
return this.post<BaseResponse<PollingAlertRule>>('/api/admin/polling-alert-rules', data)
}
/**
* 更新告警规则
*/
static updatePollingAlertRule(
id: number,
data: UpdatePollingAlertRuleRequest
): Promise<BaseResponse<null>> {
return this.put<BaseResponse<null>>(`/api/admin/polling-alert-rules/${id}`, data)
}
/**
* 删除告警规则
*/
static deletePollingAlertRule(id: number): Promise<BaseResponse<null>> {
return this.delete<BaseResponse<null>>(`/api/admin/polling-alert-rules/${id}`)
}
/**
* 获取告警历史列表
*/
static getPollingAlertHistory(params?: PollingAlertHistoryQueryParams): Promise<
BaseResponse<{
items: PollingAlertHistory[]
page: number
page_size: number
total: number
total_pages: number
}>
> {
return this.get<
BaseResponse<{
items: PollingAlertHistory[]
page: number
page_size: number
total: number
total_pages: number
}>
>('/api/admin/polling-alert-history', params)
}
}

View File

@@ -0,0 +1,103 @@
/**
* 数据清理管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
DataCleanupConfig,
CreateDataCleanupConfigRequest,
UpdateDataCleanupConfigRequest,
DataCleanupLog,
DataCleanupLogQueryParams,
DataCleanupPreviewItem,
DataCleanupProgress,
TriggerDataCleanupRequest
} from '@/types/api/dataCleanup'
export class DataCleanupService extends BaseService {
/**
* 获取数据清理配置列表
*/
static getDataCleanupConfigs(): Promise<BaseResponse<{ items: DataCleanupConfig[] }>> {
return this.get<BaseResponse<{ items: DataCleanupConfig[] }>>('/api/admin/data-cleanup-configs')
}
/**
* 获取数据清理配置详情
*/
static getDataCleanupConfigById(id: number): Promise<BaseResponse<DataCleanupConfig>> {
return this.get<BaseResponse<DataCleanupConfig>>(`/api/admin/data-cleanup-configs/${id}`)
}
/**
* 创建数据清理配置
*/
static createDataCleanupConfig(
data: CreateDataCleanupConfigRequest
): Promise<BaseResponse<DataCleanupConfig>> {
return this.post<BaseResponse<DataCleanupConfig>>('/api/admin/data-cleanup-configs', data)
}
/**
* 更新数据清理配置
*/
static updateDataCleanupConfig(
id: number,
data: UpdateDataCleanupConfigRequest
): Promise<BaseResponse<null>> {
return this.put<BaseResponse<null>>(`/api/admin/data-cleanup-configs/${id}`, data)
}
/**
* 删除数据清理配置
*/
static deleteDataCleanupConfig(id: number): Promise<BaseResponse<null>> {
return this.delete<BaseResponse<null>>(`/api/admin/data-cleanup-configs/${id}`)
}
/**
* 获取数据清理日志列表
*/
static getDataCleanupLogs(params?: DataCleanupLogQueryParams): Promise<
BaseResponse<{
items: DataCleanupLog[]
page: number
page_size: number
total: number
total_pages: number
}>
> {
return this.get<
BaseResponse<{
items: DataCleanupLog[]
page: number
page_size: number
total: number
total_pages: number
}>
>('/api/admin/data-cleanup-logs', params)
}
/**
* 预览待清理数据
*/
static previewDataCleanup(): Promise<BaseResponse<{ items: DataCleanupPreviewItem[] }>> {
return this.get<BaseResponse<{ items: DataCleanupPreviewItem[] }>>(
'/api/admin/data-cleanup/preview'
)
}
/**
* 获取数据清理进度
*/
static getDataCleanupProgress(): Promise<BaseResponse<DataCleanupProgress>> {
return this.get<BaseResponse<DataCleanupProgress>>('/api/admin/data-cleanup/progress')
}
/**
* 手动触发数据清理
*/
static triggerDataCleanup(data?: TriggerDataCleanupRequest): Promise<BaseResponse<null>> {
return this.post<BaseResponse<null>>('/api/admin/data-cleanup/trigger', data || {})
}
}

View File

@@ -29,6 +29,13 @@ export { AssetService } from './asset'
export { AgentRechargeService } from './agentRecharge'
export { WechatConfigService } from './wechatConfig'
export { ExchangeService } from './exchange'
export { RefundService } from './refund'
export { DataCleanupService } from './dataCleanup'
export { PollingAlertService } from './alert'
export { PollingConcurrencyService } from './pollingConcurrency'
export { PollingConfigService } from './pollingConfig'
export { ManualTriggerService } from './manualTrigger'
export { PollingMonitorService } from './pollingMonitor'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -0,0 +1,88 @@
/**
* 手动触发相关 API
*/
import { BaseService } from '../BaseService'
import type {
ManualTriggerLog,
BatchManualTriggerRequest,
ConditionManualTriggerRequest,
SingleManualTriggerRequest,
CancelManualTriggerRequest,
ManualTriggerHistoryQueryParams,
ManualTriggerHistoryResponse,
ManualTriggerStatusResponse,
BaseResponse
} from '@/types/api'
export class ManualTriggerService extends BaseService {
/**
* 批量手动触发
* POST /api/admin/polling-manual-trigger/batch
*/
static batchTrigger(
data: BatchManualTriggerRequest
): Promise<BaseResponse<ManualTriggerLog>> {
return this.post<BaseResponse<ManualTriggerLog>>(
'/api/admin/polling-manual-trigger/batch',
data
)
}
/**
* 条件筛选触发
* POST /api/admin/polling-manual-trigger/by-condition
*/
static conditionTrigger(
data: ConditionManualTriggerRequest
): Promise<BaseResponse<ManualTriggerLog>> {
return this.post<BaseResponse<ManualTriggerLog>>(
'/api/admin/polling-manual-trigger/by-condition',
data
)
}
/**
* 单卡手动触发
* POST /api/admin/polling-manual-trigger/single
*/
static singleTrigger(
data: SingleManualTriggerRequest
): Promise<BaseResponse<ManualTriggerLog>> {
return this.post<BaseResponse<ManualTriggerLog>>(
'/api/admin/polling-manual-trigger/single',
data
)
}
/**
* 取消手动触发任务
* POST /api/admin/polling-manual-trigger/cancel
*/
static cancelTrigger(data: CancelManualTriggerRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>('/api/admin/polling-manual-trigger/cancel', data)
}
/**
* 获取手动触发历史
* GET /api/admin/polling-manual-trigger/history
*/
static getHistory(
params?: ManualTriggerHistoryQueryParams
): Promise<BaseResponse<ManualTriggerHistoryResponse>> {
return this.get<BaseResponse<ManualTriggerHistoryResponse>>(
'/api/admin/polling-manual-trigger/history',
params
)
}
/**
* 获取手动触发状态
* GET /api/admin/polling-manual-trigger/status
*/
static getStatus(): Promise<BaseResponse<ManualTriggerStatusResponse>> {
return this.get<BaseResponse<ManualTriggerStatusResponse>>(
'/api/admin/polling-manual-trigger/status'
)
}
}

View File

@@ -0,0 +1,50 @@
/**
* 轮询并发控制 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PollingConcurrency,
PollingConcurrencyListResponse,
UpdateConcurrencyParams,
ResetConcurrencyParams
} from '@/types/api/pollingConcurrency'
/**
* 轮询并发控制服务
*/
export class PollingConcurrencyService extends BaseService {
/**
* 获取轮询并发配置列表
* @returns 并发配置列表
*/
static getConcurrencyList(): Promise<BaseResponse<PollingConcurrencyListResponse>> {
return this.get<BaseResponse<PollingConcurrencyListResponse>>('/api/admin/polling-concurrency')
}
/**
* 获取指定任务类型的并发配置
* @param taskType 任务类型
* @returns 并发配置
*/
static getConcurrencyByType(taskType: string): Promise<BaseResponse<PollingConcurrency>> {
return this.get<BaseResponse<PollingConcurrency>>(`/api/admin/polling-concurrency/${taskType}`)
}
/**
* 更新轮询并发配置
* @param taskType 任务类型
* @param params 更新参数
*/
static updateConcurrency(taskType: string, params: UpdateConcurrencyParams): Promise<BaseResponse<void>> {
return this.put<BaseResponse<void>>(`/api/admin/polling-concurrency/${taskType}`, params)
}
/**
* 重置轮询并发计数
* @param params 重置参数
*/
static resetConcurrency(params?: ResetConcurrencyParams): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>('/api/admin/polling-concurrency/reset', params || {})
}
}

View File

@@ -0,0 +1,90 @@
/**
* 轮询配置管理 API
*/
import { BaseService } from '../BaseService'
import type { BaseResponse } from '@/types/api'
import type {
PollingConfig,
PollingConfigQueryParams,
PollingConfigListResponse,
CreatePollingConfigRequest,
UpdatePollingConfigRequest,
UpdatePollingConfigStatusRequest
} from '@/types/api/pollingConfig'
/**
* 轮询配置管理服务
*/
export class PollingConfigService extends BaseService {
/**
* 获取轮询配置列表
* @param params 查询参数
* @returns 配置列表
*/
static getPollingConfigs(
params?: PollingConfigQueryParams
): Promise<BaseResponse<PollingConfigListResponse>> {
return this.get<BaseResponse<PollingConfigListResponse>>('/api/admin/polling-configs', params)
}
/**
* 获取轮询配置详情
* @param id 配置ID
* @returns 配置详情
*/
static getPollingConfigById(id: number): Promise<BaseResponse<PollingConfig>> {
return this.get<BaseResponse<PollingConfig>>(`/api/admin/polling-configs/${id}`)
}
/**
* 创建轮询配置
* @param data 创建参数
* @returns 创建的配置
*/
static createPollingConfig(
data: CreatePollingConfigRequest
): Promise<BaseResponse<PollingConfig>> {
return this.post<BaseResponse<PollingConfig>>('/api/admin/polling-configs', data)
}
/**
* 更新轮询配置
* @param id 配置ID
* @param data 更新参数
* @returns 更新后的配置
*/
static updatePollingConfig(
id: number,
data: UpdatePollingConfigRequest
): Promise<BaseResponse<PollingConfig>> {
return this.put<BaseResponse<PollingConfig>>(`/api/admin/polling-configs/${id}`, data)
}
/**
* 删除轮询配置
* @param id 配置ID
*/
static deletePollingConfig(id: number): Promise<BaseResponse<void>> {
return this.delete<BaseResponse<void>>(`/api/admin/polling-configs/${id}`)
}
/**
* 更新轮询配置状态
* @param id 配置ID
* @param data 状态参数
*/
static updatePollingConfigStatus(
id: number,
data: UpdatePollingConfigStatusRequest
): Promise<BaseResponse<void>> {
return this.put<BaseResponse<void>>(`/api/admin/polling-configs/${id}/status`, data)
}
/**
* 获取所有启用的配置
* @returns 启用的配置列表
*/
static getEnabledPollingConfigs(): Promise<BaseResponse<PollingConfig[]>> {
return this.get<BaseResponse<PollingConfig[]>>('/api/admin/polling-configs/enabled')
}
}

View File

@@ -0,0 +1,46 @@
/**
* 轮询监控相关 API
*/
import { BaseService } from '../BaseService'
import type {
PollingOverviewStats,
PollingInitProgress,
PollingQueueStatusResponse,
PollingTaskStatsResponse,
BaseResponse
} from '@/types/api'
export class PollingMonitorService extends BaseService {
/**
* 获取轮询总览统计
* GET /api/admin/polling-stats
*/
static getOverviewStats(): Promise<BaseResponse<PollingOverviewStats>> {
return this.get<BaseResponse<PollingOverviewStats>>('/api/admin/polling-stats')
}
/**
* 获取轮询初始化进度
* GET /api/admin/polling-stats/init-progress
*/
static getInitProgress(): Promise<BaseResponse<PollingInitProgress>> {
return this.get<BaseResponse<PollingInitProgress>>('/api/admin/polling-stats/init-progress')
}
/**
* 获取轮询队列状态
* GET /api/admin/polling-stats/queues
*/
static getQueueStatus(): Promise<BaseResponse<PollingQueueStatusResponse>> {
return this.get<BaseResponse<PollingQueueStatusResponse>>('/api/admin/polling-stats/queues')
}
/**
* 获取轮询任务统计
* GET /api/admin/polling-stats/tasks
*/
static getTaskStats(): Promise<BaseResponse<PollingTaskStatsResponse>> {
return this.get<BaseResponse<PollingTaskStatsResponse>>('/api/admin/polling-stats/tasks')
}
}

78
src/api/modules/refund.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* 退款管理相关 API
*/
import { BaseService } from '../BaseService'
import type {
Refund,
RefundQueryParams,
RefundListResponse,
CreateRefundRequest,
ApproveRefundRequest,
RejectRefundRequest,
ResubmitRefundRequest,
ReturnRefundRequest,
BaseResponse
} from '@/types/api'
export class RefundService extends BaseService {
/**
* 获取退款申请列表
* @param params 查询参数
*/
static getRefunds(params?: RefundQueryParams): Promise<BaseResponse<RefundListResponse>> {
return this.get<BaseResponse<RefundListResponse>>('/api/admin/refunds', params)
}
/**
* 获取退款申请详情
* @param id 退款申请ID
*/
static getRefundById(id: number): Promise<BaseResponse<Refund>> {
return this.getOne<Refund>(`/api/admin/refunds/${id}`)
}
/**
* 创建退款申请
* @param data 创建退款申请请求参数
*/
static createRefund(data: CreateRefundRequest): Promise<BaseResponse<Refund>> {
return this.post<BaseResponse<Refund>>('/api/admin/refunds', data)
}
/**
* 审批通过退款申请
* @param id 退款申请ID
* @param data 审批通过请求参数
*/
static approveRefund(id: number, data: ApproveRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/approve`, data)
}
/**
* 审批拒绝退款申请
* @param id 退款申请ID
* @param data 审批拒绝请求参数
*/
static rejectRefund(id: number, data: RejectRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/reject`, data)
}
/**
* 重新提交退款申请
* @param id 退款申请ID
* @param data 重新提交请求参数
*/
static resubmitRefund(id: number, data: ResubmitRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/resubmit`, data)
}
/**
* 退回退款申请
* @param id 退款申请ID
* @param data 退回请求参数
*/
static returnRefund(id: number, data: ReturnRefundRequest): Promise<BaseResponse<void>> {
return this.post<BaseResponse<void>>(`/api/admin/refunds/${id}/return`, data)
}
}

View File

@@ -1193,6 +1193,25 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
{
path: 'refund',
name: 'RefundManagement',
component: RoutesAlias.RefundManagement,
meta: {
title: '退款管理',
keepAlive: true
}
},
{
path: 'refund/detail/:id',
name: 'RefundDetailRoute',
component: RoutesAlias.RefundDetail,
meta: {
title: '退款详情',
isHide: true,
keepAlive: false
}
}
]
},
@@ -1305,5 +1324,81 @@ export const asyncRoutes: AppRouteRecord[] = [
// }
// }
]
},
// 轮询管理
{
path: '/polling-management',
name: 'PollingManagement',
component: RoutesAlias.Home,
meta: {
title: '轮询管理',
icon: '&#xe68a;'
},
children: [
{
path: 'data-cleanup',
name: 'DataCleanup',
component: RoutesAlias.DataCleanup,
meta: {
title: '数据清理',
keepAlive: true
}
},
{
path: 'alert-rules',
name: 'AlertRules',
component: RoutesAlias.AlertRules,
meta: {
title: '告警规则',
keepAlive: true
}
},
{
path: 'alert-history',
name: 'AlertHistory',
component: RoutesAlias.AlertHistory,
meta: {
title: '告警历史',
keepAlive: true
}
},
{
path: 'concurrency',
name: 'PollingConcurrency',
component: RoutesAlias.PollingConcurrency,
meta: {
title: '并发控制',
keepAlive: true
}
},
{
path: 'config',
name: 'PollingConfig',
component: RoutesAlias.PollingConfig,
meta: {
title: '轮询配置',
keepAlive: true
}
},
{
path: 'manual-trigger',
name: 'ManualTrigger',
component: RoutesAlias.ManualTrigger,
meta: {
title: '手动触发',
keepAlive: true
}
},
{
path: 'monitor',
name: 'PollingMonitor',
component: RoutesAlias.PollingMonitor,
meta: {
title: '轮询监控',
keepAlive: true
}
}
]
}
]

View File

@@ -129,7 +129,20 @@ export enum RoutesAlias {
DeveloperApi = '/settings/developer-api', // 开发者API
CommissionTemplate = '/settings/commission-template', // 分佣模板
WechatConfig = '/settings/wechat-config', // 微信支付配置
WechatConfigDetail = '/settings/wechat-config/detail' // 微信支付配置详情
WechatConfigDetail = '/settings/wechat-config/detail', // 微信支付配置详情
// 退款管理
RefundManagement = '/finance/refund', // 退款管理
RefundDetail = '/finance/refund/detail', // 退款详情
// 轮询管理
DataCleanup = '/polling-management/data-cleanup', // 数据清理
AlertRules = '/polling-management/alert-rules', // 告警规则
AlertHistory = '/polling-management/alert-history', // 告警历史
PollingConcurrency = '/polling-management/concurrency', // 并发控制
PollingConfig = '/polling-management/config', // 轮询配置
ManualTrigger = '/polling-management/manual-trigger', // 手动触发
PollingMonitor = '/polling-management/monitor' // 轮询监控
}
// 主页路由

74
src/types/api/alert.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* 轮询告警管理相关类型定义
*/
/**
* 告警规则
*/
export interface PollingAlertRule {
id: number
rule_name: string
alert_level: string
metric_type: string
metric_type_name: string
task_type: string
task_type_name: string
operator: string
threshold: number
cooldown_minutes: number
notify_channels: string
status: number
created_at: string
updated_at: string
}
/**
* 告警历史
*/
export interface PollingAlertHistory {
id: number
rule_id: number
rule_name: string
alert_level: string
metric_type: string
task_type: string
threshold: number
current_value: number
message: string
created_at: string
}
/**
* 创建告警规则请求
*/
export interface CreatePollingAlertRuleRequest {
rule_name: string
alert_level: string
metric_type: string
task_type: string
threshold: number
operator?: string
cooldown_minutes?: number
notify_channels?: string
}
/**
* 更新告警规则请求
*/
export interface UpdatePollingAlertRuleRequest {
rule_name?: string
alert_level?: string
threshold?: number
cooldown_minutes?: number
notify_channels?: string
status?: number
}
/**
* 告警历史查询参数
*/
export interface PollingAlertHistoryQueryParams {
rule_id?: number
page?: number
page_size?: number
}

View File

@@ -0,0 +1,78 @@
/**
* 数据清理配置相关类型定义
*/
// 数据清理配置
export interface DataCleanupConfig {
id: number
table_name: string
description: string
retention_days: number
batch_size: number
enabled: number // 0=禁用1=启用
created_at: string
updated_at: string
updated_by: number
}
// 创建数据清理配置请求
export interface CreateDataCleanupConfigRequest {
table_name: string
retention_days: number
batch_size?: number
description?: string
}
// 更新数据清理配置请求
export interface UpdateDataCleanupConfigRequest {
retention_days?: number
batch_size?: number
description?: string
enabled?: number
}
// 数据清理日志
export interface DataCleanupLog {
id: number
table_name: string
cleanup_type: 'scheduled' | 'manual'
status: 'success' | 'failed' | 'running'
retention_days: number
deleted_count: number
duration_ms: number
started_at: string
completed_at: string | null
error_message: string
triggered_by: number | null
}
// 数据清理日志查询参数
export interface DataCleanupLogQueryParams {
table_name?: string
page?: number
page_size?: number
}
// 数据清理预览项
export interface DataCleanupPreviewItem {
table_name: string
description: string
retention_days: number
record_count: number
}
// 数据清理进度
export interface DataCleanupProgress {
is_running: boolean
current_table: string
total_tables: number
processed_tables: number
total_deleted: number
started_at: string | null
last_log: DataCleanupLog | null
}
// 手动触发数据清理请求
export interface TriggerDataCleanupRequest {
table_name?: string
}

View File

@@ -85,3 +85,24 @@ export * from './agentRecharge'
// 微信支付配置相关
export * from './wechatConfig'
// 退款管理相关
export * from './refund'
// 数据清理相关
export * from './dataCleanup'
// 轮询告警相关
export * from './alert'
// 轮询并发控制相关
export * from './pollingConcurrency'
// 轮询配置管理相关
export * from './pollingConfig'
// 手动触发相关
export * from './manualTrigger'
// 轮询监控相关
export * from './pollingMonitor'

View File

@@ -0,0 +1,140 @@
/**
* 手动触发相关类型定义
*/
/**
* 任务类型
*/
export type TaskType = 'polling:realname' | 'polling:carddata' | 'polling:package'
/**
* 触发类型
*/
export type TriggerType = 'single' | 'batch' | 'by_condition'
/**
* 任务状态
*/
export type ManualTriggerStatus = 'pending' | 'processing' | 'completed' | 'cancelled'
/**
* 手动触发日志对象
*/
export interface ManualTriggerLog {
/** 日志ID */
id: number
/** 任务类型 */
task_type: TaskType
/** 任务类型名称 */
task_type_name: string
/** 触发类型 */
trigger_type: TriggerType
/** 触发类型名称 */
trigger_type_name: string
/** 总卡数 */
total_count: number
/** 已处理数 */
processed_count: number
/** 成功数 */
success_count: number
/** 失败数 */
failed_count: number
/** 状态 */
status: ManualTriggerStatus
/** 状态名称 */
status_name: string
/** 触发时间 */
triggered_at: string
/** 完成时间 */
completed_at: string | null
/** 触发人ID */
triggered_by: number
}
/**
* 批量手动触发请求参数
*/
export interface BatchManualTriggerRequest {
/** 任务类型 */
task_type: TaskType
/** 卡ID列表最多1000个 */
card_ids?: number[]
}
/**
* 条件筛选触发请求参数
*/
export interface ConditionManualTriggerRequest {
/** 任务类型 */
task_type: TaskType
/** 限制数量最多1000 */
limit: number
/** 卡状态筛选 */
card_status?: string
/** 卡类型筛选 */
card_type?: string
/** 运营商代码筛选 */
carrier_code?: string
/** 是否启用轮询筛选 */
enable_polling?: boolean
/** 套餐ID列表筛选 */
package_ids?: number[]
/** 店铺ID筛选 */
shop_id?: number
}
/**
* 单卡手动触发请求参数
*/
export interface SingleManualTriggerRequest {
/** 卡ID */
card_id: number
/** 任务类型 */
task_type: TaskType
}
/**
* 取消手动触发请求参数
*/
export interface CancelManualTriggerRequest {
/** 触发任务ID */
trigger_id: number
}
/**
* 手动触发历史查询参数
*/
export interface ManualTriggerHistoryQueryParams {
/** 任务类型筛选 */
task_type?: TaskType
/** 页码 */
page?: number
/** 每页数量 */
page_size?: number
}
/**
* 手动触发历史列表响应
*/
export interface ManualTriggerHistoryResponse {
/** 日志列表 */
items: ManualTriggerLog[]
/** 当前页 */
page: number
/** 每页数量 */
page_size: number
/** 总数 */
total: number
/** 总页数 */
total_pages: number
}
/**
* 手动触发状态响应
*/
export interface ManualTriggerStatusResponse {
/** 各队列大小 */
queue_sizes: Record<string, number>
/** 正在运行的任务列表 */
running_tasks: ManualTriggerLog[]
}

View File

@@ -0,0 +1,45 @@
/**
* 轮询并发控制相关类型定义
*/
/**
* 并发配置对象
*/
export interface PollingConcurrency {
/** 任务类型 */
task_type: string
/** 任务类型名称 */
task_type_name: string
/** 最大并发数 */
max_concurrency: number
/** 当前并发数 */
current: number
/** 可用并发数 */
available: number
/** 使用率(百分比) */
utilization: number
}
/**
* 并发配置列表响应
*/
export interface PollingConcurrencyListResponse {
/** 并发配置列表 */
items: PollingConcurrency[]
}
/**
* 更新并发配置请求参数
*/
export interface UpdateConcurrencyParams {
/** 最大并发数1-1000 */
max_concurrency: number
}
/**
* 重置并发计数请求参数
*/
export interface ResetConcurrencyParams {
/** 任务类型(不传则重置全部) */
task_type?: string
}

View File

@@ -0,0 +1,137 @@
/**
* 轮询配置管理相关类型定义
*/
/**
* 卡业务类型
*/
export type CardCategory = 'normal' | 'industry'
/**
* 卡状态条件
*/
export type CardCondition = 'not_real_name' | 'real_name' | 'activated' | 'suspended'
/**
* 轮询配置对象
*/
export interface PollingConfig {
/** 配置ID */
id: number
/** 配置名称 */
config_name: string
/** 配置说明 */
description: string
/** 卡业务类型 */
card_category: CardCategory
/** 卡状态条件 */
card_condition: CardCondition
/** 运营商ID */
carrier_id: number
/** 优先级(数字越小优先级越高) */
priority: number
/** 状态1:启用0:禁用) */
status: number
/** 实名检查间隔NULL表示不检查 */
realname_check_interval: number | null
/** 套餐检查间隔NULL表示不检查 */
package_check_interval: number | null
/** 流量检查间隔NULL表示不检查 */
carddata_check_interval: number | null
/** 创建时间 */
created_at: string
/** 更新时间 */
updated_at: string
}
/**
* 轮询配置查询参数
*/
export interface PollingConfigQueryParams {
/** 页码默认1 */
page?: number
/** 每页数量1-100 */
page_size?: number
/** 状态1:启用0:禁用) */
status?: number
/** 卡状态条件 */
card_condition?: CardCondition
/** 卡业务类型 */
card_category?: CardCategory
/** 运营商ID */
carrier_id?: number
/** 配置名称(模糊搜索) */
config_name?: string
}
/**
* 轮询配置列表响应
*/
export interface PollingConfigListResponse {
/** 配置列表 */
items: PollingConfig[]
/** 当前页 */
page: number
/** 每页数量 */
page_size: number
/** 总数 */
total: number
/** 总页数 */
total_pages: number
}
/**
* 创建轮询配置请求参数
*/
export interface CreatePollingConfigRequest {
/** 配置名称1-100字符 */
config_name: string
/** 优先级1-1000数字越小优先级越高 */
priority: number
/** 配置说明最多500字符 */
description?: string
/** 卡业务类型 */
card_category?: CardCategory
/** 卡状态条件 */
card_condition?: CardCondition
/** 运营商ID */
carrier_id?: number
/** 实名检查间隔最小30NULL表示不检查 */
realname_check_interval?: number | null
/** 套餐检查间隔最小60NULL表示不检查 */
package_check_interval?: number | null
/** 流量检查间隔最小60NULL表示不检查 */
carddata_check_interval?: number | null
}
/**
* 更新轮询配置请求参数
*/
export interface UpdatePollingConfigRequest {
/** 配置名称1-100字符 */
config_name?: string
/** 优先级1-1000数字越小优先级越高 */
priority?: number
/** 配置说明最多500字符 */
description?: string
/** 卡业务类型 */
card_category?: CardCategory
/** 卡状态条件 */
card_condition?: CardCondition
/** 运营商ID */
carrier_id?: number
/** 实名检查间隔最小30NULL表示不检查 */
realname_check_interval?: number | null
/** 套餐检查间隔最小60NULL表示不检查 */
package_check_interval?: number | null
/** 流量检查间隔最小60NULL表示不检查 */
carddata_check_interval?: number | null
}
/**
* 更新轮询配置状态请求参数
*/
export interface UpdatePollingConfigStatusRequest {
/** 状态1:启用0:禁用) */
status: number
}

View File

@@ -0,0 +1,95 @@
/**
* 轮询监控相关类型定义
*/
/**
* 轮询总览统计对象
*/
export interface PollingOverviewStats {
/** 总卡数 */
total_cards: number
/** 已初始化卡数 */
initialized_cards: number
/** 是否正在初始化 */
is_initializing: boolean
/** 初始化进度0-100 */
init_progress: number
/** 实名检查队列大小 */
realname_queue_size: number
/** 套餐检查队列大小 */
package_queue_size: number
/** 流量检查队列大小 */
carddata_queue_size: number
}
/**
* 轮询初始化进度对象
*/
export interface PollingInitProgress {
/** 总卡数 */
total_cards: number
/** 已初始化卡数 */
initialized_cards: number
/** 进度百分比0-100 */
progress: number
/** 是否完成 */
is_complete: boolean
/** 开始时间 */
started_at: string
/** 预计完成时间 */
estimated_eta: string
}
/**
* 轮询队列状态对象
*/
export interface PollingQueueStatus {
/** 任务类型 */
task_type: string
/** 任务类型名称 */
task_type_name: string
/** 队列大小 */
queue_size: number
/** 到期待处理数 */
due_count: number
/** 手动触发待处理数 */
manual_pending: number
/** 平均等待时间(秒) */
avg_wait_time_s: number
}
/**
* 队列状态列表响应
*/
export interface PollingQueueStatusResponse {
/** 队列状态列表 */
items: PollingQueueStatus[]
}
/**
* 轮询任务统计对象
*/
export interface PollingTaskStats {
/** 任务类型 */
task_type: string
/** 任务类型名称 */
task_type_name: string
/** 1小时总数 */
total_count_1h: number
/** 1小时成功数 */
success_count_1h: number
/** 1小时失败数 */
failure_count_1h: number
/** 成功率0-100 */
success_rate: number
/** 平均耗时(毫秒) */
avg_duration_ms: number
}
/**
* 任务统计列表响应
*/
export interface PollingTaskStatsResponse {
/** 任务统计列表 */
items: PollingTaskStats[]
}

85
src/types/api/refund.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* 退款管理相关类型定义
*/
// 退款状态
export enum RefundStatus {
PENDING = 1, // 待审批
APPROVED = 2, // 已通过
REJECTED = 3, // 已拒绝
RETURNED = 4 // 已退回
}
// 退款申请
export interface Refund {
id: number
refund_no: string
order_id: number
shop_id: number
package_usage_id: number | null
requested_refund_amount: number // 申请退款金额(分)
approved_refund_amount: number | null // 实际退款金额(分)
actual_received_amount: number // 实收金额(分)
status: RefundStatus
refund_reason: string
reject_reason: string
remark: string
asset_reset: boolean
commission_deducted: boolean
creator: number
processor_id: number | null
processed_at: string | null
created_at: string
updated_at: string
updater: number
}
// 查询退款申请列表参数
export interface RefundQueryParams {
page?: number
page_size?: number
status?: RefundStatus
order_id?: number
shop_id?: number
dateRange?: string[]
}
// 退款申请列表响应
export interface RefundListResponse {
items: Refund[]
page: number
size: number
total: number
}
// 创建退款申请请求
export interface CreateRefundRequest {
order_id: number
package_usage_id?: number
requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分)
refund_reason?: string
}
// 审批通过退款申请请求
export interface ApproveRefundRequest {
approved_refund_amount?: number // 实际退款金额(分)
remark?: string
}
// 审批拒绝退款申请请求
export interface RejectRefundRequest {
reject_reason: string
}
// 重新提交退款申请请求
export interface ResubmitRefundRequest {
requested_refund_amount?: number
actual_received_amount?: number
refund_reason?: string
}
// 退回退款申请请求
export interface ReturnRefundRequest {
remark?: string
}

View File

@@ -0,0 +1,565 @@
<template>
<div class="refund-detail-page">
<ElCard shadow="never" v-loading="loading">
<template #header>
<div class="card-header">
<span>退款详情</span>
<ElButton @click="handleGoBack">返回</ElButton>
</div>
</template>
<ElDescriptions :column="2" border v-if="refund">
<ElDescriptionsItem label="退款单号">{{ refund.refund_no }}</ElDescriptionsItem>
<ElDescriptionsItem label="状态">
<ElTag :type="getStatusType(refund.status)">{{ getStatusText(refund.status) }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="订单ID">{{ refund.order_id }}</ElDescriptionsItem>
<ElDescriptionsItem label="店铺ID">{{ refund.shop_id }}</ElDescriptionsItem>
<ElDescriptionsItem label="套餐使用记录ID">
{{ refund.package_usage_id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="申请退款金额">
{{ formatCurrency(refund.requested_refund_amount) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="实际退款金额">
{{
refund.approved_refund_amount ? formatCurrency(refund.approved_refund_amount) : '-'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="实收金额">
{{ formatCurrency(refund.actual_received_amount) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="资产重置">
<ElTag :type="refund.asset_reset ? 'success' : 'info'">
{{ refund.asset_reset ? '是' : '否' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="佣金扣除">
<ElTag :type="refund.commission_deducted ? 'success' : 'info'">
{{ refund.commission_deducted ? '是' : '否' }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="退款原因" :span="2">
{{ refund.refund_reason || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="拒绝原因" :span="2">
{{ refund.reject_reason || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="审批备注" :span="2">
{{ refund.remark || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建人ID">{{ refund.creator }}</ElDescriptionsItem>
<ElDescriptionsItem label="审批人ID">
{{ refund.processor_id || '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">
{{ formatDateTime(refund.created_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="审批时间">
{{ refund.processed_at ? formatDateTime(refund.processed_at) : '-' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">
{{ formatDateTime(refund.updated_at) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="更新人ID">{{ refund.updater }}</ElDescriptionsItem>
</ElDescriptions>
<div class="action-buttons" v-if="refund">
<!-- 待审批状态显示审批操作 -->
<template v-if="refund.status === 1">
<ElButton type="primary" @click="handleShowApprove">审批通过</ElButton>
<ElButton type="danger" @click="handleShowReject">审批拒绝</ElButton>
<ElButton type="warning" @click="handleShowReturn">退回</ElButton>
</template>
<!-- 已拒绝或已退回状态显示重新提交 -->
<template v-if="refund.status === 3 || refund.status === 4">
<ElButton type="primary" @click="handleShowResubmit">重新提交</ElButton>
</template>
</div>
</ElCard>
<!-- 审批通过对话框 -->
<ElDialog
v-model="approveDialogVisible"
title="审批通过"
width="500px"
@closed="handleApproveDialogClosed"
>
<ElForm
ref="approveFormRef"
:model="approveForm"
:rules="approveRules"
label-width="120px"
>
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="申请退款金额">
<span>{{ formatCurrency(refund?.requested_refund_amount || 0) }}</span>
</ElFormItem>
<ElFormItem label="实际退款金额" prop="approved_refund_amount">
<ElInputNumber
v-model="approveForm.approved_refund_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则默认使用申请金额"
/>
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary)">
不填则默认使用申请金额
</div>
</ElFormItem>
<ElFormItem label="审批备注">
<ElInput
v-model="approveForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入审批备注"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="approveDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleApproveRefund" :loading="approveLoading">
确认通过
</ElButton>
</div>
</template>
</ElDialog>
<!-- 审批拒绝对话框 -->
<ElDialog
v-model="rejectDialogVisible"
title="审批拒绝"
width="500px"
@closed="handleRejectDialogClosed"
>
<ElForm ref="rejectFormRef" :model="rejectForm" :rules="rejectRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="拒绝原因" prop="reject_reason">
<ElInput
v-model="rejectForm.reject_reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入拒绝原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="rejectDialogVisible = false">取消</ElButton>
<ElButton type="danger" @click="handleRejectRefund" :loading="rejectLoading">
确认拒绝
</ElButton>
</div>
</template>
</ElDialog>
<!-- 退回对话框 -->
<ElDialog
v-model="returnDialogVisible"
title="退回退款申请"
width="500px"
@closed="handleReturnDialogClosed"
>
<ElForm ref="returnFormRef" :model="returnForm" :rules="returnRules" label-width="120px">
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="退回备注">
<ElInput
v-model="returnForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入退回备注"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="returnDialogVisible = false">取消</ElButton>
<ElButton type="warning" @click="handleReturnRefund" :loading="returnLoading">
确认退回
</ElButton>
</div>
</template>
</ElDialog>
<!-- 重新提交对话框 -->
<ElDialog
v-model="resubmitDialogVisible"
title="重新提交退款申请"
width="500px"
@closed="handleResubmitDialogClosed"
>
<ElForm
ref="resubmitFormRef"
:model="resubmitForm"
:rules="resubmitRules"
label-width="120px"
>
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="申请退款金额">
<ElInputNumber
v-model="resubmitForm.requested_refund_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="实收金额">
<ElInputNumber
v-model="resubmitForm.actual_received_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="退款原因">
<ElInput
v-model="resubmitForm.refund_reason"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="请输入退款原因"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="resubmitDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleResubmitRefund" :loading="resubmitLoading">
确认提交
</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { RefundService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
Refund,
RefundStatus,
ApproveRefundRequest,
RejectRefundRequest,
ReturnRefundRequest,
ResubmitRefundRequest
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'RefundDetail' })
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const approveLoading = ref(false)
const rejectLoading = ref(false)
const returnLoading = ref(false)
const resubmitLoading = ref(false)
const refund = ref<Refund | null>(null)
const approveDialogVisible = ref(false)
const rejectDialogVisible = ref(false)
const returnDialogVisible = ref(false)
const resubmitDialogVisible = ref(false)
const approveFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const returnFormRef = ref<FormInstance>()
const resubmitFormRef = ref<FormInstance>()
const approveRules = reactive<FormRules>({})
const rejectRules = reactive<FormRules>({
reject_reason: [{ required: true, message: '请输入拒绝原因', trigger: 'blur' }]
})
const returnRules = reactive<FormRules>({})
const resubmitRules = reactive<FormRules>({})
const approveForm = reactive<ApproveRefundRequest>({
approved_refund_amount: undefined,
remark: ''
})
const rejectForm = reactive<RejectRefundRequest>({
reject_reason: ''
})
const returnForm = reactive<ReturnRefundRequest>({
remark: ''
})
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
refund_reason: ''
})
// 格式化货币 - 将分转换为元
const formatCurrency = (amount: number): string => {
return `¥${(amount / 100).toFixed(2)}`
}
// 获取状态标签类型
const getStatusType = (
status: RefundStatus
): 'warning' | 'success' | 'danger' | 'info' => {
const statusMap: Record<RefundStatus, 'warning' | 'success' | 'danger' | 'info'> = {
1: 'warning', // 待审批
2: 'success', // 已通过
3: 'danger', // 已拒绝
4: 'info' // 已退回
}
return statusMap[status] || 'info'
}
// 获取状态文本
const getStatusText = (status: RefundStatus): string => {
const statusMap: Record<RefundStatus, string> = {
1: '待审批',
2: '已通过',
3: '已拒绝',
4: '已退回'
}
return statusMap[status] || '-'
}
onMounted(() => {
const id = route.params.id as string
if (id) {
fetchRefundDetail(Number(id))
}
})
// 获取退款详情
const fetchRefundDetail = async (id: number) => {
loading.value = true
try {
const res = await RefundService.getRefundById(id)
if (res.code === 0) {
refund.value = res.data
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 返回
const handleGoBack = () => {
router.back()
}
// 显示审批通过对话框
const handleShowApprove = () => {
approveDialogVisible.value = true
}
// 审批通过对话框关闭后的清理
const handleApproveDialogClosed = () => {
approveFormRef.value?.resetFields()
approveForm.approved_refund_amount = undefined
approveForm.remark = ''
}
// 审批通过
const handleApproveRefund = async () => {
if (!approveFormRef.value || !refund.value) return
await approveFormRef.value.validate(async (valid) => {
if (valid) {
approveLoading.value = true
try {
const data: ApproveRefundRequest = {
approved_refund_amount: approveForm.approved_refund_amount
? approveForm.approved_refund_amount * 100
: undefined,
remark: approveForm.remark || undefined
}
await RefundService.approveRefund(refund.value.id, data)
ElMessage.success('审批通过成功')
approveDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
} catch (error) {
console.error(error)
} finally {
approveLoading.value = false
}
}
})
}
// 显示审批拒绝对话框
const handleShowReject = () => {
rejectDialogVisible.value = true
}
// 审批拒绝对话框关闭后的清理
const handleRejectDialogClosed = () => {
rejectFormRef.value?.resetFields()
rejectForm.reject_reason = ''
}
// 审批拒绝
const handleRejectRefund = async () => {
if (!rejectFormRef.value || !refund.value) return
await rejectFormRef.value.validate(async (valid) => {
if (valid) {
rejectLoading.value = true
try {
await RefundService.rejectRefund(refund.value.id, rejectForm)
ElMessage.success('审批拒绝成功')
rejectDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
} catch (error) {
console.error(error)
} finally {
rejectLoading.value = false
}
}
})
}
// 显示退回对话框
const handleShowReturn = () => {
returnDialogVisible.value = true
}
// 退回对话框关闭后的清理
const handleReturnDialogClosed = () => {
returnFormRef.value?.resetFields()
returnForm.remark = ''
}
// 退回
const handleReturnRefund = async () => {
if (!returnFormRef.value || !refund.value) return
await returnFormRef.value.validate(async (valid) => {
if (valid) {
returnLoading.value = true
try {
await RefundService.returnRefund(refund.value.id, returnForm)
ElMessage.success('退回成功')
returnDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
} catch (error) {
console.error(error)
} finally {
returnLoading.value = false
}
}
})
}
// 显示重新提交对话框
const handleShowResubmit = () => {
if (!refund.value) return
// 预填充原有数据
resubmitForm.requested_refund_amount = refund.value.requested_refund_amount / 100
resubmitForm.actual_received_amount = refund.value.actual_received_amount / 100
resubmitForm.refund_reason = refund.value.refund_reason
resubmitDialogVisible.value = true
}
// 重新提交对话框关闭后的清理
const handleResubmitDialogClosed = () => {
resubmitFormRef.value?.resetFields()
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.refund_reason = ''
}
// 重新提交
const handleResubmitRefund = async () => {
if (!resubmitFormRef.value || !refund.value) return
await resubmitFormRef.value.validate(async (valid) => {
if (valid) {
resubmitLoading.value = true
try {
const data: ResubmitRefundRequest = {
requested_refund_amount: resubmitForm.requested_refund_amount
? resubmitForm.requested_refund_amount * 100
: undefined,
actual_received_amount: resubmitForm.actual_received_amount
? resubmitForm.actual_received_amount * 100
: undefined,
refund_reason: resubmitForm.refund_reason || undefined
}
await RefundService.resubmitRefund(refund.value.id, data)
ElMessage.success('重新提交成功')
resubmitDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
} catch (error) {
console.error(error)
} finally {
resubmitLoading.value = false
}
}
})
}
</script>
<style scoped lang="scss">
.refund-detail-page {
padding: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.action-buttons {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
<template>
<ArtTableFullScreen>
<div class="alert-history-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
/>
<el-card shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<el-table-column v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</el-card>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { ElMessage, ElTag } from 'element-plus'
import { PollingAlertService } from '@/api/modules'
import type {
PollingAlertHistory,
PollingAlertHistoryQueryParams,
PollingAlertRule
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { formatDateTime } from '@/utils/business/format'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
const loading = ref(false)
const tableData = ref<PollingAlertHistory[]>([])
const tableRef = ref()
const contextMenuRef = ref()
const currentClickRow = ref<PollingAlertHistory | null>(null)
const ruleList = ref<PollingAlertRule[]>([])
// 分页数据
const pagination = reactive({
page: 1,
page_size: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
rule_id: undefined as number | undefined
})
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
// 搜索表单配置
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '告警规则',
prop: 'rule_id',
type: 'select',
config: {
clearable: true,
filterable: true,
placeholder: '请选择告警规则'
},
options: () =>
ruleList.value.map((r) => ({
label: r.rule_name,
value: r.id
}))
}
])
const getAlertLevelLabel = (level: string) => {
return level === 'critical' ? '严重' : '警告'
}
const getMetricTypeLabel = (type: string) => {
const labels: Record<string, string> = {
queue_size: '队列大小',
success_rate: '成功率',
avg_duration: '平均执行时间',
concurrency: '并发数'
}
return labels[type] || type
}
const getTaskTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'polling:realname': '实名认证轮询',
'polling:carddata': '卡数据轮询',
'polling:package': '套餐轮询'
}
return labels[type] || type
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'rule_name',
label: '规则名称',
minWidth: 150
},
{
prop: 'alert_level',
label: '告警级别',
width: 100,
slots: {
default: ({ row }: any) =>
h(ElTag, { type: row.alert_level === 'critical' ? 'danger' : 'warning' }, () =>
getAlertLevelLabel(row.alert_level)
)
}
},
{
prop: 'metric_type',
label: '指标类型',
width: 120,
formatter: (row: any) => getMetricTypeLabel(row.metric_type)
},
{
prop: 'task_type',
label: '任务类型',
width: 150,
formatter: (row: any) => getTaskTypeLabel(row.task_type)
},
{
prop: 'threshold',
label: '阈值',
width: 100
},
{
prop: 'current_value',
label: '触发值',
width: 100
},
{
prop: 'message',
label: '告警消息',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'created_at',
label: '触发时间',
width: 180,
formatter: (row: any) => formatDateTime(row.created_at)
}
])
const columnOptions = computed(() =>
columns.value.map((col) => ({
label: col.label,
prop: col.prop || col.label,
visible: true
}))
)
const contextMenuItems = computed<MenuItemType[]>(() => [
{
label: '刷新',
key: 'refresh'
}
])
const loadRuleList = async () => {
try {
const res = await PollingAlertService.getPollingAlertRules()
if (res.code === 0 && res.data) {
ruleList.value = res.data.items
}
} catch (error) {
console.error('加载规则列表失败', error)
}
}
const loadData = async () => {
loading.value = true
try {
const queryParams: PollingAlertHistoryQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
...searchForm
}
const { data } = await PollingAlertService.getPollingAlertHistory(queryParams)
tableData.value = data.items
pagination.total = data.total
} catch (error) {
ElMessage.error('加载告警历史失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.assign(searchForm, {
rule_id: undefined
})
pagination.page = 1
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadData()
}
const handleRowContextMenu = (row: PollingAlertHistory, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentClickRow.value = row
contextMenuRef.value?.show(event)
}
const handleContextMenuSelect = (item: MenuItemType) => {
if (item.key === 'refresh') {
loadData()
}
}
onMounted(() => {
loadRuleList()
loadData()
})
</script>
<style scoped lang="scss">
.alert-history-page {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,479 @@
<template>
<ArtTableFullScreen>
<div class="alert-rules-page" id="table-full-screen">
<el-card shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<el-button type="primary" @click="showCreateDialog">新增规则</el-button>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:marginTop="10"
:row-class-name="getRowClassName"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<el-table-column v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</el-card>
<!-- 新增/编辑规则弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'create' ? '新增告警规则' : '编辑告警规则'"
width="700px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="规则名称" prop="rule_name">
<el-input v-model="form.rule_name" placeholder="请输入规则名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="告警级别" prop="alert_level">
<el-select v-model="form.alert_level" placeholder="请选择告警级别" style="width: 100%">
<el-option label="警告" value="warning" />
<el-option label="严重" value="critical" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指标类型" prop="metric_type">
<el-select
v-model="form.metric_type"
placeholder="请选择指标类型"
:disabled="dialogType === 'edit'"
style="width: 100%"
>
<el-option label="队列大小" value="queue_size" />
<el-option label="成功率" value="success_rate" />
<el-option label="平均执行时间" value="avg_duration" />
<el-option label="并发数" value="concurrency" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="任务类型" prop="task_type">
<el-select
v-model="form.task_type"
placeholder="请选择任务类型"
:disabled="dialogType === 'edit'"
style="width: 100%"
>
<el-option label="实名认证轮询" value="polling:realname" />
<el-option label="卡数据轮询" value="polling:carddata" />
<el-option label="套餐轮询" value="polling:package" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="运算符" prop="operator">
<el-select v-model="form.operator" placeholder="请选择运算符" style="width: 100%">
<el-option label=">" value=">" />
<el-option label=">=" value=">=" />
<el-option label="<" value="<" />
<el-option label="<=" value="<=" />
<el-option label="=" value="=" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="阈值" prop="threshold">
<el-input-number
v-model="form.threshold"
:min="0"
:precision="2"
placeholder="请输入阈值"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="冷却时间(分钟)" prop="cooldown_minutes">
<el-input-number
v-model="form.cooldown_minutes"
:min="1"
placeholder="请输入冷却时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="通知渠道" prop="notify_channels">
<el-input
v-model="form.notify_channels"
type="textarea"
:rows="3"
placeholder="请输入通知渠道配置(JSON格式)"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch, h } from 'vue'
import { ElMessage, ElMessageBox, ElTag, type FormInstance, type FormRules } from 'element-plus'
import { PollingAlertService } from '@/api/modules'
import type { PollingAlertRule, CreatePollingAlertRuleRequest } from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
const loading = ref(false)
const tableData = ref<PollingAlertRule[]>([])
const dialogVisible = ref(false)
const dialogType = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const contextMenuRef = ref()
const currentRow = ref<PollingAlertRule | null>(null)
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const form = reactive<CreatePollingAlertRuleRequest & { id?: number }>({
rule_name: '',
alert_level: '',
metric_type: '',
task_type: '',
threshold: 0,
operator: '>',
cooldown_minutes: 5,
notify_channels: ''
})
const rules: FormRules = {
rule_name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
alert_level: [{ required: true, message: '请选择告警级别', trigger: 'change' }],
metric_type: [{ required: true, message: '请选择指标类型', trigger: 'change' }],
task_type: [{ required: true, message: '请选择任务类型', trigger: 'change' }],
threshold: [{ required: true, message: '请输入阈值', trigger: 'blur' }],
operator: [{ required: true, message: '请选择运算符', trigger: 'change' }],
cooldown_minutes: [{ required: true, message: '请输入冷却时间', trigger: 'blur' }]
}
const getAlertLevelLabel = (level: string) => {
return level === 'critical' ? '严重' : '警告'
}
const getMetricTypeLabel = (type: string) => {
const labels: Record<string, string> = {
queue_size: '队列大小',
success_rate: '成功率',
avg_duration: '平均执行时间',
concurrency: '并发数'
}
return labels[type] || type
}
const getTaskTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'polling:realname': '实名认证轮询',
'polling:carddata': '卡数据轮询',
'polling:package': '套餐轮询'
}
return labels[type] || type
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'rule_name',
label: '规则名称',
minWidth: 150
},
{
prop: 'alert_level',
label: '告警级别',
width: 100,
slots: {
default: ({ row }: any) =>
h(
ElTag,
{ type: row.alert_level === 'critical' ? 'danger' : 'warning' },
() => getAlertLevelLabel(row.alert_level)
)
}
},
{
prop: 'metric_type_name',
label: '指标类型',
width: 120,
formatter: (row: any) => getMetricTypeLabel(row.metric_type)
},
{
prop: 'task_type_name',
label: '任务类型',
width: 150,
formatter: (row: any) => getTaskTypeLabel(row.task_type)
},
{
prop: 'operator',
label: '运算符',
width: 80
},
{
prop: 'threshold',
label: '阈值',
width: 100
},
{
prop: 'cooldown_minutes',
label: '冷却时间(分钟)',
width: 120
},
{
prop: 'status',
label: '状态',
width: 80,
slots: {
default: ({ row }: any) =>
h(ElTag, { type: row.status === 1 ? 'success' : 'info' }, () =>
row.status === 1 ? '启用' : '禁用'
)
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: any) => formatDateTime(row.created_at)
}
])
const columnOptions = computed(() =>
columns.value.map((col) => ({
label: col.label,
prop: col.prop || col.label,
visible: true
}))
)
// 右键菜单项
const contextMenuItems = computed<MenuItemType[]>(() => {
return [
{ key: 'edit', label: '编辑' },
{ key: 'toggle', label: currentRow.value?.status === 1 ? '禁用' : '启用' },
{ key: 'delete', label: '删除' }
]
})
watch(dialogVisible, (val) => {
if (!val) {
nextTick(() => {
formRef.value?.clearValidate()
})
}
})
const loadData = async () => {
loading.value = true
try {
const res = await PollingAlertService.getPollingAlertRules()
if (res.code === 0 && res.data) {
tableData.value = res.data.items
}
} catch (error) {
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
const handleRefresh = () => {
loadData()
}
const resetForm = () => {
Object.assign(form, {
id: undefined,
rule_name: '',
alert_level: '',
metric_type: '',
task_type: '',
threshold: 0,
operator: '>',
cooldown_minutes: 5,
notify_channels: ''
})
}
const showCreateDialog = () => {
resetForm()
dialogType.value = 'create'
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
const showEditDialog = async (row: PollingAlertRule) => {
try {
const res = await PollingAlertService.getPollingAlertRuleById(row.id)
if (res.code === 0 && res.data) {
Object.assign(form, {
id: res.data.id,
rule_name: res.data.rule_name,
alert_level: res.data.alert_level,
metric_type: res.data.metric_type,
task_type: res.data.task_type,
threshold: res.data.threshold,
operator: res.data.operator,
cooldown_minutes: res.data.cooldown_minutes,
notify_channels: res.data.notify_channels
})
dialogType.value = 'edit'
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
} catch (error) {
ElMessage.error('获取规则详情失败')
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
try {
if (dialogType.value === 'create') {
const res = await PollingAlertService.createPollingAlertRule(form)
if (res.code === 0) {
ElMessage.success('创建成功')
dialogVisible.value = false
loadData()
}
} else {
const { id, metric_type, task_type, operator, ...updateData } = form
const res = await PollingAlertService.updatePollingAlertRule(id!, updateData)
if (res.code === 0) {
ElMessage.success('更新成功')
dialogVisible.value = false
loadData()
}
}
} catch (error) {
ElMessage.error(dialogType.value === 'create' ? '创建失败' : '更新失败')
}
}
})
}
const toggleStatus = async (row: PollingAlertRule) => {
try {
const newStatus = row.status === 1 ? 0 : 1
const res = await PollingAlertService.updatePollingAlertRule(row.id, { status: newStatus })
if (res.code === 0) {
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
loadData()
}
} catch (error) {
ElMessage.error('操作失败')
}
}
const handleDelete = (row: PollingAlertRule) => {
ElMessageBox.confirm('确定要删除该告警规则吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await PollingAlertService.deletePollingAlertRule(row.id)
if (res.code === 0) {
ElMessage.success('删除成功')
loadData()
}
} catch (error) {
ElMessage.error('删除失败')
}
})
}
// 处理表格行右键菜单
const handleRowContextMenu = (row: PollingAlertRule, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'edit':
showEditDialog(currentRow.value)
break
case 'toggle':
toggleStatus(currentRow.value)
break
case 'delete':
handleDelete(currentRow.value)
break
}
}
loadData()
</script>
<style scoped lang="scss">
.alert-rules-page {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,292 @@
<template>
<div class="app-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>并发配置</span>
<el-button
v-permission="'polling_concurrency_reset'"
type="warning"
@click="handleResetAll"
>
重置全部并发
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
:row-class-name="getRowClassName"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<el-table-column prop="task_type" label="任务类型" min-width="200" />
<el-table-column prop="task_type_name" label="任务名称" min-width="150" />
<el-table-column prop="max_concurrency" label="最大并发数" min-width="120" align="center" />
<el-table-column prop="current" label="当前并发数" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.current > row.max_concurrency * 0.8 ? 'danger' : 'success'">
{{ row.current }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="available" label="可用并发数" min-width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.available === 0 ? 'danger' : 'info'">
{{ row.available }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="utilization" label="使用率" min-width="150" align="center">
<template #default="{ row }">
<el-progress
:percentage="row.utilization"
:color="getUtilizationColor(row.utilization)"
:stroke-width="8"
/>
</template>
</el-table-column>
</el-table>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</el-card>
<!-- 编辑对话框 -->
<el-dialog
v-model="dialogVisible"
title="修改并发配置"
width="500px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="任务类型">
<el-input v-model="currentRow.task_type_name" disabled />
</el-form-item>
<el-form-item label="最大并发数" prop="max_concurrency">
<el-input-number
v-model="formData.max_concurrency"
:min="1"
:max="1000"
:step="10"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PollingConcurrencyService } from '@/api/modules'
import type { PollingConcurrency, UpdateConcurrencyParams } from '@/types/api'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
const loading = ref(false)
const tableData = ref<PollingConcurrency[]>([])
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentClickRow = ref<PollingConcurrency | null>(null)
const currentRow = ref<PollingConcurrency>({
task_type: '',
task_type_name: '',
max_concurrency: 0,
current: 0,
available: 0,
utilization: 0
})
const formData = reactive<UpdateConcurrencyParams>({
max_concurrency: 100
})
const formRules: FormRules = {
max_concurrency: [
{ required: true, message: '请输入最大并发数', trigger: 'blur' },
{ type: 'number', min: 1, max: 1000, message: '并发数范围为 1-1000', trigger: 'blur' }
]
}
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
const contextMenuItems = computed<MenuItemType[]>(() => [
{
label: '修改配置',
key: 'edit',
permission: 'polling_concurrency_update'
},
{
label: '重置',
key: 'reset',
permission: 'polling_concurrency_reset'
}
])
const getUtilizationColor = (percentage: number) => {
if (percentage >= 90) return '#f56c6c'
if (percentage >= 70) return '#e6a23c'
if (percentage >= 50) return '#409eff'
return '#67c23a'
}
const loadData = async () => {
loading.value = true
try {
const { data } = await PollingConcurrencyService.getConcurrencyList()
tableData.value = data.items
} catch (error) {
ElMessage.error('加载并发配置失败')
} finally {
loading.value = false
}
}
const handleEdit = async (row: PollingConcurrency) => {
try {
// 调用接口#2获取指定任务类型的最新并发配置
const { data } = await PollingConcurrencyService.getConcurrencyByType(row.task_type)
currentRow.value = { ...data }
formData.max_concurrency = data.max_concurrency
dialogVisible.value = true
} catch (error) {
ElMessage.error('获取并发配置失败')
}
}
const handleReset = async (row: PollingConcurrency) => {
try {
await ElMessageBox.confirm(
`确定要重置 ${row.task_type_name} 的并发计数吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await PollingConcurrencyService.resetConcurrency({ task_type: row.task_type })
ElMessage.success('重置成功')
await loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('重置失败')
}
}
}
const handleResetAll = async () => {
try {
await ElMessageBox.confirm(
'确定要重置所有任务类型的并发计数吗?',
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await PollingConcurrencyService.resetConcurrency()
ElMessage.success('重置成功')
await loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('重置失败')
}
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async valid => {
if (!valid) return
try {
await PollingConcurrencyService.updateConcurrency(
currentRow.value.task_type,
formData
)
ElMessage.success('修改成功')
dialogVisible.value = false
await loadData()
} catch (error) {
ElMessage.error('修改失败')
}
})
}
const handleDialogClose = () => {
formRef.value?.resetFields()
}
// 处理表格行右键菜单
const handleRowContextMenu = (row: PollingConcurrency, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentClickRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentClickRow.value) return
switch (item.key) {
case 'edit':
handleEdit(currentClickRow.value)
break
case 'reset':
handleReset(currentClickRow.value)
break
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,704 @@
<template>
<ArtTableFullScreen>
<div class="polling-config-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="true"
label-width="100"
@reset="handleReset"
@search="handleSearch"
/>
<el-card shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<el-button type="primary" @click="handleCreate" v-permission="'polling_config_create'">
新增配置
</el-button>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<el-table-column v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'create' ? '新增轮询配置' : '编辑轮询配置'"
width="900px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="配置名称" prop="config_name">
<el-input
v-model="formData.config_name"
placeholder="请输入配置名称"
maxlength="100"
show-word-limit
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级" prop="priority">
<el-input-number
v-model="formData.priority"
:min="1"
:max="1000"
:controls="false"
placeholder="数字越小优先级越高"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="配置说明" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="2"
placeholder="请输入配置说明(最多500字符)"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
<!-- 筛选条件 -->
<el-divider content-position="left">筛选条件</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="卡业务类型" prop="card_category">
<el-select
v-model="formData.card_category"
placeholder="请选择卡业务类型"
style="width: 100%"
clearable
>
<el-option label="普通卡" value="normal" />
<el-option label="行业卡" value="industry" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卡状态条件" prop="card_condition">
<el-select
v-model="formData.card_condition"
placeholder="请选择卡状态条件"
style="width: 100%"
clearable
>
<el-option label="未实名" value="not_real_name" />
<el-option label="已实名" value="real_name" />
<el-option label="已激活" value="activated" />
<el-option label="已停用" value="suspended" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="运营商" prop="carrier_id">
<el-select
v-model="formData.carrier_id"
placeholder="请选择运营商"
style="width: 100%"
filterable
remote
:remote-method="remoteSearchCarrier"
clearable
>
<el-option
v-for="carrier in carrierList"
:key="carrier.id"
:label="carrier.carrier_name"
:value="carrier.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 检查间隔配置 -->
<el-divider content-position="left">检查间隔配置</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="实名检查" prop="realname_check_interval">
<el-input-number
v-model="formData.realname_check_interval"
:min="30"
:controls="false"
placeholder="最小30"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="套餐检查" prop="package_check_interval">
<el-input-number
v-model="formData.package_check_interval"
:min="60"
:controls="false"
placeholder="最小60"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="流量检查" prop="carddata_check_interval">
<el-input-number
v-model="formData.carddata_check_interval"
:min="60"
:controls="false"
placeholder="最小60"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, h } from 'vue'
import { ElMessage, ElMessageBox, ElTag } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { PollingConfigService, CarrierService } from '@/api/modules'
import type {
PollingConfig,
PollingConfigQueryParams,
CreatePollingConfigRequest,
UpdatePollingConfigRequest
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { formatDateTime } from '@/utils/business/format'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
const loading = ref(false)
const tableData = ref<PollingConfig[]>([])
const tableRef = ref()
const dialogVisible = ref(false)
const dialogType = ref<'create' | 'edit'>('create')
const formRef = ref<FormInstance>()
const contextMenuRef = ref()
const currentClickRow = ref<PollingConfig | null>(null)
const carrierList = ref<any[]>([])
// 分页数据
const pagination = reactive({
page: 1,
page_size: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
config_name: '',
card_category: undefined as string | undefined,
card_condition: undefined as string | undefined,
carrier_id: undefined as number | undefined,
status: undefined as number | undefined
})
const formData = reactive<CreatePollingConfigRequest & { id?: number }>({
config_name: '',
priority: 1,
description: '',
card_category: undefined,
card_condition: undefined,
carrier_id: undefined,
realname_check_interval: null,
package_check_interval: null,
carddata_check_interval: null
})
const formRules: FormRules = {
config_name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' },
{ min: 1, max: 100, message: '配置名称长度为1-100字符', trigger: 'blur' }
],
priority: [
{ required: true, message: '请输入优先级', trigger: 'blur' },
{ type: 'number', min: 1, max: 1000, message: '优先级范围为1-1000', trigger: 'blur' }
],
description: [{ max: 500, message: '配置说明最多500字符', trigger: 'blur' }],
realname_check_interval: [
{ type: 'number', min: 30, message: '实名检查间隔最小30秒', trigger: 'blur' }
],
package_check_interval: [
{ type: 'number', min: 60, message: '套餐检查间隔最小60秒', trigger: 'blur' }
],
carddata_check_interval: [
{ type: 'number', min: 60, message: '流量检查间隔最小60秒', trigger: 'blur' }
]
}
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
// 搜索表单配置
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '配置名称',
prop: 'config_name',
type: 'input',
config: {
clearable: true,
placeholder: '请输入配置名称'
}
},
{
label: '卡业务类型',
prop: 'card_category',
type: 'select',
config: {
clearable: true,
placeholder: '请选择卡业务类型'
},
options: () => [
{ label: '普通卡', value: 'normal' },
{ label: '行业卡', value: 'industry' }
]
},
{
label: '卡状态条件',
prop: 'card_condition',
type: 'select',
config: {
clearable: true,
placeholder: '请选择卡状态'
},
options: () => [
{ label: '未实名', value: 'not_real_name' },
{ label: '已实名', value: 'real_name' },
{ label: '已激活', value: 'activated' },
{ label: '已停用', value: 'suspended' }
]
},
{
label: '运营商',
prop: 'carrier_id',
type: 'select',
config: {
clearable: true,
filterable: true,
remote: true,
remoteMethod: remoteSearchCarrier,
placeholder: '请选择运营商'
},
options: () =>
carrierList.value.map((c) => ({
label: c.carrier_name,
value: c.id
}))
},
{
label: '状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '请选择状态'
},
options: () => [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
]
}
])
const getCardConditionLabel = (condition: string) => {
const labels: Record<string, string> = {
not_real_name: '未实名',
real_name: '已实名',
activated: '已激活',
suspended: '已停用'
}
return labels[condition] || condition
}
const getCarrierName = (carrierId: number) => {
const carrier = carrierList.value.find((c) => c.id === carrierId)
return carrier?.carrier_name || '-'
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'config_name',
label: '配置名称',
minWidth: 150
},
{
prop: 'description',
label: '配置说明',
minWidth: 200,
showOverflowTooltip: true
},
{
prop: 'card_category',
label: '卡业务类型',
width: 120,
formatter: (row: any) =>
row.card_category ? (row.card_category === 'normal' ? '普通卡' : '行业卡') : '-'
},
{
prop: 'card_condition',
label: '卡状态条件',
width: 120,
formatter: (row: any) =>
row.card_condition ? getCardConditionLabel(row.card_condition) : '-'
},
{
prop: 'carrier_id',
label: '运营商',
width: 120,
formatter: (row: any) => getCarrierName(row.carrier_id)
},
{
prop: 'priority',
label: '优先级',
width: 100
},
{
prop: 'status',
label: '状态',
width: 100,
formatter: (row: any) => (row.status === 1 ? '启用' : '禁用')
},
{
label: '检查间隔(秒)',
width: 220,
slots: {
default: ({ row }: any) =>
h('div', { style: 'font-size: 12px; line-height: 1.5' }, [
h('div', `实名: ${row.realname_check_interval ?? '-'}`),
h('div', `套餐: ${row.package_check_interval ?? '-'}`),
h('div', `流量: ${row.carddata_check_interval ?? '-'}`)
])
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: any) => formatDateTime(row.created_at)
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: any) => formatDateTime(row.updated_at)
}
])
const columnOptions = computed(() =>
columns.value.map((col) => ({
label: col.label,
prop: col.prop || col.label,
visible: true
}))
)
const contextMenuItems = computed<MenuItemType[]>(() => [
{
label: '编辑',
key: 'edit',
permission: 'polling_config_update'
},
{
label: currentClickRow.value?.status === 1 ? '禁用' : '启用',
key: 'toggle',
permission: 'polling_config_update_status'
},
{
label: '删除',
key: 'delete',
permission: 'polling_config_delete'
}
])
const loadCarriers = async (carrierName?: string) => {
try {
const { data } = await CarrierService.getCarriers({
page: 1,
page_size: 20,
carrier_name: carrierName
})
carrierList.value = data.items || data.list || []
} catch (error) {
console.error('加载运营商列表失败', error)
}
}
const remoteSearchCarrier = (query: string) => {
if (query) {
loadCarriers(query)
} else {
loadCarriers()
}
}
const loadData = async () => {
loading.value = true
try {
const queryParams: PollingConfigQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
...searchForm
}
const { data } = await PollingConfigService.getPollingConfigs(queryParams)
tableData.value = data.items
pagination.total = data.total
} catch (error) {
ElMessage.error('加载配置列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.assign(searchForm, {
config_name: '',
card_category: undefined,
card_condition: undefined,
carrier_id: undefined,
status: undefined
})
pagination.page = 1
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadData()
}
const handleCreate = () => {
dialogType.value = 'create'
Object.assign(formData, {
id: undefined,
config_name: '',
priority: 1,
description: '',
card_category: undefined,
card_condition: undefined,
carrier_id: undefined,
realname_check_interval: null,
package_check_interval: null,
carddata_check_interval: null
})
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
const handleEdit = async (row: PollingConfig) => {
try {
const { data } = await PollingConfigService.getPollingConfigById(row.id)
dialogType.value = 'edit'
Object.assign(formData, {
id: data.id,
config_name: data.config_name,
priority: data.priority,
description: data.description,
card_category: data.card_category,
card_condition: data.card_condition,
carrier_id: data.carrier_id,
realname_check_interval: data.realname_check_interval,
package_check_interval: data.package_check_interval,
carddata_check_interval: data.carddata_check_interval
})
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
} catch (error) {
ElMessage.error('获取配置详情失败')
}
}
const handleToggleStatus = async (row: PollingConfig) => {
const newStatus = row.status === 1 ? 0 : 1
try {
await PollingConfigService.updatePollingConfigStatus(row.id, { status: newStatus })
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
await loadData()
} catch (error) {
ElMessage.error('状态更新失败')
}
}
const handleDelete = async (row: PollingConfig) => {
try {
await ElMessageBox.confirm(`确定要删除配置 "${row.config_name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await PollingConfigService.deletePollingConfig(row.id)
ElMessage.success('删除成功')
await loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
try {
const submitData: CreatePollingConfigRequest | UpdatePollingConfigRequest = {
config_name: formData.config_name,
priority: formData.priority,
description: formData.description,
card_category: formData.card_category,
card_condition: formData.card_condition,
carrier_id: formData.carrier_id,
realname_check_interval: formData.realname_check_interval,
package_check_interval: formData.package_check_interval,
carddata_check_interval: formData.carddata_check_interval
}
if (dialogType.value === 'create') {
await PollingConfigService.createPollingConfig(submitData as CreatePollingConfigRequest)
ElMessage.success('创建成功')
} else {
await PollingConfigService.updatePollingConfig(formData.id!, submitData)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await loadData()
} catch (error) {
ElMessage.error(dialogType.value === 'create' ? '创建失败' : '更新失败')
}
})
}
const handleDialogClosed = () => {
formRef.value?.resetFields()
}
const handleRowContextMenu = (row: PollingConfig, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentClickRow.value = row
contextMenuRef.value?.show(event)
}
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentClickRow.value) return
switch (item.key) {
case 'edit':
handleEdit(currentClickRow.value)
break
case 'toggle':
handleToggleStatus(currentClickRow.value)
break
case 'delete':
handleDelete(currentClickRow.value)
break
}
}
onMounted(() => {
loadCarriers()
loadData()
})
</script>
<style scoped lang="scss">
.polling-config-page {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<ArtTableFullScreen>
<div class="data-cleanup-page" id="table-full-screen">
<!-- 进度提示 -->
<ElAlert
v-if="progress?.is_running"
title="数据清理进行中"
type="info"
:closable="false"
style="margin-bottom: 16px"
>
<template #default>
<div>
当前清理表: {{ progress.current_table }} | 已处理: {{ progress.processed_tables }}/{{
progress.total_tables
}}
| 已删除: {{ progress.total_deleted }} 条记录
</div>
</template>
</ElAlert>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton type="primary" @click="showCreateDialog">新增配置</ElButton>
<ElButton @click="handlePreview">预览清理</ElButton>
<ElButton type="warning" @click="handleTriggerCleanup" :loading="triggering">
手动清理
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="configList"
:marginTop="10"
:row-class-name="getRowClassName"
:show-pagination="false"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="140"
@select="handleContextMenuSelect"
/>
<!-- 新增/编辑对话框 -->
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增配置' : '编辑配置'"
width="600px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="表名" prop="table_name">
<ElInput
v-model="form.table_name"
placeholder="请输入表名"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElFormItem label="配置说明" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="2"
placeholder="请输入配置说明"
/>
</ElFormItem>
<ElFormItem label="保留天数" prop="retention_days">
<ElInputNumber
v-model="form.retention_days"
:min="7"
:max="3650"
placeholder="最少7天"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem label="每批删除条数" prop="batch_size">
<ElInputNumber
v-model="form.batch_size"
:min="100"
:max="100000"
placeholder="默认10000"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem v-if="dialogType === 'edit'" label="是否启用" prop="enabled">
<ElSwitch
v-model="form.enabled"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
提交
</ElButton>
</div>
</template>
</ElDialog>
<!-- 预览对话框 -->
<ElDialog v-model="previewVisible" title="预览待清理数据" width="700px">
<ElTable :data="previewData" border>
<ElTableColumn prop="table_name" label="表名" width="150" />
<ElTableColumn prop="description" label="说明" min-width="180" />
<ElTableColumn prop="retention_days" label="保留天数" width="100" align="center" />
<ElTableColumn prop="record_count" label="待清理记录数" width="130" align="right">
<template #default="{ row }">
<span :class="{ 'text-danger': row.record_count > 10000 }">
{{ row.record_count.toLocaleString() }}
</span>
</template>
</ElTableColumn>
</ElTable>
<template #footer>
<ElButton @click="previewVisible = false">关闭</ElButton>
</template>
</ElDialog>
<!-- 清理日志对话框 -->
<ElDialog v-model="logVisible" title="清理日志" width="1000px">
<div style="margin-bottom: 16px">
<ElInput
v-model="logFilter.table_name"
placeholder="表名筛选"
clearable
style="width: 200px; margin-right: 10px"
@clear="getCleanupLogs"
/>
<ElButton type="primary" @click="getCleanupLogs">查询</ElButton>
</div>
<ElTable :data="logList" border>
<ElTableColumn prop="table_name" label="表名" width="120" />
<ElTableColumn prop="cleanup_type" label="清理类型" width="100">
<template #default="{ row }">
<ElTag :type="row.cleanup_type === 'scheduled' ? 'success' : 'warning'">
{{ row.cleanup_type === 'scheduled' ? '定时' : '手动' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="状态" width="100">
<template #default="{ row }">
<ElTag
:type="
row.status === 'success'
? 'success'
: row.status === 'running'
? 'info'
: 'danger'
"
>
{{
row.status === 'success' ? '成功' : row.status === 'running' ? '运行中' : '失败'
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="retention_days" label="保留天数" width="100" align="center" />
<ElTableColumn prop="deleted_count" label="删除记录数" width="110" align="right">
<template #default="{ row }">
{{ row.deleted_count.toLocaleString() }}
</template>
</ElTableColumn>
<ElTableColumn prop="duration_ms" label="耗时(ms)" width="100" align="right" />
<ElTableColumn prop="started_at" label="开始时间" width="160">
<template #default="{ row }">
{{ formatDateTime(row.started_at) }}
</template>
</ElTableColumn>
<ElTableColumn
prop="error_message"
label="错误信息"
min-width="150"
show-overflow-tooltip
/>
</ElTable>
<ElPagination
v-model:current-page="logPagination.page"
v-model:page-size="logPagination.page_size"
:total="logPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@size-change="getCleanupLogs"
@current-change="getCleanupLogs"
/>
<template #footer>
<ElButton @click="logVisible = false">关闭</ElButton>
</template>
</ElDialog>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { DataCleanupService } from '@/api/modules'
import { ElMessage, ElTag, ElSwitch, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type {
DataCleanupConfig,
CreateDataCleanupConfigRequest,
UpdateDataCleanupConfigRequest,
DataCleanupLog,
DataCleanupPreviewItem,
DataCleanupProgress
} from '@/types/api'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import ArtMenuRight from '@/components/core/others/ArtMenuRight.vue'
import TableContextMenuHint from '@/components/core/others/TableContextMenuHint.vue'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
import { formatDateTime } from '@/utils/business/format'
defineOptions({ name: 'DataCleanup' })
const loading = ref(false)
const submitLoading = ref(false)
const triggering = ref(false)
const tableRef = ref()
const dialogVisible = ref(false)
const previewVisible = ref(false)
const logVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const formRef = ref<FormInstance>()
// 右键菜单
const contextMenuRef = ref<InstanceType<typeof ArtMenuRight>>()
const currentRow = ref<DataCleanupConfig | null>(null)
// 列配置
const columnOptions = [
{ label: '表名', prop: 'table_name' },
{ label: '配置说明', prop: 'description' },
{ label: '保留天数', prop: 'retention_days' },
{ label: '每批删除条数', prop: 'batch_size' },
{ label: '是否启用', prop: 'enabled' },
{ label: '创建时间', prop: 'created_at' },
{ label: '更新时间', prop: 'updated_at' }
]
// 验证规则
const rules: FormRules = {
table_name: [{ required: true, message: '请输入表名', trigger: 'blur' }],
retention_days: [
{ required: true, message: '请输入保留天数', trigger: 'blur' },
{ type: 'number', min: 7, message: '保留天数最少7天', trigger: 'blur' }
]
}
const form = reactive({
id: 0,
table_name: '',
description: '',
retention_days: 30,
batch_size: 10000,
enabled: 1
})
// 重置表单
const resetForm = () => {
form.id = 0
form.table_name = ''
form.description = ''
form.retention_days = 30
form.batch_size = 10000
form.enabled = 1
}
const configList = ref<DataCleanupConfig[]>([])
const previewData = ref<DataCleanupPreviewItem[]>([])
const logList = ref<DataCleanupLog[]>([])
const progress = ref<DataCleanupProgress | null>(null)
// 日志筛选和分页
const logFilter = reactive({
table_name: ''
})
const logPagination = reactive({
page: 1,
page_size: 10,
total: 0
})
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'table_name',
label: '表名',
width: 200,
fixed: 'left'
},
{
prop: 'description',
label: '配置说明',
minWidth: 180,
showOverflowTooltip: true
},
{
prop: 'retention_days',
label: '保留天数',
width: 100,
align: 'center'
},
{
prop: 'batch_size',
label: '每批删除条数',
width: 130,
align: 'right',
formatter: (row: DataCleanupConfig) => row.batch_size.toLocaleString()
},
{
prop: 'enabled',
label: '是否启用',
width: 100,
formatter: (row: DataCleanupConfig) => {
return h(ElTag, { type: row.enabled === 1 ? 'success' : 'info' }, () =>
row.enabled === 1 ? '启用' : '禁用'
)
}
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: DataCleanupConfig) => formatDateTime(row.created_at)
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: DataCleanupConfig) => formatDateTime(row.updated_at)
}
])
onMounted(() => {
getTableData()
getProgress()
// 每30秒刷新一次进度
setInterval(getProgress, 30000)
})
// 获取配置列表
const getTableData = async () => {
loading.value = true
try {
const res = await DataCleanupService.getDataCleanupConfigs()
if (res.code === 0) {
configList.value = res.data.items || []
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 获取进度
const getProgress = async () => {
try {
const res = await DataCleanupService.getDataCleanupProgress()
if (res.code === 0) {
progress.value = res.data
}
} catch (error) {
console.error(error)
}
}
// 刷新表格
const handleRefresh = () => {
getTableData()
getProgress()
}
// 显示创建对话框
const showCreateDialog = () => {
resetForm()
dialogType.value = 'add'
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
// 显示编辑对话框
const showEditDialog = async (row: DataCleanupConfig) => {
try {
// 先调用详情接口获取完整数据
const res = await DataCleanupService.getDataCleanupConfigById(row.id)
if (res.code === 0 && res.data) {
Object.assign(form, {
id: res.data.id,
table_name: res.data.table_name,
description: res.data.description || '',
retention_days: res.data.retention_days,
batch_size: res.data.batch_size,
enabled: res.data.enabled
})
dialogType.value = 'edit'
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
} catch (error) {
console.error('获取详情失败:', error)
ElMessage.error('获取配置详情失败')
}
}
// 对话框关闭
const handleDialogClosed = () => {
// 对话框关闭后不做任何操作
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true
try {
if (dialogType.value === 'add') {
const data: CreateDataCleanupConfigRequest = {
table_name: form.table_name,
retention_days: form.retention_days,
batch_size: form.batch_size || undefined,
description: form.description || undefined
}
await DataCleanupService.createDataCleanupConfig(data)
ElMessage.success('创建成功')
} else {
const data: UpdateDataCleanupConfigRequest = {
retention_days: form.retention_days,
batch_size: form.batch_size,
description: form.description || undefined,
enabled: form.enabled
}
await DataCleanupService.updateDataCleanupConfig(form.id, data)
ElMessage.success('更新成功')
}
dialogVisible.value = false
await getTableData()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
})
}
// 删除配置
const handleDelete = async (row: DataCleanupConfig) => {
try {
await ElMessageBox.confirm(`确定要删除配置"${row.table_name}"吗?`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await DataCleanupService.deleteDataCleanupConfig(row.id)
ElMessage.success('删除成功')
await getTableData()
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
}
}
// 预览清理
const handlePreview = async () => {
try {
const res = await DataCleanupService.previewDataCleanup()
if (res.code === 0) {
previewData.value = res.data.items || []
previewVisible.value = true
}
} catch (error) {
console.error(error)
}
}
// 手动触发清理
const handleTriggerCleanup = async () => {
try {
await ElMessageBox.confirm('确定要手动触发数据清理吗?', '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
triggering.value = true
await DataCleanupService.triggerDataCleanup()
ElMessage.success('已触发清理任务')
await getProgress()
} catch (error) {
if (error !== 'cancel') {
console.error(error)
}
} finally {
triggering.value = false
}
}
// 查看日志
const handleViewLogs = () => {
logFilter.table_name = ''
logPagination.page = 1
logVisible.value = true
getCleanupLogs()
}
// 获取清理日志
const getCleanupLogs = async () => {
try {
const res = await DataCleanupService.getDataCleanupLogs({
table_name: logFilter.table_name || undefined,
page: logPagination.page,
page_size: logPagination.page_size
})
if (res.code === 0) {
logList.value = res.data.items || []
logPagination.total = res.data.total || 0
}
} catch (error) {
console.error(error)
}
}
// 右键菜单项
const contextMenuItems = computed((): MenuItemType[] => {
return [
{ key: 'edit', label: '编辑' },
{ key: 'logs', label: '查看日志' },
{ key: 'delete', label: '删除' }
]
})
// 处理表格行右键菜单
const handleRowContextMenu = (row: DataCleanupConfig, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentRow.value = row
contextMenuRef.value?.show(event)
}
// 处理右键菜单选择
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentRow.value) return
switch (item.key) {
case 'edit':
showEditDialog(currentRow.value)
break
case 'logs':
logFilter.table_name = currentRow.value.table_name
logPagination.page = 1
logVisible.value = true
getCleanupLogs()
break
case 'delete':
handleDelete(currentRow.value)
break
}
}
// 使用表格右键菜单功能
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
</script>
<style scoped lang="scss">
.data-cleanup-page {
height: 100%;
}
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.text-danger {
color: var(--el-color-danger);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,763 @@
<template>
<ArtTableFullScreen>
<div class="manual-trigger-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
label-width="100"
@reset="handleReset"
@search="handleSearch"
/>
<el-card shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<el-button
type="primary"
@click="showBatchTriggerDialog"
v-permission="'polling_manual_trigger_batch'"
>
批量触发
</el-button>
<el-button
type="success"
@click="showConditionTriggerDialog"
v-permission="'polling_manual_trigger_condition'"
>
条件触发
</el-button>
<el-button
type="info"
@click="showSingleTriggerDialog"
v-permission="'polling_manual_trigger_single'"
>
单卡触发
</el-button>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.page"
:pageSize="pagination.page_size"
:total="pagination.total"
:marginTop="10"
:row-class-name="getRowClassName"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
@row-contextmenu="handleRowContextMenu"
@cell-mouse-enter="handleCellMouseEnter"
@cell-mouse-leave="handleCellMouseLeave"
>
<template #default>
<el-table-column v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
<!-- 鼠标悬浮提示 -->
<TableContextMenuHint :visible="showContextMenuHint" :position="hintPosition" />
<!-- 右键菜单 -->
<ArtMenuRight
ref="contextMenuRef"
:menu-items="contextMenuItems"
:menu-width="120"
@select="handleContextMenuSelect"
/>
</el-card>
<!-- 批量触发弹窗 -->
<el-dialog
v-model="batchDialogVisible"
title="批量触发"
width="600px"
:close-on-click-modal="false"
>
<el-form ref="batchFormRef" :model="batchForm" :rules="batchRules" label-width="120px">
<el-form-item label="任务类型" prop="task_type">
<el-select v-model="batchForm.task_type" placeholder="请选择任务类型" style="width: 100%">
<el-option label="实名认证轮询" value="polling:realname" />
<el-option label="卡数据轮询" value="polling:carddata" />
<el-option label="套餐轮询" value="polling:package" />
</el-select>
</el-form-item>
<el-form-item label="卡ID列表" prop="card_ids">
<el-input
v-model="batchForm.card_ids_text"
type="textarea"
:rows="5"
placeholder="请输入卡ID多个ID用逗号或换行分隔最多1000个"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 条件触发弹窗 -->
<el-dialog
v-model="conditionDialogVisible"
title="条件触发"
width="700px"
:close-on-click-modal="false"
>
<el-form
ref="conditionFormRef"
:model="conditionForm"
:rules="conditionRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="任务类型" prop="task_type">
<el-select
v-model="conditionForm.task_type"
placeholder="请选择任务类型"
style="width: 100%"
>
<el-option label="实名认证轮询" value="polling:realname" />
<el-option label="卡数据轮询" value="polling:carddata" />
<el-option label="套餐轮询" value="polling:package" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="限制数量" prop="limit">
<el-input-number
v-model="conditionForm.limit"
:min="1"
:max="1000"
placeholder="最多1000"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="卡状态">
<el-select
v-model="conditionForm.card_status"
placeholder="请选择卡状态"
clearable
style="width: 100%"
>
<el-option label="未激活" value="inactive" />
<el-option label="已激活" value="activated" />
<el-option label="已停用" value="suspended" />
<el-option label="已注销" value="terminated" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卡类型">
<el-select
v-model="conditionForm.card_type"
placeholder="请选择卡类型"
clearable
style="width: 100%"
>
<el-option label="普通卡" value="normal" />
<el-option label="行业卡" value="industry" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="运营商">
<el-select
v-model="conditionForm.carrier_code"
placeholder="请选择运营商"
filterable
remote
:remote-method="remoteSearchCarrier"
clearable
no-data-text="暂无运营商数据"
style="width: 100%"
>
<el-option
v-for="carrier in carrierList"
:key="carrier.id"
:label="carrier.carrier_name"
:value="carrier.carrier_code"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="店铺">
<el-tree-select
v-model="conditionForm.shop_id"
:data="shopTreeData"
placeholder="请选择店铺"
filterable
clearable
check-strictly
:render-after-expand="false"
no-data-text="暂无店铺数据"
:props="{
label: 'shop_name',
value: 'id',
children: 'children'
}"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="启用轮询">
<el-switch v-model="conditionForm.enable_polling" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="conditionDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConditionSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 单卡触发弹窗 -->
<el-dialog
v-model="singleDialogVisible"
title="单卡触发"
width="500px"
:close-on-click-modal="false"
>
<el-form ref="singleFormRef" :model="singleForm" :rules="singleRules" label-width="100px">
<el-form-item label="卡ICCID" prop="card_id">
<el-select
v-model="singleForm.card_id"
placeholder="请输入ICCID搜索"
filterable
remote
:remote-method="remoteSearchCard"
clearable
no-data-text="暂无卡数据请输入ICCID搜索"
style="width: 100%"
>
<el-option
v-for="card in cardList"
:key="card.id"
:label="card.iccid"
:value="card.id"
/>
</el-select>
</el-form-item>
<el-form-item label="任务类型" prop="task_type">
<el-select v-model="singleForm.task_type" placeholder="请选择任务类型" style="width: 100%">
<el-option label="实名认证轮询" value="polling:realname" />
<el-option label="卡数据轮询" value="polling:carddata" />
<el-option label="套餐轮询" value="polling:package" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="singleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSingleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, h } from 'vue'
import { ElMessage, ElMessageBox, ElTag, type FormInstance, type FormRules } from 'element-plus'
import { ManualTriggerService, CarrierService, ShopService, CardService } from '@/api/modules'
import type {
ManualTriggerLog,
ManualTriggerHistoryQueryParams,
BatchManualTriggerRequest,
ConditionManualTriggerRequest,
SingleManualTriggerRequest,
TaskType
} from '@/types/api'
import type { SearchFormItem } from '@/types'
import { formatDateTime } from '@/utils/business/format'
import { useTableContextMenu } from '@/composables/useTableContextMenu'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import type { MenuItemType } from '@/components/core/others/ArtMenuRight.vue'
const loading = ref(false)
const tableData = ref<ManualTriggerLog[]>([])
const tableRef = ref()
const contextMenuRef = ref()
const currentClickRow = ref<ManualTriggerLog | null>(null)
const batchDialogVisible = ref(false)
const conditionDialogVisible = ref(false)
const singleDialogVisible = ref(false)
const batchFormRef = ref<FormInstance>()
const conditionFormRef = ref<FormInstance>()
const singleFormRef = ref<FormInstance>()
const carrierList = ref<any[]>([])
const shopTreeData = ref<any[]>([])
const cardList = ref<any[]>([])
// 分页数据
const pagination = reactive({
page: 1,
page_size: 10,
total: 0
})
// 搜索表单
const searchForm = reactive({
task_type: undefined as TaskType | undefined
})
// 批量触发表单
const batchForm = reactive({
task_type: '' as TaskType | '',
card_ids_text: ''
})
// 条件触发表单
const conditionForm = reactive<ConditionManualTriggerRequest>({
task_type: '' as TaskType | '',
limit: 100,
card_status: undefined,
card_type: undefined,
carrier_code: undefined,
enable_polling: undefined,
package_ids: undefined,
shop_id: undefined
})
// 单卡触发表单
const singleForm = reactive({
card_id: undefined as number | undefined,
task_type: '' as TaskType | ''
})
const batchRules: FormRules = {
task_type: [{ required: true, message: '请选择任务类型', trigger: 'change' }]
}
const conditionRules: FormRules = {
task_type: [{ required: true, message: '请选择任务类型', trigger: 'change' }],
limit: [{ required: true, message: '请输入限制数量', trigger: 'blur' }]
}
const singleRules: FormRules = {
card_id: [{ required: true, message: '请输入卡ID', trigger: 'blur' }],
task_type: [{ required: true, message: '请选择任务类型', trigger: 'change' }]
}
const {
showContextMenuHint,
hintPosition,
getRowClassName,
handleCellMouseEnter,
handleCellMouseLeave
} = useTableContextMenu()
// 搜索表单配置
const searchFormItems = computed<SearchFormItem[]>(() => [
{
label: '任务类型',
prop: 'task_type',
type: 'select',
config: {
clearable: true,
placeholder: '请选择任务类型'
},
options: () => [
{ label: '实名认证轮询', value: 'polling:realname' },
{ label: '卡数据轮询', value: 'polling:carddata' },
{ label: '套餐轮询', value: 'polling:package' }
]
}
])
const getStatusTag = (status: string) => {
const typeMap: Record<string, 'info' | 'warning' | 'success' | 'danger'> = {
pending: 'info',
processing: 'warning',
completed: 'success',
cancelled: 'danger'
}
return typeMap[status] || 'info'
}
const getProgressPercentage = (row: ManualTriggerLog) => {
if (row.total_count === 0) return 0
return Math.round((row.processed_count / row.total_count) * 100)
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_type_name',
label: '任务类型',
minWidth: 150
},
{
prop: 'trigger_type_name',
label: '触发类型',
minWidth: 120
},
{
prop: 'status',
label: '状态',
minWidth: 100,
slots: {
default: ({ row }: any) =>
h(ElTag, { type: getStatusTag(row.status) }, () => row.status_name)
}
},
{
label: '进度',
minWidth: 200,
slots: {
default: ({ row }: any) =>
h('div', { style: 'display: flex; align-items: center; gap: 8px;' }, [
h('span', { style: 'font-size: 12px;' }, `${row.processed_count}/${row.total_count}`),
h('el-progress', {
percentage: getProgressPercentage(row),
'stroke-width': 6,
'show-text': false,
style: 'flex: 1;'
})
])
}
},
{
prop: 'success_count',
label: '成功数',
minWidth: 100
},
{
prop: 'failed_count',
label: '失败数',
minWidth: 100
},
{
prop: 'triggered_at',
label: '触发时间',
minWidth: 180,
formatter: (row: any) => formatDateTime(row.triggered_at)
},
{
prop: 'completed_at',
label: '完成时间',
minWidth: 180,
formatter: (row: any) => (row.completed_at ? formatDateTime(row.completed_at) : '-')
}
])
const columnOptions = computed(() =>
columns.value.map((col) => ({
label: col.label,
prop: col.prop || col.label,
visible: true
}))
)
const contextMenuItems = computed<MenuItemType[]>(() => {
const items: MenuItemType[] = [{ label: '刷新', key: 'refresh' }]
if (
currentClickRow.value &&
(currentClickRow.value.status === 'pending' || currentClickRow.value.status === 'processing')
) {
items.push({
label: '取消任务',
key: 'cancel',
permission: 'polling_manual_trigger_cancel'
})
}
return items
})
const loadData = async () => {
loading.value = true
try {
const queryParams: ManualTriggerHistoryQueryParams = {
page: pagination.page,
page_size: pagination.page_size,
...searchForm
}
const { data } = await ManualTriggerService.getHistory(queryParams)
tableData.value = data.items
pagination.total = data.total
} catch (error) {
ElMessage.error('加载触发历史失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
loadData()
}
const handleReset = () => {
Object.assign(searchForm, {
task_type: undefined
})
pagination.page = 1
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleSizeChange = (size: number) => {
pagination.page_size = size
loadData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
loadData()
}
const showBatchTriggerDialog = () => {
Object.assign(batchForm, {
task_type: '',
card_ids_text: ''
})
batchDialogVisible.value = true
nextTick(() => {
batchFormRef.value?.clearValidate()
})
}
const showConditionTriggerDialog = () => {
Object.assign(conditionForm, {
task_type: '',
limit: 100,
card_status: undefined,
card_type: undefined,
carrier_code: undefined,
enable_polling: undefined,
package_ids: undefined,
shop_id: undefined
})
conditionDialogVisible.value = true
nextTick(() => {
conditionFormRef.value?.clearValidate()
})
}
const showSingleTriggerDialog = () => {
Object.assign(singleForm, {
card_id: undefined,
task_type: ''
})
singleDialogVisible.value = true
loadCards() // 加载初始卡列表
nextTick(() => {
singleFormRef.value?.clearValidate()
})
}
const handleBatchSubmit = async () => {
if (!batchFormRef.value) return
await batchFormRef.value.validate(async (valid) => {
if (!valid) return
try {
// 解析卡ID列表
const cardIds = batchForm.card_ids_text
.split(/[,\n]/)
.map((id) => id.trim())
.filter((id) => id)
.map((id) => parseInt(id))
.filter((id) => !isNaN(id))
if (cardIds.length > 1000) {
ElMessage.warning('卡ID数量不能超过1000个')
return
}
const requestData: BatchManualTriggerRequest = {
task_type: batchForm.task_type as TaskType,
card_ids: cardIds.length > 0 ? cardIds : undefined
}
await ManualTriggerService.batchTrigger(requestData)
ElMessage.success('批量触发成功')
batchDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('批量触发失败')
}
})
}
const handleConditionSubmit = async () => {
if (!conditionFormRef.value) return
await conditionFormRef.value.validate(async (valid) => {
if (!valid) return
try {
const requestData: ConditionManualTriggerRequest = {
task_type: conditionForm.task_type as TaskType,
limit: conditionForm.limit,
card_status: conditionForm.card_status,
card_type: conditionForm.card_type,
carrier_code: conditionForm.carrier_code,
enable_polling: conditionForm.enable_polling,
package_ids: conditionForm.package_ids,
shop_id: conditionForm.shop_id
}
await ManualTriggerService.conditionTrigger(requestData)
ElMessage.success('条件触发成功')
conditionDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('条件触发失败')
}
})
}
const handleSingleSubmit = async () => {
if (!singleFormRef.value) return
await singleFormRef.value.validate(async (valid) => {
if (!valid) return
try {
await ManualTriggerService.singleTrigger({
card_id: singleForm.card_id!,
task_type: singleForm.task_type as TaskType
})
ElMessage.success('单卡触发成功')
singleDialogVisible.value = false
loadData()
} catch (error) {
ElMessage.error('单卡触发失败')
}
})
}
const handleCancelTask = async (row: ManualTriggerLog) => {
try {
await ElMessageBox.confirm('确定要取消该任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await ManualTriggerService.cancelTrigger({ trigger_id: row.id })
ElMessage.success('任务已取消')
loadData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('取消任务失败')
}
}
}
const handleRowContextMenu = (row: ManualTriggerLog, column: any, event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
currentClickRow.value = row
contextMenuRef.value?.show(event)
}
const handleContextMenuSelect = (item: MenuItemType) => {
if (!currentClickRow.value) return
switch (item.key) {
case 'refresh':
loadData()
break
case 'cancel':
handleCancelTask(currentClickRow.value)
break
}
}
// 加载运营商列表
const loadCarriers = async (carrierName?: string) => {
try {
const { data } = await CarrierService.getCarriers({
page: 1,
page_size: 20,
carrier_name: carrierName
})
carrierList.value = data.items || data.list || []
} catch (error) {
console.error('加载运营商列表失败', error)
}
}
const remoteSearchCarrier = (query: string) => {
if (query) {
loadCarriers(query)
} else {
loadCarriers()
}
}
// 加载店铺树形数据
const loadShops = async () => {
try {
const { data } = await ShopService.getShops()
shopTreeData.value = data.items || data.list || []
} catch (error) {
console.error('加载店铺列表失败', error)
}
}
// 加载卡列表
const loadCards = async (iccid?: string) => {
try {
const { data } = await CardService.getStandaloneIotCards({
page: 1,
page_size: 20,
iccid: iccid
})
cardList.value = data.items || data.list || []
} catch (error) {
console.error('加载卡列表失败', error)
}
}
const remoteSearchCard = (query: string) => {
if (query) {
loadCards(query)
} else {
loadCards()
}
}
onMounted(() => {
loadData()
loadCarriers()
loadShops()
})
</script>
<style scoped lang="scss">
.manual-trigger-page {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="polling-monitor-page">
<!-- 总览统计卡片 -->
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :md="8" :lg="24 / 5" :xl="24 / 5">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon total">
<el-icon :size="32"><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">总卡数</div>
<div class="stat-value">{{ overviewStats?.total_cards || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="24 / 5" :xl="24 / 5">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon initialized">
<el-icon :size="32"><CircleCheck /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">已初始化</div>
<div class="stat-value">{{ overviewStats?.initialized_cards || 0 }}</div>
<el-progress
v-if="overviewStats"
:percentage="overviewStats.init_progress"
:stroke-width="6"
:show-text="false"
style="margin-top: 8px"
/>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="24 / 5" :xl="24 / 5">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon realname">
<el-icon :size="32"><User /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">实名队列</div>
<div class="stat-value">{{ overviewStats?.realname_queue_size || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="24 / 5" :xl="24 / 5">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon package">
<el-icon :size="32"><Box /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">套餐队列</div>
<div class="stat-value">{{ overviewStats?.package_queue_size || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="24 / 5" :xl="24 / 5">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon carddata">
<el-icon :size="32"><DataLine /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">流量队列</div>
<div class="stat-value">{{ overviewStats?.carddata_queue_size || 0 }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 队列状态 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span>队列状态</span>
<el-button size="small" @click="loadQueueStatus">刷新</el-button>
</div>
</template>
<el-table :data="queueStatusList" border stripe>
<el-table-column prop="task_type_name" label="任务类型" min-width="120" />
<el-table-column prop="queue_size" label="队列大小" min-width="100" />
<el-table-column prop="due_count" label="到期待处理" min-width="110" />
<el-table-column prop="manual_pending" label="手动待处理" min-width="110" />
<el-table-column label="平均等待时间" min-width="120">
<template #default="{ row }">
{{ row.avg_wait_time_s ? row.avg_wait_time_s.toFixed(1) + 's' : '-' }}
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 任务统计 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span>任务统计最近1小时</span>
<el-button size="small" @click="loadTaskStats">刷新</el-button>
</div>
</template>
<el-table :data="taskStatsList" border stripe>
<el-table-column prop="task_type_name" label="任务类型" min-width="120" />
<el-table-column prop="total_count_1h" label="总数" min-width="100" />
<el-table-column prop="success_count_1h" label="成功数" min-width="100" />
<el-table-column prop="failure_count_1h" label="失败数" min-width="100" />
<el-table-column label="成功率" min-width="120">
<template #default="{ row }">
<el-progress
:percentage="row.success_rate"
:stroke-width="8"
:color="getSuccessRateColor(row.success_rate)"
>
<span style="font-size: 12px">{{ row.success_rate.toFixed(2) }}%</span>
</el-progress>
</template>
</el-table-column>
<el-table-column label="平均耗时" min-width="120">
<template #default="{ row }">
{{ row.avg_duration_ms ? row.avg_duration_ms.toFixed(1) + 'ms' : '-' }}
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 初始化进度 -->
<el-card shadow="never" class="section-card" v-if="initProgress && !initProgress.is_complete">
<template #header>
<div class="card-header">
<span>初始化进度</span>
<el-button size="small" @click="loadInitProgress">刷新</el-button>
</div>
</template>
<div class="init-progress-content">
<div class="progress-info">
<div class="info-item">
<span class="label">总卡数</span>
<span class="value">{{ initProgress.total_cards }}</span>
</div>
<div class="info-item">
<span class="label">已初始化</span>
<span class="value">{{ initProgress.initialized_cards }}</span>
</div>
<div class="info-item">
<span class="label">开始时间</span>
<span class="value">{{ formatDateTime(initProgress.started_at) }}</span>
</div>
<div class="info-item">
<span class="label">预计完成</span>
<span class="value">{{ formatDateTime(initProgress.estimated_eta) }}</span>
</div>
</div>
<el-progress
:percentage="initProgress.progress"
:stroke-width="20"
:color="getProgressColor(initProgress.progress)"
>
<span style="font-size: 14px; font-weight: bold">{{ initProgress.progress.toFixed(1) }}%</span>
</el-progress>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, CircleCheck, User, Box, DataLine } from '@element-plus/icons-vue'
import { PollingMonitorService } from '@/api/modules'
import type {
PollingOverviewStats,
PollingInitProgress,
PollingQueueStatus,
PollingTaskStats
} from '@/types/api'
import { formatDateTime } from '@/utils/business/format'
const overviewStats = ref<PollingOverviewStats>()
const initProgress = ref<PollingInitProgress>()
const queueStatusList = ref<PollingQueueStatus[]>([])
const taskStatsList = ref<PollingTaskStats[]>([])
let refreshTimer: NodeJS.Timeout | null = null
// 加载总览统计
const loadOverviewStats = async () => {
try {
const { data } = await PollingMonitorService.getOverviewStats()
overviewStats.value = data
} catch (error) {
ElMessage.error('加载总览统计失败')
}
}
// 加载初始化进度
const loadInitProgress = async () => {
try {
const { data } = await PollingMonitorService.getInitProgress()
initProgress.value = data
} catch (error) {
console.error('加载初始化进度失败', error)
}
}
// 加载队列状态
const loadQueueStatus = async (showMessage = false) => {
try {
const { data } = await PollingMonitorService.getQueueStatus()
queueStatusList.value = data.items
if (showMessage) {
ElMessage.success('队列状态已刷新')
}
} catch (error) {
ElMessage.error('加载队列状态失败')
}
}
// 加载任务统计
const loadTaskStats = async (showMessage = false) => {
try {
const { data } = await PollingMonitorService.getTaskStats()
taskStatsList.value = data.items
if (showMessage) {
ElMessage.success('任务统计已刷新')
}
} catch (error) {
ElMessage.error('加载任务统计失败')
}
}
// 加载所有数据
const loadAllData = () => {
loadOverviewStats()
loadInitProgress()
loadQueueStatus()
loadTaskStats()
}
// 获取成功率颜色
const getSuccessRateColor = (rate: number) => {
if (rate >= 95) return '#67c23a'
if (rate >= 85) return '#e6a23c'
return '#f56c6c'
}
// 获取进度颜色
const getProgressColor = (progress: number) => {
if (progress >= 90) return '#67c23a'
if (progress >= 50) return '#409eff'
return '#e6a23c'
}
onMounted(() => {
loadAllData()
// 每30秒自动刷新
refreshTimer = setInterval(() => {
loadAllData()
}, 30000)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
})
</script>
<style scoped lang="scss">
.polling-monitor-page {
padding: 20px;
.stats-row {
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 64px;
height: 64px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
&.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.initialized {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.realname {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.package {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
&.carddata {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
}
.stat-content {
flex: 1;
.stat-label {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: var(--el-text-color-primary);
}
}
}
.section-card {
margin-bottom: 20px;
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
color: var(--el-text-color-primary);
}
}
.init-progress-content {
.progress-info {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
.info-item {
.label {
color: var(--el-text-color-secondary);
font-size: 14px;
}
.value {
color: var(--el-text-color-primary);
font-weight: 500;
margin-left: 8px;
}
}
}
}
}
</style>

View File

@@ -60,10 +60,11 @@
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增支付配置' : '编辑支付配置'"
width="800px"
width="900px"
:close-on-click-modal="false"
@closed="handleDialogClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="130px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="140px" class="config-form">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="配置名称" prop="name">
@@ -77,6 +78,7 @@
placeholder="请选择支付渠道类型"
style="width: 100%"
:disabled="dialogType === 'edit'"
@change="handleProviderTypeChange"
>
<ElOption label="微信直连" value="wechat" />
<ElOption label="富友支付" value="fuiou" />
@@ -95,11 +97,17 @@
<!-- 微信相关配置 -->
<template v-if="form.provider_type === 'wechat'">
<ElDivider content-position="left">小程序配置</ElDivider>
<ElDivider content-position="left">
<span class="divider-title">小程序配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="小程序AppID" prop="miniapp_app_id">
<ElInput v-model="form.miniapp_app_id" placeholder="请输入小程序AppID" />
<ElInput
v-model="form.miniapp_app_id"
placeholder="请输入小程序AppID"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
@@ -114,11 +122,17 @@
</ElCol>
</ElRow>
<ElDivider content-position="left">公众号配置</ElDivider>
<ElDivider content-position="left">
<span class="divider-title">公众号配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="公众号AppID" prop="oa_app_id">
<ElInput v-model="form.oa_app_id" placeholder="请输入公众号AppID" />
<ElInput
v-model="form.oa_app_id"
placeholder="请输入公众号AppID"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
@@ -155,19 +169,33 @@
</ElCol>
</ElRow>
<ElFormItem label="OAuth回调地址" prop="oa_oauth_redirect_url">
<ElInput v-model="form.oa_oauth_redirect_url" placeholder="请输入OAuth回调地址" />
<ElInput
v-model="form.oa_oauth_redirect_url"
placeholder="请输入OAuth回调地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElDivider content-position="left">微信支付配置</ElDivider>
<ElDivider content-position="left">
<span class="divider-title">微信支付配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="微信商户号" prop="wx_mch_id">
<ElInput v-model="form.wx_mch_id" placeholder="请输入微信商户号" />
<ElInput
v-model="form.wx_mch_id"
placeholder="请输入微信商户号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="证书序列号" prop="wx_serial_no">
<ElInput v-model="form.wx_serial_no" placeholder="请输入微信证书序列号" />
<ElInput
v-model="form.wx_serial_no"
placeholder="请输入微信证书序列号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
</ElRow>
@@ -194,7 +222,11 @@
</ElCol>
</ElRow>
<ElFormItem label="支付回调地址" prop="wx_notify_url">
<ElInput v-model="form.wx_notify_url" placeholder="请输入微信支付回调地址" />
<ElInput
v-model="form.wx_notify_url"
placeholder="请输入微信支付回调地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElRow :gutter="20">
<ElCol :span="12">
@@ -222,33 +254,207 @@
<!-- 富友支付配置 -->
<template v-if="form.provider_type === 'fuiou'">
<ElDivider content-position="left">富友支付配置</ElDivider>
<ElDivider content-position="left">小程序配置</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="小程序AppID" prop="miniapp_app_id">
<ElInput
v-model="form.miniapp_app_id"
placeholder="请输入小程序AppID"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="小程序AppSecret" prop="miniapp_app_secret">
<ElInput
v-model="form.miniapp_app_secret"
type="password"
show-password
placeholder="请输入小程序AppSecret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">
<span class="divider-title">公众号配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="公众号AppID" prop="oa_app_id">
<ElInput
v-model="form.oa_app_id"
placeholder="请输入公众号AppID"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="公众号AppSecret" prop="oa_app_secret">
<ElInput
v-model="form.oa_app_secret"
type="password"
show-password
placeholder="请输入公众号AppSecret"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="公众号Token" prop="oa_token">
<ElInput
v-model="form.oa_token"
type="password"
show-password
placeholder="请输入公众号Token"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="公众号AES Key" prop="oa_aes_key">
<ElInput
v-model="form.oa_aes_key"
type="password"
show-password
placeholder="请输入公众号AES加密Key"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="OAuth回调地址" prop="oa_oauth_redirect_url">
<ElInput
v-model="form.oa_oauth_redirect_url"
placeholder="请输入OAuth回调地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElDivider content-position="left">
<span class="divider-title">微信支付配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="微信商户号" prop="wx_mch_id">
<ElInput
v-model="form.wx_mch_id"
placeholder="请输入微信商户号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="证书序列号" prop="wx_serial_no">
<ElInput
v-model="form.wx_serial_no"
placeholder="请输入微信证书序列号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="APIv2密钥" prop="wx_api_v2_key">
<ElInput
v-model="form.wx_api_v2_key"
type="password"
show-password
placeholder="请输入微信APIv2密钥"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="APIv3密钥" prop="wx_api_v3_key">
<ElInput
v-model="form.wx_api_v3_key"
type="password"
show-password
placeholder="请输入微信APIv3密钥"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="支付回调地址" prop="wx_notify_url">
<ElInput
v-model="form.wx_notify_url"
placeholder="请输入微信支付回调地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="支付证书" prop="wx_cert_content">
<ElInput
v-model="form.wx_cert_content"
type="textarea"
:rows="3"
placeholder="请粘贴PEM格式证书内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="支付密钥" prop="wx_key_content">
<ElInput
v-model="form.wx_key_content"
type="textarea"
:rows="3"
placeholder="请粘贴PEM格式密钥内容"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElDivider content-position="left">
<span class="divider-title">富友支付配置</span>
</ElDivider>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="富友API地址" prop="fy_api_url">
<ElInput v-model="form.fy_api_url" placeholder="请输入富友API地址" />
<ElInput
v-model="form.fy_api_url"
placeholder="请输入富友API地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="富友机构号" prop="fy_ins_cd">
<ElInput v-model="form.fy_ins_cd" placeholder="请输入富友机构号" />
<ElInput
v-model="form.fy_ins_cd"
placeholder="请输入富友机构号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="富友商户号" prop="fy_mchnt_cd">
<ElInput v-model="form.fy_mchnt_cd" placeholder="请输入富友商户号" />
<ElInput
v-model="form.fy_mchnt_cd"
placeholder="请输入富友商户号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="富友终端号" prop="fy_term_id">
<ElInput v-model="form.fy_term_id" placeholder="请输入富友终端号" />
<ElInput
v-model="form.fy_term_id"
placeholder="请输入富友终端号"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
</ElCol>
</ElRow>
<ElFormItem label="支付回调地址" prop="fy_notify_url">
<ElInput v-model="form.fy_notify_url" placeholder="请输入富友支付回调地址" />
<ElInput
v-model="form.fy_notify_url"
placeholder="请输入富友支付回调地址"
:disabled="dialogType === 'edit'"
/>
</ElFormItem>
<ElRow :gutter="20">
<ElCol :span="12">
@@ -289,7 +495,7 @@
</template>
<script setup lang="ts">
import { h } from 'vue'
import { h, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { WechatConfigService } from '@/api/modules'
import { ElMessage, ElTag, ElMessageBox, ElSwitch, ElButton } from 'element-plus'
@@ -387,51 +593,73 @@
{ label: '更新时间', prop: 'updated_at' }
]
// 动态表单验证规则
const rules = computed<FormRules>(() => {
const baseRules: FormRules = {
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
provider_type: [{ required: true, message: '请选择支付渠道类型', trigger: 'change' }]
// URL格式验证规则
const urlValidator = (rule: any, value: any, callback: any) => {
if (!value) {
callback()
return
}
const urlPattern = /^(https?:\/\/)[\w\-]+(\.[\w\-]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?$/
if (!urlPattern.test(value)) {
callback(new Error('请输入正确的URL格式(http://或https://)'))
} else {
callback()
}
}
// 只在创建模式下添加必填验证
if (dialogType.value === 'add') {
if (form.provider_type === 'wechat') {
// 微信直连必填字段
baseRules.wx_mch_id = [{ required: true, message: '请输入微信商户号', trigger: 'blur' }]
baseRules.wx_api_v3_key = [
{ required: true, message: '请输入微信APIv3密钥', trigger: 'blur' }
]
baseRules.wx_cert_content = [
{ required: true, message: '请输入微信支付证书内容', trigger: 'blur' }
]
baseRules.wx_key_content = [
{ required: true, message: '请输入微信支付密钥内容', trigger: 'blur' }
]
baseRules.wx_serial_no = [
{ required: true, message: '请输入微信证书序列号', trigger: 'blur' }
]
baseRules.wx_notify_url = [
{ required: true, message: '请输入微信支付回调地址', trigger: 'blur' }
]
} else if (form.provider_type === 'fuiou') {
// 富友支付必填字段
baseRules.fy_api_url = [{ required: true, message: '请输入富友API地址', trigger: 'blur' }]
baseRules.fy_ins_cd = [{ required: true, message: '请输入富友机构号', trigger: 'blur' }]
baseRules.fy_mchnt_cd = [{ required: true, message: '请输入富友商户号', trigger: 'blur' }]
baseRules.fy_term_id = [{ required: true, message: '请输入富友终端号', trigger: 'blur' }]
baseRules.fy_private_key = [{ required: true, message: '请输入富友私钥', trigger: 'blur' }]
baseRules.fy_public_key = [{ required: true, message: '请输入富友公钥', trigger: 'blur' }]
baseRules.fy_notify_url = [
{ required: true, message: '请输入富友支付回调地址', trigger: 'blur' }
]
// 自定义验证器 - 编辑模式下允许为空,新增模式下必填
const createRequiredValidator = (fieldName: string) => {
return (rule: any, value: any, callback: any) => {
if (dialogType.value === 'add' && !value) {
callback(new Error(`请输入${fieldName}`))
} else {
callback()
}
}
}
return baseRules
// 静态验证规则 - 一次性定义所有规则
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
provider_type: [{ required: true, message: '请选择支付渠道类型', trigger: 'change' }],
// 所有字段的验证规则都定义好
miniapp_app_id: [{ required: true, validator: createRequiredValidator('小程序AppID'), trigger: 'blur' }],
miniapp_app_secret: [{ required: true, validator: createRequiredValidator('小程序AppSecret'), trigger: 'blur' }],
oa_app_id: [{ required: true, validator: createRequiredValidator('公众号AppID'), trigger: 'blur' }],
oa_app_secret: [{ required: true, validator: createRequiredValidator('公众号AppSecret'), trigger: 'blur' }],
oa_token: [{ required: true, validator: createRequiredValidator('公众号Token'), trigger: 'blur' }],
oa_aes_key: [{ required: true, validator: createRequiredValidator('公众号AES Key'), trigger: 'blur' }],
oa_oauth_redirect_url: [
{ required: true, validator: createRequiredValidator('OAuth回调地址'), trigger: 'blur' },
{ validator: urlValidator, trigger: 'blur' }
],
wx_mch_id: [{ required: true, validator: createRequiredValidator('微信商户号'), trigger: 'blur' }],
wx_api_v2_key: [{ required: true, validator: createRequiredValidator('微信APIv2密钥'), trigger: 'blur' }],
wx_api_v3_key: [{ required: true, validator: createRequiredValidator('微信APIv3密钥'), trigger: 'blur' }],
wx_serial_no: [{ required: true, validator: createRequiredValidator('微信证书序列号'), trigger: 'blur' }],
wx_cert_content: [{ required: true, validator: createRequiredValidator('微信支付证书内容'), trigger: 'blur' }],
wx_key_content: [{ required: true, validator: createRequiredValidator('微信支付密钥内容'), trigger: 'blur' }],
wx_notify_url: [
{ required: true, validator: createRequiredValidator('微信支付回调地址'), trigger: 'blur' },
{ validator: urlValidator, trigger: 'blur' }
],
fy_api_url: [
{ required: true, validator: createRequiredValidator('富友API地址'), trigger: 'blur' },
{ validator: urlValidator, trigger: 'blur' }
],
fy_ins_cd: [{ required: true, validator: createRequiredValidator('富友机构号'), trigger: 'blur' }],
fy_mchnt_cd: [{ required: true, validator: createRequiredValidator('富友商户号'), trigger: 'blur' }],
fy_term_id: [{ required: true, validator: createRequiredValidator('富友终端号'), trigger: 'blur' }],
fy_private_key: [{ required: true, validator: createRequiredValidator('富友私钥'), trigger: 'blur' }],
fy_public_key: [{ required: true, validator: createRequiredValidator('富友公钥'), trigger: 'blur' }],
fy_notify_url: [
{ required: true, validator: createRequiredValidator('富友支付回调地址'), trigger: 'blur' },
{ validator: urlValidator, trigger: 'blur' }
]
})
const initialFormState = {
const form = reactive({
id: 0,
name: '',
provider_type: 'wechat' as PaymentProviderType,
description: '',
@@ -456,9 +684,36 @@
fy_notify_url: '',
fy_private_key: '',
fy_public_key: ''
}
})
const form = reactive({ id: 0, ...initialFormState })
// 重置表单数据
const resetForm = () => {
form.id = 0
form.name = ''
form.provider_type = 'wechat'
form.description = ''
form.miniapp_app_id = ''
form.miniapp_app_secret = ''
form.oa_app_id = ''
form.oa_app_secret = ''
form.oa_token = ''
form.oa_aes_key = ''
form.oa_oauth_redirect_url = ''
form.wx_mch_id = ''
form.wx_api_v2_key = ''
form.wx_api_v3_key = ''
form.wx_notify_url = ''
form.wx_serial_no = ''
form.wx_cert_content = ''
form.wx_key_content = ''
form.fy_api_url = ''
form.fy_ins_cd = ''
form.fy_mchnt_cd = ''
form.fy_term_id = ''
form.fy_notify_url = ''
form.fy_private_key = ''
form.fy_public_key = ''
}
const configList = ref<WechatConfig[]>([])
@@ -583,16 +838,6 @@
}
])
// 监听支付渠道类型变化,清除表单验证
watch(
() => form.provider_type,
() => {
nextTick(() => {
formRef.value?.clearValidate()
})
}
)
onMounted(() => {
getTableData()
})
@@ -650,45 +895,72 @@
// 显示创建对话框
const showCreateDialog = () => {
resetForm()
dialogType.value = 'add'
Object.assign(form, { id: 0, ...initialFormState })
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
// 处理敏感字段值 - 如果是***或[已配置]等特殊值,转换为空字符串
const processSensitiveField = (value: string | undefined | null): string => {
if (!value || value === '***' || value.includes('[已配置]') || value.includes('[未配置]')) {
return ''
}
return value
}
// 显示编辑对话框
const showEditDialog = (row: WechatConfig) => {
dialogType.value = 'edit'
Object.assign(form, {
id: row.id,
name: row.name,
provider_type: row.provider_type,
description: row.description,
miniapp_app_id: row.miniapp_app_id,
miniapp_app_secret: '',
oa_app_id: row.oa_app_id,
oa_app_secret: '',
oa_token: '',
oa_aes_key: '',
oa_oauth_redirect_url: row.oa_oauth_redirect_url,
wx_mch_id: row.wx_mch_id,
wx_api_v2_key: '',
wx_api_v3_key: '',
wx_notify_url: row.wx_notify_url,
wx_serial_no: row.wx_serial_no,
wx_cert_content: '',
wx_key_content: '',
fy_api_url: row.fy_api_url,
fy_ins_cd: row.fy_ins_cd,
fy_mchnt_cd: row.fy_mchnt_cd,
fy_term_id: row.fy_term_id,
fy_notify_url: row.fy_notify_url,
fy_private_key: '',
fy_public_key: ''
})
dialogVisible.value = true
const showEditDialog = async (row: WechatConfig) => {
// 先调用详情接口获取完整数据
try {
const res = await WechatConfigService.getWechatConfigById(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
// 赋值表单数据
Object.assign(form, {
id: detail.id,
name: detail.name,
provider_type: detail.provider_type,
description: detail.description || '',
miniapp_app_id: detail.miniapp_app_id || '',
miniapp_app_secret: processSensitiveField(detail.miniapp_app_secret),
oa_app_id: detail.oa_app_id || '',
oa_app_secret: processSensitiveField(detail.oa_app_secret),
oa_token: processSensitiveField(detail.oa_token),
oa_aes_key: processSensitiveField(detail.oa_aes_key),
oa_oauth_redirect_url: detail.oa_oauth_redirect_url || '',
wx_mch_id: detail.wx_mch_id || '',
wx_api_v2_key: processSensitiveField(detail.wx_api_v2_key),
wx_api_v3_key: processSensitiveField(detail.wx_api_v3_key),
wx_notify_url: detail.wx_notify_url || '',
wx_serial_no: detail.wx_serial_no || '',
wx_cert_content: processSensitiveField(detail.wx_cert_content),
wx_key_content: processSensitiveField(detail.wx_key_content),
fy_api_url: detail.fy_api_url || '',
fy_ins_cd: detail.fy_ins_cd || '',
fy_mchnt_cd: detail.fy_mchnt_cd || '',
fy_term_id: detail.fy_term_id || '',
fy_notify_url: detail.fy_notify_url || '',
fy_private_key: processSensitiveField(detail.fy_private_key),
fy_public_key: processSensitiveField(detail.fy_public_key)
})
dialogType.value = 'edit'
dialogVisible.value = true
nextTick(() => {
formRef.value?.clearValidate()
})
}
} catch (error) {
console.error('获取详情失败:', error)
ElMessage.error('获取配置详情失败')
}
}
// 处理支付渠道类型变化
const handleProviderTypeChange = () => {
// 清除验证状态
nextTick(() => {
formRef.value?.clearValidate()
})
@@ -696,8 +968,8 @@
// 对话框关闭后的清理
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(form, { id: 0, ...initialFormState })
// 不调用resetFields,因为会把数据也重置了
// 只需要什么都不做,下次打开时会重新赋值
}
// 提交表单
@@ -772,7 +1044,6 @@
}
dialogVisible.value = false
formRef.value?.resetFields()
await getTableData()
} catch (error) {
console.error(error)
@@ -898,4 +1169,46 @@
:deep(.el-table__row.table-row-with-context-menu) {
cursor: pointer;
}
.config-form {
max-height: 600px;
overflow-y: auto;
padding-right: 10px;
.divider-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
:deep(.el-divider) {
margin: 24px 0 20px 0;
}
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-input__wrapper) {
transition: all 0.3s ease;
}
:deep(.el-textarea__inner) {
transition: all 0.3s ease;
}
}
// 优化滚动条样式
.config-form::-webkit-scrollbar {
width: 6px;
}
.config-form::-webkit-scrollbar-thumb {
background-color: var(--el-border-color-darker);
border-radius: 3px;
}
.config-form::-webkit-scrollbar-track {
background-color: var(--el-fill-color-lighter);
}
</style>