feat(ads/compliance/tencent): 新增导出失败素材接口并优化校验页面功能

1. 新增导出驳回素材的API接口和前端导出逻辑,统一处理图片/视频素材导出
2. 移除旧的批量校验、刷新、手动送检功能,重构日志查看逻辑
3. 优化预览弹窗样式,调整表格列宽和日志表格展示字段
4. 替换旧的本地导出实现为后端接口导出方式
This commit is contained in:
2026-05-15 14:35:19 +08:00
parent c70bf33260
commit 1991f74b7e
2 changed files with 312 additions and 169 deletions

View File

@@ -156,3 +156,16 @@ export function batchVerifyVideo(requestOptions?: RequestOptions) {
requestOptions, requestOptions,
}); });
} }
export interface ExportParams {
materialType?: 'IMAGE' | 'VIDEO';
}
export function exportRejectedMaterials(data: ExportParams = {}, requestOptions?: RequestOptions) {
return request({
url: '/cid/material/verify/controller/export-rejected',
method: 'post',
data,
requestOptions,
});
}

View File

@@ -2,7 +2,7 @@
<div class="ads-compliance-tencent"> <div class="ads-compliance-tencent">
<el-card shadow="hover" class="main-card"> <el-card shadow="hover" class="main-card">
<!-- Tabs --> <!-- Tabs -->
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="main-tabs"> <el-tabs v-model="activeTab" class="main-tabs">
<el-tab-pane label="图片素材" name="image"> <el-tab-pane label="图片素材" name="image">
<!-- 图片素材内容区域 --> <!-- 图片素材内容区域 -->
<div class="tab-content"> <div class="tab-content">
@@ -36,10 +36,10 @@
<!-- 右侧操作按钮 --> <!-- 右侧操作按钮 -->
<div class="action-buttons"> <div class="action-buttons">
<el-button type="success" @click="batchVerifyImage" :loading="batchLoading"> 批量校验图片 </el-button> <!-- <el-button type="success" @click="batchVerifyImage" :loading="batchLoading"> 批量校验图片 </el-button> -->
<el-button type="primary" plain @click="pollImageResults" :loading="pollLoading"> <!-- <el-button type="primary" plain @click="pollImageResults" :loading="pollLoading">
<el-icon><Refresh /></el-icon> 刷新检测结果 <el-icon><Refresh /></el-icon> 刷新检测结果
</el-button> </el-button> -->
<el-button type="warning" plain @click="exportImageUrls"> <el-button type="warning" plain @click="exportImageUrls">
<el-icon><Download /></el-icon> 导出失败素材 <el-icon><Download /></el-icon> 导出失败素材
</el-button> </el-button>
@@ -71,7 +71,6 @@
<el-table-column prop="description" label="描述" min-width="200"></el-table-column> <el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="text" @click="verifyImage(scope.row.imageId)">送检</el-button>
<el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button> <el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -124,10 +123,10 @@
<!-- 右侧操作按钮 --> <!-- 右侧操作按钮 -->
<div class="action-buttons"> <div class="action-buttons">
<el-button type="success" @click="batchVerifyVideo" :loading="batchLoading"> 批量校验视频 </el-button> <!-- <el-button type="success" @click="batchVerifyVideo" :loading="batchLoading"> 批量校验视频 </el-button>
<el-button type="primary" plain @click="pollVideoResults" :loading="pollLoading"> <el-button type="primary" plain @click="pollVideoResults" :loading="pollLoading">
<el-icon><Refresh /></el-icon> 刷新检测结果 <el-icon><Refresh /></el-icon> 刷新检测结果
</el-button> </el-button> -->
<el-button type="warning" plain @click="exportVideoUrls"> <el-button type="warning" plain @click="exportVideoUrls">
<el-icon><Download /></el-icon> 导出失败素材 <el-icon><Download /></el-icon> 导出失败素材
</el-button> </el-button>
@@ -138,7 +137,7 @@
<div class="table-wrapper"> <div class="table-wrapper">
<el-table :data="videoList" border style="width: 100%" v-loading="videoLoading"> <el-table :data="videoList" border style="width: 100%" v-loading="videoLoading">
<el-table-column prop="accountId" label="账户ID" width="120"></el-table-column> <el-table-column prop="accountId" label="账户ID" width="120"></el-table-column>
<el-table-column label="预览" width="180"> <el-table-column label="预览" width="220">
<template #default="scope"> <template #default="scope">
<div class="video-preview" @click="previewMedia(scope.row.previewUrl, 'video')"> <div class="video-preview" @click="previewMedia(scope.row.previewUrl, 'video')">
<el-icon><VideoPlay style="font-size: 32px" /></el-icon> <el-icon><VideoPlay style="font-size: 32px" /></el-icon>
@@ -155,7 +154,6 @@
<el-table-column prop="description" label="描述" min-width="200"></el-table-column> <el-table-column prop="description" label="描述" min-width="200"></el-table-column>
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="text" @click="verifyVideo(scope.row.videoId)">送检</el-button>
<el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button> <el-button size="small" type="text" @click="viewLog(scope.row)">查看日志</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -179,15 +177,15 @@
</el-card> </el-card>
<!-- 预览对话框 --> <!-- 预览对话框 -->
<el-dialog title="媒体预览" v-model="previewVisible" width="60%"> <el-dialog title="媒体预览" v-model="previewVisible" width="60%" class="preview-dialog">
<div style="text-align: center"> <div class="preview-content">
<img v-if="previewType === 'image'" :src="previewUrl" style="max-width: 100%" /> <img v-if="previewType === 'image'" :src="previewUrl" />
<video v-if="previewType === 'video'" :src="previewUrl" controls style="max-width: 100%"></video> <video v-if="previewType === 'video'" :src="previewUrl" controls></video>
</div> </div>
</el-dialog> </el-dialog>
<!-- 日志对话框 --> <!-- 日志对话框 -->
<el-dialog title="校验日志" v-model="logVisible" width="60%"> <el-dialog title="校验日志" v-model="logVisible" width="70%">
<el-table :data="currentLogList" border style="width: 100%"> <el-table :data="currentLogList" border style="width: 100%">
<el-table-column prop="time" label="时间" width="180"></el-table-column> <el-table-column prop="time" label="时间" width="180"></el-table-column>
<el-table-column prop="type" label="类型" width="120"> <el-table-column prop="type" label="类型" width="120">
@@ -197,7 +195,22 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="message" label="日志内容"></el-table-column> <el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<span :class="'table-status status-' + (scope.row.status || 'info').toLowerCase()">
{{ getStatusText(scope.row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="suggestion" label="建议" width="120">
<template #default="scope">
<span :class="'table-status status-' + getSuggestionClass(scope.row.suggestion)">
{{ getSuggestionText(scope.row.suggestion) }}
</span>
</template>
</el-table-column>
<el-table-column prop="errorMsg" label="错误信息" min-width="200"></el-table-column>
<el-table-column prop="message" label="日志内容" min-width="200"></el-table-column>
</el-table> </el-table>
<div v-if="!currentLogList.length" style="text-align: center; color: #909399; padding: 40px">暂无日志记录</div> <div v-if="!currentLogList.length" style="text-align: center; color: #909399; padding: 40px">暂无日志记录</div>
</el-dialog> </el-dialog>
@@ -205,26 +218,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'; import { ref, reactive, onMounted, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage } from 'element-plus';
import { Refresh, Download, VideoPlay } from '@element-plus/icons-vue'; import { Download, VideoPlay } from '@element-plus/icons-vue';
import { import {
getImageStats, getImageStats,
getVideoStats, getVideoStats,
getImageList, getImageList,
getVideoList, getVideoList,
manualVerifyImage, getVerifyLogList,
manualVerifyVideo, exportRejectedMaterials,
pollImageResults as pollImageResultsApi, // manualVerifyImage,
pollVideoResults as pollVideoResultsApi, // manualVerifyVideo,
batchVerifyImage as batchVerifyImageApi, // pollImageResults as pollImageResultsApi,
batchVerifyVideo as batchVerifyVideoApi, // pollVideoResults as pollVideoResultsApi,
// batchVerifyImage as batchVerifyImageApi,
// batchVerifyVideo as batchVerifyVideoApi,
} from '/@/api/ads/compliance/tencent/materialVerify'; } from '/@/api/ads/compliance/tencent/materialVerify';
// 响应式状态 // 响应式状态
const activeTab = ref('image'); const activeTab = ref('image');
const pollLoading = ref(false); // const pollLoading = ref(false);
const batchLoading = ref(false); // const batchLoading = ref(false);
// 图片统计 // 图片统计
const imageStats = reactive({ pending: 0, verified: 0, rejected: 0 }); const imageStats = reactive({ pending: 0, verified: 0, rejected: 0 });
@@ -319,14 +334,14 @@ const loadVideoList = () => {
}); });
}; };
// Tab 切换 // Tab 切换监听
const handleTabClick = (tab: any) => { watch(activeTab, (newTab) => {
if (tab.name === 'image') { if (newTab === 'image') {
loadImageList(); loadImageList();
} else if (tab.name === 'video') { } else if (newTab === 'video') {
loadVideoList(); loadVideoList();
} }
}; });
// 搜索 // 搜索
const searchImage = () => { const searchImage = () => {
@@ -362,107 +377,123 @@ const handleVideoPageChange = (page: number) => {
}; };
// 手动送检 // 手动送检
const verifyImage = (imageId: string) => { // const verifyImage = (imageId: string) => {
ElMessageBox.confirm('确认提交图片 ' + imageId + ' 进行校验?', '提示', { // ElMessageBox.confirm('确认提交图片 ' + imageId + ' 进行校验?', '提示', {
confirmButtonText: '确定', // confirmButtonText: '确定',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning', // type: 'warning',
}) // })
.then(() => { // .then(() => {
manualVerifyImage({ materialId: imageId }) // manualVerifyImage({ materialId: imageId })
.then(() => { // .then(() => {
ElMessage.success('提交成功'); // ElMessage.success('提交成功');
loadImageList(); // loadImageList();
loadStats(); // loadStats();
}) // })
.catch(() => {}); // .catch(() => {});
}) // })
.catch(() => {}); // .catch(() => {});
}; // };
const verifyVideo = (videoId: string) => { // const verifyVideo = (videoId: string) => {
ElMessageBox.confirm('确认提交视频 ' + videoId + ' 进行校验?', '提示', { // ElMessageBox.confirm('确认提交视频 ' + videoId + ' 进行校验?', '提示', {
confirmButtonText: '确定', // confirmButtonText: '确定',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning', // type: 'warning',
}) // })
.then(() => { // .then(() => {
manualVerifyVideo({ materialId: videoId }) // manualVerifyVideo({ materialId: videoId })
.then(() => { // .then(() => {
ElMessage.success('提交成功'); // ElMessage.success('提交成功');
loadVideoList(); // loadVideoList();
loadStats(); // loadStats();
}) // })
.catch(() => {}); // .catch(() => {});
}) // })
.catch(() => {}); // .catch(() => {});
}; // };
// 刷新检测结果 // // 刷新检测结果
const pollImageResults = () => { // const pollImageResults = () => {
pollLoading.value = true; // pollLoading.value = true;
pollImageResultsApi() // pollImageResultsApi()
.then((res: any) => { // .then((res: any) => {
ElMessage.success(res?.msg || '刷新完成'); // ElMessage.success(res?.msg || '刷新完成');
loadImageList(); // loadImageList();
loadStats(); // loadStats();
}) // })
.catch(() => {}) // .catch(() => {})
.finally(() => { // .finally(() => {
pollLoading.value = false; // pollLoading.value = false;
}); // });
}; // };
const pollVideoResults = () => { // const pollVideoResults = () => {
pollLoading.value = true; // pollLoading.value = true;
pollVideoResultsApi() // pollVideoResultsApi()
.then((res: any) => { // .then((res: any) => {
ElMessage.success(res?.msg || '刷新完成'); // ElMessage.success(res?.msg || '刷新完成');
loadVideoList(); // loadVideoList();
loadStats(); // loadStats();
}) // })
.catch(() => {}) // .catch(() => {})
.finally(() => { // .finally(() => {
pollLoading.value = false; // pollLoading.value = false;
}); // });
}; // };
// 导出失败素材 // 导出失败素材
const exportImageUrls = () => { const exportImageUrls = () => {
const failedImages = imageList.value.filter((item) => item.verifyStatus === 'REJECTED'); ElMessage.info('正在导出图片失败素材...');
if (!failedImages.length) { exportRejectedMaterials({ materialType: 'IMAGE' })
ElMessage.warning('没有失败的图片素材'); .then((res: any) => {
return; if (res.data && res.data.items && res.data.items.length > 0) {
} downloadJsonAsCsv('图片失败素材.csv', res.data.items);
const rows = [['ID', '图片ID', '账户ID', '用途', '预览URL', '状态', '描述']]; ElMessage.success('导出成功');
failedImages.forEach((item) => { } else {
rows.push([ ElMessage.warning('没有失败的图片素材');
item.id, }
item.imageId, })
item.accountId, .catch(() => {
item.imageUsage, ElMessage.error('导出失败');
item.previewUrl || '-', });
getStatusText(item.verifyStatus),
item.description || '-',
]);
});
downloadCsv('图片失败素材.csv', rows);
}; };
const exportVideoUrls = () => { const exportVideoUrls = () => {
const failedVideos = videoList.value.filter((item) => item.verifyStatus === 'REJECTED'); ElMessage.info('正在导出视频失败素材...');
if (!failedVideos.length) { exportRejectedMaterials({ materialType: 'VIDEO' })
ElMessage.warning('没有失败的视频素材'); .then((res: any) => {
return; if (res.data && res.data.items && res.data.items.length > 0) {
} downloadJsonAsCsv('视频失败素材.csv', res.data.items);
const rows = [['ID', '视频ID', '账户ID', '预览URL', '状态', '描述']]; ElMessage.success('导出成功');
failedVideos.forEach((item) => { } else {
rows.push([item.id, item.videoId, item.accountId, item.previewUrl || '-', getStatusText(item.verifyStatus), item.description || '-']); ElMessage.warning('没有失败的视频素材');
}); }
downloadCsv('视频失败素材.csv', rows); })
.catch(() => {
ElMessage.error('导出失败');
});
}; };
const downloadCsv = (filename: string, rows: string[][]) => { const downloadJsonAsCsv = (filename: string, items: any[]) => {
const headers = ['ID', '素材ID', '账户ID', '公司名称', '预览URL', '描述', '错误信息', '素材类型', '图片用途', '创建时间'];
const rows = [headers];
items.forEach((item) => {
rows.push([
item.id,
item.materialId,
item.accountId,
item.corporationName || '-',
(item.previewUrl || '').trim(),
item.description || '-',
item.errorMsg || '-',
item.materialType || '-',
item.imageUsage || '-',
item.createdAt || '-',
]);
});
const csv = rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); const csv = rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); const link = document.createElement('a');
@@ -475,49 +506,49 @@ const downloadCsv = (filename: string, rows: string[][]) => {
}; };
// 批量送检 // 批量送检
const batchVerifyImage = () => { // const batchVerifyImage = () => {
ElMessageBox.confirm('确认批量校验待处理的图片?', '提示', { // ElMessageBox.confirm('确认批量校验待处理的图片?', '提示', {
confirmButtonText: '确定', // confirmButtonText: '确定',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning', // type: 'warning',
}) // })
.then(() => { // .then(() => {
batchLoading.value = true; // batchLoading.value = true;
batchVerifyImageApi() // batchVerifyImageApi()
.then((res: any) => { // .then((res: any) => {
ElMessage.success(res?.msg || '批量校验完成'); // ElMessage.success(res?.msg || '批量校验完成');
loadImageList(); // loadImageList();
loadStats(); // loadStats();
}) // })
.catch(() => {}) // .catch(() => {})
.finally(() => { // .finally(() => {
batchLoading.value = false; // batchLoading.value = false;
}); // });
}) // })
.catch(() => {}); // .catch(() => {});
}; // };
const batchVerifyVideo = () => { // const batchVerifyVideo = () => {
ElMessageBox.confirm('确认批量校验待处理的视频?', '提示', { // ElMessageBox.confirm('确认批量校验待处理的视频?', '提示', {
confirmButtonText: '确定', // confirmButtonText: '确定',
cancelButtonText: '取消', // cancelButtonText: '取消',
type: 'warning', // type: 'warning',
}) // })
.then(() => { // .then(() => {
batchLoading.value = true; // batchLoading.value = true;
batchVerifyVideoApi() // batchVerifyVideoApi()
.then((res: any) => { // .then((res: any) => {
ElMessage.success(res?.msg || '批量校验完成'); // ElMessage.success(res?.msg || '批量校验完成');
loadVideoList(); // loadVideoList();
loadStats(); // loadStats();
}) // })
.catch(() => {}) // .catch(() => {})
.finally(() => { // .finally(() => {
batchLoading.value = false; // batchLoading.value = false;
}); // });
}) // })
.catch(() => {}); // .catch(() => {});
}; // };
// 预览 // 预览
const previewMedia = (url: string, type: string) => { const previewMedia = (url: string, type: string) => {
@@ -541,17 +572,94 @@ const getStatusText = (status: string) => {
return map[status] || status || '待校验'; return map[status] || status || '待校验';
}; };
// 解析响应结果
const parseResponseResult = (responseResult: string) => {
try {
const result = JSON.parse(responseResult);
if (result.antispam && result.antispam.riskDescription) {
return result.antispam.riskDescription;
}
if (result.riskDescription) {
return result.riskDescription;
}
} catch (e) {
// 解析失败,返回原始字符串
}
return '';
};
// 查看日志 // 查看日志
const viewLog = (row: any) => { const viewLog = (row: any) => {
// 模拟日志数据 const materialId = row.imageId || row.videoId;
currentLogList.value = [ const materialType = row.imageId ? 'IMAGE' : 'VIDEO';
{ time: new Date().toLocaleString('zh-CN'), type: 'INFO', message: '素材已创建' },
{ time: new Date().toLocaleString('zh-CN'), type: 'INFO', message: '开始校验流程' }, getVerifyLogList({
{ time: new Date().toLocaleString('zh-CN'), type: 'SUCCESS', message: `校验完成,结果:${getStatusText(row.verifyStatus)}` }, page: 1,
]; pageSize: 100,
materialId,
materialType,
})
.then((res: any) => {
if (res.data && res.data.list && res.data.list.length > 0) {
currentLogList.value = res.data.list.map((item: any) => {
const riskDesc = parseResponseResult(item.responseResult);
return {
time: item.createdAt || '-',
type: item.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
status: item.verifyStatus,
suggestion: item.suggestion,
errorMsg: item.errorMsg || '-',
message: riskDesc || `校验状态:${getStatusText(item.verifyStatus)}`,
};
});
} else {
currentLogList.value = [
{
time: row.createdAt || new Date().toLocaleString('zh-CN'),
type: row.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
status: row.verifyStatus,
suggestion: row.suggestion,
errorMsg: row.errorMsg || '-',
message: `校验完成,结果:${getStatusText(row.verifyStatus)}`,
},
];
}
})
.catch(() => {
currentLogList.value = [
{
time: row.createdAt || new Date().toLocaleString('zh-CN'),
type: row.verifyStatus === 'REJECTED' ? 'ERROR' : 'SUCCESS',
status: row.verifyStatus,
suggestion: row.suggestion,
errorMsg: row.errorMsg || '-',
message: `校验完成,结果:${getStatusText(row.verifyStatus)}`,
},
];
});
logVisible.value = true; logVisible.value = true;
}; };
// 获取建议文本
const getSuggestionText = (suggestion: number) => {
const map: Record<number, string> = {
0: '通过',
1: '建议通过',
2: '建议删除',
3: '建议删除',
};
return map[suggestion] ?? '-';
};
// 获取建议样式类
const getSuggestionClass = (suggestion: number) => {
if (suggestion === 0) return 'success';
if (suggestion === 1) return 'info';
if (suggestion === 2 || suggestion === 3) return 'rejected';
return 'info';
};
// 获取日志类型文本 // 获取日志类型文本
const getLogTypeText = (type: string) => { const getLogTypeText = (type: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
@@ -751,4 +859,26 @@ onMounted(() => {
color: #606266; color: #606266;
margin: 0; margin: 0;
} }
.preview-dialog {
:deep(.el-dialog__body) {
padding: 16px;
overflow: hidden;
}
.preview-content {
display: flex;
align-items: center;
justify-content: center;
max-height: calc(80vh - 120px);
overflow: hidden;
img,
video {
max-width: 100%;
max-height: calc(80vh - 120px);
object-fit: contain;
}
}
}
</style> </style>