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