Files
admin-ui/src/views/ads/compliance/tencent/index.vue

885 lines
23 KiB
Vue
Raw Normal View History

<template>
<div class="ads-compliance-tencent">
<el-card shadow="hover" class="main-card">
<!-- Tabs -->
<el-tabs v-model="activeTab" class="main-tabs">
<el-tab-pane label="图片素材" name="image">
<!-- 图片素材内容区域 -->
<div class="tab-content">
<!-- 工具栏区域 -->
<div class="toolbar">
<!-- 左侧筛选表单 -->
<el-form :model="imageFilters" :inline="true" class="search-form">
<el-form-item label="状态">
<el-select v-model="imageFilters.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value=""></el-option>
<el-option label="待校验" value="PENDING"></el-option>
<el-option label="送检中" value="SUBMITTING"></el-option>
<el-option label="校验通过" value="VERIFIED"></el-option>
<el-option label="校验不通过" value="REJECTED"></el-option>
</el-select>
</el-form-item>
<el-form-item label="账户ID">
<el-input
v-model="imageFilters.accountId"
placeholder="账户ID"
style="width: 150px"
clearable
@keyup.enter="searchImage"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchImage">搜索</el-button>
<el-button @click="resetImageFilter">重置</el-button>
</el-form-item>
</el-form>
<!-- 右侧操作按钮 -->
<div class="action-buttons">
<!-- <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 type="warning" plain @click="exportImageUrls">
<el-icon><Download /></el-icon> 导出失败素材
</el-button>
</div>
</div>
<!-- 表格区域 -->
<div class="table-wrapper">
<el-table :data="imageList" border style="width: 100%" v-loading="imageLoading">
<el-table-column prop="accountId" label="账户ID" width="120"></el-table-column>
<el-table-column prop="imageUsage" label="用途" width="120"></el-table-column>
<el-table-column label="预览" width="180">
<template #default="scope">
<img
v-if="scope.row.previewUrl"
:src="scope.row.previewUrl"
class="media-preview"
@click="previewMedia(scope.row.previewUrl, 'image')"
/>
</template>
</el-table-column>
<el-table-column prop="verifyStatus" label="校验状态" width="120">
<template #default="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<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="viewLog(scope.row)">查看日志</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="pagination-container">
<el-pagination
@current-change="handleImagePageChange"
v-model:current-page="imagePage"
:page-size="imagePageSize"
layout="total, prev, pager, next"
:total="imageTotal"
>
</el-pagination>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="视频素材" name="video">
<!-- 视频素材内容区域 -->
<div class="tab-content">
<!-- 工具栏区域 -->
<div class="toolbar">
<!-- 左侧筛选表单 -->
<el-form :model="videoFilters" :inline="true" class="search-form">
<el-form-item label="状态">
<el-select v-model="videoFilters.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value=""></el-option>
<el-option label="待校验" value="PENDING"></el-option>
<el-option label="送检中" value="SUBMITTING"></el-option>
<el-option label="校验通过" value="VERIFIED"></el-option>
<el-option label="校验不通过" value="REJECTED"></el-option>
</el-select>
</el-form-item>
<el-form-item label="账户ID">
<el-input
v-model="videoFilters.accountId"
placeholder="账户ID"
style="width: 150px"
clearable
@keyup.enter="searchVideo"
></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchVideo">搜索</el-button>
<el-button @click="resetVideoFilter">重置</el-button>
</el-form-item>
</el-form>
<!-- 右侧操作按钮 -->
<div class="action-buttons">
<!-- <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 type="warning" plain @click="exportVideoUrls">
<el-icon><Download /></el-icon> 导出失败素材
</el-button>
</div>
</div>
<!-- 表格区域 -->
<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="220">
<template #default="scope">
<div class="video-preview" @click="previewMedia(scope.row.previewUrl, 'video')">
<el-icon><VideoPlay style="font-size: 32px" /></el-icon>
</div>
</template>
</el-table-column>
<el-table-column prop="verifyStatus" label="校验状态" width="120">
<template #default="scope">
<span :class="'table-status status-' + (scope.row.verifyStatus || 'pending').toLowerCase()">
{{ getStatusText(scope.row.verifyStatus) }}
</span>
</template>
</el-table-column>
<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="viewLog(scope.row)">查看日志</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="pagination-container">
<el-pagination
@current-change="handleVideoPageChange"
v-model:current-page="videoPage"
:page-size="videoPageSize"
layout="total, prev, pager, next"
:total="videoTotal"
>
</el-pagination>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 预览对话框 -->
<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="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">
<template #default="scope">
<span :class="'table-status status-' + (scope.row.type || 'info').toLowerCase()">
{{ getLogTypeText(scope.row.type) }}
</span>
</template>
</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>
</div>
</template>
<script setup lang="ts">
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,
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 imageStats = reactive({ pending: 0, verified: 0, rejected: 0 });
// 视频统计
const videoStats = reactive({ pending: 0, verified: 0, rejected: 0 });
// 图片列表
const imageList = ref<any[]>([]);
const imageLoading = ref(false);
const imagePage = ref(1);
const imagePageSize = ref(20);
const imageTotal = ref(0);
const imageFilters = reactive({ status: '', accountId: '' });
// 视频列表
const videoList = ref<any[]>([]);
const videoLoading = ref(false);
const videoPage = ref(1);
const videoPageSize = ref(20);
const videoTotal = ref(0);
const videoFilters = reactive({ status: '', accountId: '' });
// 对话框
const previewVisible = ref(false);
const previewUrl = ref('');
const previewType = ref('image');
// 日志对话框
const logVisible = ref(false);
const currentLogList = ref<any[]>([]);
// 加载统计
const loadStats = async () => {
try {
const [imgRes, vidRes] = await Promise.all([getImageStats(), getVideoStats()]);
const imgStats = imgRes.data || {};
const vidStats = vidRes.data || {};
Object.assign(imageStats, {
pending: imgStats.pending || 0,
verified: imgStats.verified || 0,
rejected: imgStats.rejected || 0,
});
Object.assign(videoStats, {
pending: vidStats.pending || 0,
verified: vidStats.verified || 0,
rejected: vidStats.rejected || 0,
});
} catch (err) {
ElMessage.error('加载统计失败');
}
};
// 加载图片列表
const loadImageList = () => {
imageLoading.value = true;
getImageList({
page: imagePage.value,
pageSize: imagePageSize.value,
status: imageFilters.status,
accountId: imageFilters.accountId,
})
.then((res) => {
imageList.value = res.data?.list || [];
imageTotal.value = res.data?.total || 0;
})
.catch(() => {
ElMessage.error('加载图片列表失败');
})
.finally(() => {
imageLoading.value = false;
});
};
// 加载视频列表
const loadVideoList = () => {
videoLoading.value = true;
getVideoList({
page: videoPage.value,
pageSize: videoPageSize.value,
status: videoFilters.status,
accountId: videoFilters.accountId,
})
.then((res) => {
videoList.value = res.data?.list || [];
videoTotal.value = res.data?.total || 0;
})
.catch(() => {
ElMessage.error('加载视频列表失败');
})
.finally(() => {
videoLoading.value = false;
});
};
// Tab 切换监听
watch(activeTab, (newTab) => {
if (newTab === 'image') {
loadImageList();
} else if (newTab === 'video') {
loadVideoList();
}
});
// 搜索
const searchImage = () => {
imagePage.value = 1;
loadImageList();
};
const searchVideo = () => {
videoPage.value = 1;
loadVideoList();
};
// 重置筛选
const resetImageFilter = () => {
Object.assign(imageFilters, { status: '', accountId: '' });
searchImage();
};
const resetVideoFilter = () => {
Object.assign(videoFilters, { status: '', accountId: '' });
searchVideo();
};
// 分页
const handleImagePageChange = (page: number) => {
imagePage.value = page;
loadImageList();
};
const handleVideoPageChange = (page: number) => {
videoPage.value = page;
loadVideoList();
};
// 手动送检
// 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 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 exportImageUrls = () => {
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 = () => {
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 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');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
};
// 批量送检
// 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 previewMedia = (url: string, type: string) => {
if (!url) {
ElMessage.warning('无预览地址');
return;
}
previewUrl.value = url;
previewType.value = type;
previewVisible.value = true;
};
// 工具方法
const getStatusText = (status: string) => {
const map: Record<string, string> = {
PENDING: '待校验',
SUBMITTING: '送检中',
VERIFIED: '校验通过',
REJECTED: '校验不通过',
};
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 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> = {
INFO: '信息',
SUCCESS: '成功',
WARN: '警告',
ERROR: '错误',
};
return map[type] || type || '信息';
};
// 组件挂载时加载数据
onMounted(() => {
loadStats();
loadImageList();
});
</script>
<style scoped lang="scss">
.ads-compliance-tencent {
padding: 24px;
background: #f5f7fa;
min-height: calc(100vh - 60px);
}
.main-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.card-description {
color: #909399;
font-size: 14px;
margin: 12px 0 20px 0;
line-height: 1.6;
padding-bottom: 16px;
border-bottom: 1px solid #ebeeef;
}
.main-tabs {
margin-top: 16px;
}
.tab-content {
padding: 20px 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: #fafafa;
border-radius: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.search-form {
display: flex;
align-items: center;
}
.search-form :deep(.el-form-item) {
margin-right: 16px;
margin-bottom: 0;
}
.search-form :deep(.el-form-item__label) {
font-size: 14px;
color: #606266;
padding-right: 8px;
}
.search-form :deep(.el-button) {
margin-left: 8px;
}
.action-buttons {
display: flex;
gap: 12px;
}
.action-buttons :deep(.el-button) {
padding: 8px 20px;
font-size: 14px;
}
.table-wrapper {
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
overflow: hidden;
}
.table-wrapper :deep(.el-table) {
border: none;
}
.table-wrapper :deep(.el-table__header th) {
background: #fafafa;
font-weight: 600;
color: #606266;
}
.table-status {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
&.status-pending {
background: #fdf6ec;
color: #e6a23c;
}
&.status-submitting {
background: #e8f4fd;
color: #409eff;
}
&.status-verified {
background: #f0f9eb;
color: #67c23a;
}
&.status-rejected {
background: #fef0f0;
color: #f56c6c;
}
&.status-info {
background: #e8f4fd;
color: #409eff;
}
&.status-success {
background: #f0f9eb;
color: #67c23a;
}
&.status-warn {
background: #fdf6ec;
color: #e6a23c;
}
&.status-error {
background: #fef0f0;
color: #f56c6c;
}
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.media-preview {
max-width: 200px;
max-height: 150px;
border-radius: 4px;
cursor: pointer;
}
.video-preview {
width: 200px;
height: 120px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.description-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
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>