440 lines
13 KiB
Vue
440 lines
13 KiB
Vue
<template>
|
|
<div>
|
|
<!-- 统计卡片 -->
|
|
<el-row :gutter="16">
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="今日发送总数" :value="statistics.total" />
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="成功" :value="statistics.successCount">
|
|
<template #suffix>
|
|
<el-tag type="success" size="small">成功</el-tag>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="失败" :value="statistics.failedCount">
|
|
<template #suffix>
|
|
<el-tag type="danger" size="small">失败</el-tag>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :span="6">
|
|
<el-card shadow="hover">
|
|
<el-statistic title="待发送" :value="statistics.pendingCount">
|
|
<template #suffix>
|
|
<el-tag type="info" size="small">待发送</el-tag>
|
|
</template>
|
|
</el-statistic>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<!-- 测试发送 -->
|
|
<el-card class="mt-4" shadow="never">
|
|
<template #header>
|
|
<span>测试发送</span>
|
|
</template>
|
|
<el-form :model="testForm" label-width="80px" inline>
|
|
<el-form-item label="目标类型">
|
|
<el-select v-model="testForm.targetType" style="width: 140px">
|
|
<el-option label="会话" value="conversation" />
|
|
<el-option label="人员" value="person" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="目标" v-if="testForm.targetType === 'conversation'" style="width: 280px">
|
|
<el-select v-model="selectedConversationId" placeholder="选择会话" clearable>
|
|
<el-option
|
|
v-for="c in conversations"
|
|
:key="c.id"
|
|
:label="c.conversationName"
|
|
:value="c.id"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="目标" v-if="testForm.targetType === 'person'" style="width: 280px">
|
|
<el-input v-model="personIdInput" placeholder="输入 person_id" />
|
|
</el-form-item>
|
|
<el-form-item label="渠道">
|
|
<el-select v-model="testForm.channelStrategy.channel_type" style="width: 140px">
|
|
<el-option label="企微" value="wecom" />
|
|
<el-option label="微信个人号" value="wechat_personal" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="内容">
|
|
<el-input v-model="textContent" placeholder="输入文本消息" style="width: 260px" />
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<!-- 筛选栏 -->
|
|
<el-card class="mt-4" shadow="never">
|
|
<el-form :model="query" inline>
|
|
<el-form-item label="任务 ID">
|
|
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
|
|
</el-form-item>
|
|
<el-form-item label="发送时间">
|
|
<el-date-picker
|
|
v-model="dateRange"
|
|
type="datetimerange"
|
|
range-separator="至"
|
|
start-placeholder="开始时间"
|
|
end-placeholder="结束时间"
|
|
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
|
style="width: 360px"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="渠道">
|
|
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
|
|
<el-option label="企微" value="wecom" />
|
|
<el-option label="微信个人号" value="wechat_personal" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="状态">
|
|
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
|
|
<el-option label="待发送" :value="0" />
|
|
<el-option label="发送中" :value="1" />
|
|
<el-option label="成功" :value="2" />
|
|
<el-option label="失败" :value="3" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
|
<el-button @click="handleReset">重置</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<!-- 记录列表 -->
|
|
<el-card class="mt-4" shadow="never">
|
|
<el-table :data="records" border v-loading="loading">
|
|
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
|
|
<el-table-column prop="targetType" label="目标类型" width="110" />
|
|
<el-table-column prop="channelType" label="渠道" width="120" />
|
|
<el-table-column prop="sendStatus" label="状态" width="100">
|
|
<template #default="{ row }">
|
|
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
|
|
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
|
|
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
|
|
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
|
|
<el-tag v-else type="info">未知</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
|
|
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
|
|
<el-table-column label="操作" width="160" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
|
<el-button
|
|
v-if="row.sendStatus === 0"
|
|
link
|
|
type="danger"
|
|
@click="handleCancel(row.taskId)"
|
|
>
|
|
取消
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<div class="pagination-bar">
|
|
<el-pagination
|
|
v-model:current-page="pagination.page"
|
|
v-model:page-size="pagination.size"
|
|
:total="pagination.total"
|
|
:page-sizes="[10, 20, 50, 100]"
|
|
layout="total, sizes, prev, pager, next"
|
|
@size-change="handleSizeChange"
|
|
@current-change="loadRecords"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- 详情抽屉 -->
|
|
<el-drawer v-model="detailVisible" title="发送详情" size="520px">
|
|
<div v-if="currentRecord" class="detail-content">
|
|
<div class="detail-item">
|
|
<span class="detail-label">任务 ID</span>
|
|
<span class="detail-value">{{ currentRecord.taskId }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">目标类型</span>
|
|
<span class="detail-value">{{ currentRecord.targetType }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">目标值</span>
|
|
<pre>{{ formatJson(currentRecord.targetValue) }}</pre>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">渠道</span>
|
|
<span class="detail-value">{{ currentRecord.channelType }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">内容</span>
|
|
<pre>{{ formatJson(currentRecord.contentBody) }}</pre>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">渠道结果</span>
|
|
<pre>{{ formatJson(currentRecord.channelResult) }}</pre>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">回调地址</span>
|
|
<span class="detail-value">{{ currentRecord.callbackUrl || '-' }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">发送时间</span>
|
|
<span class="detail-value">{{ currentRecord.sendTime || '-' }}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">完成时间</span>
|
|
<span class="detail-value">{{ currentRecord.finishTime || '-' }}</span>
|
|
</div>
|
|
</div>
|
|
</el-drawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, reactive, ref } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import {
|
|
queryRecords,
|
|
getStatistics,
|
|
cancelTask,
|
|
testSend,
|
|
type QueryRecordsParams,
|
|
} from '@/api/sendRecord'
|
|
import { listConversations } from '@/api/channel'
|
|
|
|
interface RecordItem {
|
|
id: number
|
|
taskId: string
|
|
targetType: string
|
|
targetValue?: string
|
|
channelType: string
|
|
sendStatus: number
|
|
sendTime?: string
|
|
finishTime?: string
|
|
contentBody?: string
|
|
channelResult?: string
|
|
callbackUrl?: string
|
|
}
|
|
|
|
const statistics = reactive({
|
|
total: 0,
|
|
pendingCount: 0,
|
|
sendingCount: 0,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
})
|
|
|
|
const query = reactive<QueryRecordsParams>({
|
|
taskId: '',
|
|
channelType: '',
|
|
status: '',
|
|
})
|
|
const dateRange = ref<string[]>([])
|
|
|
|
const pagination = reactive({
|
|
page: 1,
|
|
size: 20,
|
|
total: 0,
|
|
})
|
|
|
|
const records = ref<RecordItem[]>([])
|
|
const loading = ref(false)
|
|
const detailVisible = ref(false)
|
|
const currentRecord = ref<RecordItem | null>(null)
|
|
|
|
const testForm = reactive({
|
|
targetType: 'conversation',
|
|
contentType: 'text',
|
|
channelStrategy: { channel_type: 'wecom', strategy: 'primary' },
|
|
})
|
|
const selectedConversationId = ref<number | null>(null)
|
|
const personIdInput = ref('')
|
|
const textContent = ref('')
|
|
const conversations = ref<any[]>([])
|
|
|
|
function buildTargetValue() {
|
|
if (testForm.targetType === 'conversation' && selectedConversationId.value) {
|
|
return { conversation_id: selectedConversationId.value }
|
|
}
|
|
if (testForm.targetType === 'person' && personIdInput.value) {
|
|
return { person_id: Number(personIdInput.value) }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
async function loadRecords() {
|
|
loading.value = true
|
|
try {
|
|
const params: QueryRecordsParams = {
|
|
page: pagination.page,
|
|
size: pagination.size,
|
|
taskId: query.taskId || undefined,
|
|
channelType: query.channelType || undefined,
|
|
status: query.status === '' ? undefined : query.status,
|
|
}
|
|
if (dateRange.value && dateRange.value.length === 2) {
|
|
params.start = dateRange.value[0]
|
|
params.end = dateRange.value[1]
|
|
}
|
|
const res: any = await queryRecords(params)
|
|
const data = res.data || {}
|
|
records.value = data.records || data || []
|
|
pagination.total = data.total || records.value.length
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
pagination.page = 1
|
|
loadRecords()
|
|
}
|
|
|
|
function handleReset() {
|
|
query.taskId = ''
|
|
query.channelType = ''
|
|
query.status = ''
|
|
dateRange.value = []
|
|
pagination.page = 1
|
|
loadRecords()
|
|
}
|
|
|
|
function handleSizeChange() {
|
|
pagination.page = 1
|
|
loadRecords()
|
|
}
|
|
|
|
async function loadStatistics() {
|
|
const now = new Date()
|
|
const start = formatDateTime(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0))
|
|
const end = formatDateTime(now)
|
|
try {
|
|
const res: any = await getStatistics(start, end)
|
|
Object.assign(statistics, res.data || {})
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message)
|
|
}
|
|
}
|
|
|
|
async function handleTestSend() {
|
|
const targetValue = buildTargetValue()
|
|
if (Object.keys(targetValue).length === 0) {
|
|
ElMessage.warning('请选择或填写目标')
|
|
return
|
|
}
|
|
try {
|
|
await testSend({
|
|
targetType: testForm.targetType,
|
|
targetValue,
|
|
contentType: 'text',
|
|
content: { text: textContent.value },
|
|
channelStrategy: testForm.channelStrategy,
|
|
})
|
|
ElMessage.success('测试发送已提交')
|
|
textContent.value = ''
|
|
await loadRecords()
|
|
await loadStatistics()
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message)
|
|
}
|
|
}
|
|
|
|
async function handleCancel(taskId: string) {
|
|
try {
|
|
await ElMessageBox.confirm('确定取消该任务的待发送记录?', '提示', { type: 'warning' })
|
|
await cancelTask(taskId)
|
|
ElMessage.success('取消成功')
|
|
await loadRecords()
|
|
await loadStatistics()
|
|
} catch (e: any) {
|
|
if (e !== 'cancel') {
|
|
ElMessage.error(e.message || '取消失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
function openDetail(row: RecordItem) {
|
|
currentRecord.value = row
|
|
detailVisible.value = true
|
|
}
|
|
|
|
function formatJson(value?: string) {
|
|
if (!value) return '-'
|
|
try {
|
|
return JSON.stringify(JSON.parse(value), null, 2)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function formatDateTime(date: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
}
|
|
|
|
async function loadConversations() {
|
|
try {
|
|
const res: any = await listConversations()
|
|
conversations.value = res.data || []
|
|
} catch (e: any) {
|
|
ElMessage.error(e.message)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadConversations()
|
|
loadRecords()
|
|
loadStatistics()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mt-4 {
|
|
margin-top: 16px;
|
|
}
|
|
.pagination-bar {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
.detail-content {
|
|
font-size: 14px;
|
|
}
|
|
.detail-item {
|
|
margin-bottom: 16px;
|
|
}
|
|
.detail-label {
|
|
display: block;
|
|
color: #606266;
|
|
margin-bottom: 6px;
|
|
}
|
|
.detail-value {
|
|
color: #303133;
|
|
word-break: break-all;
|
|
}
|
|
pre {
|
|
margin: 0;
|
|
padding: 12px;
|
|
background: #f5f7fa;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
</style>
|