新增: 轮询管理
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

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>