搜索关键字爬虫

This commit is contained in:
2025-11-12 16:10:34 +08:00
parent 7be02fe396
commit 675e6da7d7
37 changed files with 3382 additions and 572 deletions

View File

@@ -1,20 +1,654 @@
<template>
<AdminLayout
title="资源管理"
<AdminLayout
title="资源管理"
subtitle="管理文章、资源、数据等内容"
>
<div class="resource-management">
<el-empty description="请使用顶部标签页切换到对应的资源管理功能" />
<div class="header">
<h2>数据采集管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 搜索筛选区域 -->
<div class="search-bar">
<!-- 任务名称搜索 -->
<div class="search-item">
<span class="search-label">任务名称</span>
<el-input
v-model="searchForm.taskName"
placeholder="请输入任务名称"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
</div>
<!-- 日志批次ID搜索 -->
<div class="search-item">
<span class="search-label">批次ID</span>
<el-input
v-model="searchForm.logId"
placeholder="请输入批次ID"
clearable
style="width: 150px"
@keyup.enter="handleSearch"
/>
</div>
<!-- 标题搜索 -->
<div class="search-item">
<span class="search-label">标题</span>
<el-input
v-model="searchForm.title"
placeholder="请输入标题"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
</div>
<!-- 来源URL搜索 -->
<div class="search-item">
<span class="search-label">来源URL</span>
<el-input
v-model="searchForm.sourceUrl"
placeholder="请输入URL"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
</div>
<!-- 状态筛选 -->
<div class="search-item">
<span class="search-label">转换状态</span>
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
>
<el-option label="未处理" :value="0" />
<el-option label="已转换" :value="1" />
<el-option label="已忽略" :value="2" />
</el-select>
</div>
<!-- 搜索/重置按钮 -->
<div class="search-actions">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
</div>
<!-- 数据表格 -->
<el-table
:data="dataList"
v-loading="loading"
border
stripe
style="width: 100%"
>
<!-- 任务名称 -->
<el-table-column
prop="taskName"
label="任务名称"
width="150"
fixed="left"
show-overflow-tooltip
/>
<!-- 日志批次ID -->
<el-table-column
prop="logId"
label="批次ID"
width="100"
show-overflow-tooltip
/>
<!-- 来源URL -->
<el-table-column label="来源URL" width="200">
<template #default="{ row }">
<el-link
v-if="row.sourceUrl"
:href="row.sourceUrl"
target="_blank"
type="primary"
:underline="false"
>
{{ truncateUrl(row.sourceUrl) }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<!-- 爬虫解析结果 -->
<el-table-column label="解析结果" width="220">
<template #default="{ row }">
<div class="parse-result">
<div v-if="row.category" class="result-item">
<el-tag size="small" type="info">{{ row.category }}</el-tag>
</div>
<div v-if="row.source" class="result-item">
来源: {{ row.source }}
</div>
<div v-if="row.tags" class="result-item">
标签: {{ row.tags }}
</div>
</div>
</template>
</el-table-column>
<!-- 标题 -->
<el-table-column
prop="title"
label="标题"
min-width="250"
show-overflow-tooltip
/>
<!-- 作者 -->
<el-table-column
prop="author"
label="作者"
width="100"
show-overflow-tooltip
/>
<!-- 发布时间 -->
<el-table-column label="发布时间" width="160">
<template #default="{ row }">
{{ formatDateTime(row.publishTime) }}
</template>
</el-table-column>
<!-- 转换状态 -->
<el-table-column label="转换状态" width="100">
<template #default="{ row }">
<el-tag
:type="getStatusTagType(row.status)"
size="small"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<!-- 操作列 -->
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
<el-button
v-if="row.status === 0"
type="success"
size="small"
@click="handleConvert(row)"
>
转换为资源
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
v-model:current-page="pageParam.pageNumber"
v-model:page-size="pageParam.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="数据采集详情"
width="900px"
:close-on-click-modal="false"
>
<div class="detail-content" v-if="currentItem">
<!-- 基本信息区域 -->
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="标题" :span="2">
{{ currentItem.title }}
</el-descriptions-item>
<el-descriptions-item label="作者">
{{ currentItem.author || '未知' }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ formatDateTime(currentItem.publishTime) }}
</el-descriptions-item>
<el-descriptions-item label="来源">
{{ currentItem.source || '-' }}
</el-descriptions-item>
<el-descriptions-item label="分类">
{{ currentItem.category || '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentItem.status)">
{{ getStatusText(currentItem.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="任务名称">
{{ currentItem.taskName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="来源URL" :span="2">
<el-link
v-if="currentItem.sourceUrl"
:href="currentItem.sourceUrl"
target="_blank"
type="primary"
>
{{ currentItem.sourceUrl }}
</el-link>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="标签" :span="2">
{{ currentItem.tags || '无' }}
</el-descriptions-item>
</el-descriptions>
<!-- 封面图片 -->
<div v-if="currentItem.coverImage" class="cover-section">
<h4>封面图片</h4>
<el-image
:src="currentItem.coverImage"
fit="cover"
style="width: 200px; height: 150px; border-radius: 4px"
:preview-src-list="[currentItem.coverImage]"
/>
</div>
<!-- 摘要 -->
<div v-if="currentItem.summary" class="summary-section">
<h4>摘要</h4>
<p>{{ currentItem.summary }}</p>
</div>
<!-- 正文内容 - 使用富文本显示 -->
<div v-if="currentItem.content" class="content-section">
<h4>正文内容</h4>
<div class="content-display" v-html="currentItem.content"></div>
</div>
<!-- 转换信息 -->
<div v-if="currentItem.status === 1" class="convert-info">
<h4>转换信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="资源ID">
{{ currentItem.resourceId || '-' }}
</el-descriptions-item>
<el-descriptions-item label="转换时间">
{{ formatDateTime(currentItem.processTime) }}
</el-descriptions-item>
<el-descriptions-item label="处理人" :span="2">
{{ currentItem.processor || '系统' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 错误信息 -->
<div v-if="currentItem.status === 2 && currentItem.errorMessage" class="error-info">
<h4>错误信息</h4>
<el-alert type="error" :closable="false">
{{ currentItem.errorMessage }}
</el-alert>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
<el-button
v-if="currentItem && currentItem.status === 0"
type="success"
@click="handleConvertFromDetail"
>
转换为资源
</el-button>
</template>
</el-dialog>
<!-- 转换对话框 - 使用 ArticleAdd 组件 -->
<el-dialog
v-model="convertDialogVisible"
title="转换为资源"
width="90%"
:close-on-click-modal="false"
:destroy-on-close="true"
top="5vh"
>
<ArticleAdd
v-if="convertDialogVisible"
:initial-data="convertFormData"
:show-back-button="false"
@publish-success="handleConvertSuccess"
@back="convertDialogVisible = false"
/>
</el-dialog>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Refresh } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import { ArticleAdd } from '@/views/public/article/components';
import type { DataCollectionItem, PageParam, ResourceVO } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'ResourceManagementView'
});
// ==================== 数据状态 ====================
const loading = ref(false);
const dataList = ref<DataCollectionItem[]>([]);
const total = ref(0);
const currentItem = ref<DataCollectionItem | null>(null);
const convertItem = ref<DataCollectionItem | null>(null);
// 转换表单数据
const convertFormData = ref<ResourceVO>({
resource: {},
tags: []
});
// ==================== 搜索表单 ====================
const searchForm = reactive({
taskName: '',
logId: '',
title: '',
sourceUrl: '',
status: undefined as number | undefined
});
// ==================== 分页参数 ====================
const pageParam = reactive<PageParam>({
pageNumber: 1,
pageSize: 20
});
// ==================== 对话框状态 ====================
const detailDialogVisible = ref(false);
const convertDialogVisible = ref(false);
// ==================== 数据加载 ====================
/**
* 加载数据采集列表
*/
async function loadDataList() {
loading.value = true;
try {
const filter: Partial<DataCollectionItem> = {};
if (searchForm.taskName) filter.taskName = searchForm.taskName;
if (searchForm.logId) filter.logId = searchForm.logId;
if (searchForm.title) filter.title = searchForm.title;
if (searchForm.sourceUrl) filter.sourceUrl = searchForm.sourceUrl;
if (searchForm.status !== undefined) filter.status = searchForm.status;
const result = await crontabApi.getCollectionItemPage(filter, pageParam);
if (result.success) {
if (result.pageDomain) {
dataList.value = result.pageDomain.dataList || [];
total.value = result.pageDomain.pageParam?.totalElements || 0;
} else if (result.dataList) {
dataList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
} else {
dataList.value = [];
total.value = 0;
}
} else {
ElMessage.error(result.message || '加载数据失败');
dataList.value = [];
total.value = 0;
}
} catch (error) {
console.error('加载数据采集列表失败:', error);
ElMessage.error('加载数据失败');
dataList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
// ==================== 搜索操作 ====================
/**
* 搜索
*/
function handleSearch() {
pageParam.pageNumber = 1;
loadDataList();
}
/**
* 重置搜索
*/
function handleReset() {
searchForm.taskName = '';
searchForm.logId = '';
searchForm.title = '';
searchForm.sourceUrl = '';
searchForm.status = undefined;
pageParam.pageNumber = 1;
loadDataList();
}
/**
* 刷新列表
*/
function handleRefresh() {
loadDataList();
}
// ==================== 分页操作 ====================
/**
* 页码变化
*/
function handlePageChange(page: number) {
pageParam.pageNumber = page;
loadDataList();
}
/**
* 每页数量变化
*/
function handleSizeChange(size: number) {
pageParam.pageSize = size;
pageParam.pageNumber = 1;
loadDataList();
}
// ==================== 详情查看 ====================
/**
* 查看详情
*/
function handleViewDetail(row: DataCollectionItem) {
currentItem.value = row;
detailDialogVisible.value = true;
}
// ==================== 转换操作 ====================
/**
* 处理富文本内容,清理不必要的样式
*/
function cleanHtmlContent(html: string): string {
if (!html) return '';
// 创建临时DOM元素来处理HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// 移除所有内联样式中的字体大小、字体族等可能导致显示问题的样式
const elementsWithStyle = tempDiv.querySelectorAll('[style]');
elementsWithStyle.forEach((el) => {
const element = el as HTMLElement;
const style = element.style;
// 保留一些重要的样式,移除可能冲突的样式
const preservedStyles: string[] = [];
// 保留文本颜色
if (style.color) preservedStyles.push(`color: ${style.color}`);
// 保留背景色
if (style.backgroundColor) preservedStyles.push(`background-color: ${style.backgroundColor}`);
// 保留文本对齐
if (style.textAlign) preservedStyles.push(`text-align: ${style.textAlign}`);
// 保留边距
if (style.marginTop) preservedStyles.push(`margin-top: ${style.marginTop}`);
if (style.marginBottom) preservedStyles.push(`margin-bottom: ${style.marginBottom}`);
element.setAttribute('style', preservedStyles.join('; '));
});
// 移除可能的外部类名,避免样式冲突
const elementsWithClass = tempDiv.querySelectorAll('[class]');
elementsWithClass.forEach((el) => {
el.removeAttribute('class');
});
return tempDiv.innerHTML;
}
/**
* 打开转换对话框,预填充数据
*/
function handleConvert(row: DataCollectionItem) {
convertItem.value = row;
// 处理富文本内容,清理样式
const cleanedContent = cleanHtmlContent(row.content || '');
// 预填充文章数据
convertFormData.value = {
resource: {
title: row.title || '',
content: cleanedContent,
summary: row.summary || '',
coverImage: row.coverImage || '',
author: row.author || '',
source: row.source || '',
sourceUrl: row.sourceUrl || '',
publishTime: row.publishTime || new Date().toISOString(),
status: 1, // 已发布
allowComment: true,
isTop: false,
isRecommend: false
},
tags: []
};
convertDialogVisible.value = true;
}
/**
* 从详情页转换
*/
function handleConvertFromDetail() {
detailDialogVisible.value = false;
handleConvert(currentItem.value!);
}
/**
* 转换成功后的回调
*/
function handleConvertSuccess(resourceId: string) {
ElMessage.success('转换成功');
convertDialogVisible.value = false;
// 更新采集项状态为已转换
if (convertItem.value?.id) {
// 这里可以调用API更新状态,或者直接刷新列表
loadDataList();
}
}
// ==================== 辅助函数 ====================
/**
* 格式化日期时间
*/
function formatDateTime(dateTime: string | Date | undefined): string {
if (!dateTime) return '-';
const date = typeof dateTime === 'string' ? new Date(dateTime) : dateTime;
if (isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* 截断URL显示
*/
function truncateUrl(url: string | undefined): string {
if (!url) return '-';
return url.length > 30 ? url.substring(0, 30) + '...' : url;
}
/**
* 获取状态文本
*/
function getStatusText(status: number | undefined): string {
switch (status) {
case 0: return '未处理';
case 1: return '已转换';
case 2: return '已忽略';
default: return '未知';
}
}
/**
* 获取状态标签类型
*/
function getStatusTagType(status: number | undefined): string {
switch (status) {
case 0: return 'warning';
case 1: return 'success';
case 2: return 'info';
default: return '';
}
}
// ==================== 生命周期 ====================
onMounted(() => {
loadDataList();
});
</script>
<style lang="scss" scoped>
@@ -23,8 +657,184 @@ defineOptions({
padding: 24px;
border-radius: 14px;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #141F38;
}
.header-actions {
display: flex;
gap: 12px;
}
}
.search-bar {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
.search-item {
display: flex;
align-items: center;
gap: 8px;
.search-label {
font-size: 14px;
color: #606266;
white-space: nowrap;
min-width: 70px;
}
}
.search-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
}
// 表格内的解析结果
.parse-result {
.result-item {
margin-bottom: 4px;
font-size: 12px;
color: #606266;
&:last-child {
margin-bottom: 0;
}
}
}
// 分页容器
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
// 详情对话框样式
.detail-content {
max-height: 70vh;
// overflow-y: auto;
h4 {
margin: 20px 0 10px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
border-left: 4px solid #409eff;
padding-left: 10px;
&:first-child {
margin-top: 0;
}
}
.cover-section {
margin-top: 20px;
}
.summary-section {
margin-top: 20px;
p {
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
line-height: 1.8;
color: #606266;
margin: 0;
}
}
.content-section {
margin-top: 20px;
.content-display {
padding: 16px;
background-color: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 4px;
line-height: 1.8;
color: #303133;
max-height: 200px;
overflow-y: auto;
// 富文本内容样式
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin: 8px 0;
}
:deep(h1), :deep(h2), :deep(h3),
:deep(h4), :deep(h5), :deep(h6) {
margin: 16px 0 8px 0;
}
:deep(a) {
color: #409eff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(ul), :deep(ol) {
padding-left: 24px;
}
:deep(blockquote) {
border-left: 4px solid #dcdfe6;
padding-left: 12px;
color: #909399;
margin: 12px 0;
}
:deep(code) {
background-color: #f5f7fa;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
:deep(pre) {
background-color: #f5f7fa;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
code {
background-color: transparent;
padding: 0;
}
}
}
}
.convert-info,
.error-info {
margin-top: 20px;
}
}
}
</style>