搜索关键字爬虫

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

@@ -115,60 +115,152 @@
</div>
<!-- 详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
<el-dialog
v-model="detailDialogVisible"
title="执行日志详情"
width="700px"
width="900px"
:close-on-click-modal="false"
>
<div class="detail-content" v-if="currentLog">
<div class="detail-item">
<span class="detail-label">任务名称</span>
<span class="detail-value">{{ currentLog.taskName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">任务分组</span>
<span class="detail-value">{{ currentLog.taskGroup }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Bean名称</span>
<span class="detail-value">{{ currentLog.beanName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">方法名称</span>
<span class="detail-value">{{ currentLog.methodName }}</span>
</div>
<div class="detail-item" v-if="currentLog.methodParams">
<span class="detail-label">方法参数</span>
<span class="detail-value">{{ currentLog.methodParams }}</span>
</div>
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">执行时长</span>
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
</div>
<div class="detail-item">
<span class="detail-label">开始时间</span>
<span class="detail-value">{{ currentLog.startTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">结束时间</span>
<span class="detail-value">{{ currentLog.endTime }}</span>
</div>
<div class="detail-item" v-if="currentLog.executeMessage">
<span class="detail-label">执行结果</span>
<div class="detail-message">{{ currentLog.executeMessage }}</div>
</div>
<div class="detail-item" v-if="currentLog.exceptionInfo">
<span class="detail-label">异常信息</span>
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
</div>
<!-- 日志基本信息 -->
<el-card class="detail-card" shadow="never">
<template #header>
<div class="card-header-title">
<span>执行信息</span>
</div>
</template>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">任务名称</span>
<span class="detail-value">{{ currentLog.taskName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">任务分组</span>
<span class="detail-value">{{ currentLog.taskGroup }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Bean名称</span>
<span class="detail-value">{{ currentLog.beanName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">方法名称</span>
<span class="detail-value">{{ currentLog.methodName }}</span>
</div>
<div class="detail-item" v-if="currentLog.methodParams">
<span class="detail-label">方法参数</span>
<span class="detail-value">{{ currentLog.methodParams }}</span>
</div>
<div class="detail-item">
<span class="detail-label">执行状态</span>
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-item">
<span class="detail-label">执行时长</span>
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
</div>
<div class="detail-item">
<span class="detail-label">开始时间</span>
<span class="detail-value">{{ currentLog.startTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">结束时间</span>
<span class="detail-value">{{ currentLog.endTime }}</span>
</div>
<div class="detail-item full-width" v-if="currentLog.executeMessage">
<span class="detail-label">执行结果</span>
<div class="detail-message">{{ currentLog.executeMessage }}</div>
</div>
<div class="detail-item full-width" v-if="currentLog.exceptionInfo">
<span class="detail-label">异常信息</span>
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
</div>
</div>
</el-card>
<!-- 采集的新闻数据 -->
<el-card class="detail-card" shadow="never" style="margin-top: 20px">
<template #header>
<div class="card-header-title">
<span>采集数据</span>
<el-tag size="small" type="info"> {{ collectionItems.length }} </el-tag>
</div>
</template>
<div v-loading="loadingItems">
<!-- 无数据提示 -->
<el-empty
v-if="!loadingItems && collectionItems.length === 0"
description="暂无采集数据"
:image-size="80"
/>
<!-- 新闻列表 -->
<div v-else class="news-list">
<div
v-for="(item, index) in collectionItems"
:key="item.id"
class="news-item"
>
<div class="news-header">
<span class="news-index">#{{ index + 1 }}</span>
<el-tag
v-if="item.status === 0"
type="info"
size="small"
>
未处理
</el-tag>
<el-tag
v-else-if="item.status === 1"
type="success"
size="small"
>
已转换
</el-tag>
<el-tag
v-else
type="warning"
size="small"
>
已忽略
</el-tag>
</div>
<h4 class="news-title">{{ item.title }}</h4>
<div class="news-meta">
<span v-if="item.source">来源: {{ item.source }}</span>
<span v-if="item.author">作者: {{ item.author }}</span>
<span v-if="item.publishTime">发布: {{ item.publishTime }}</span>
<span v-if="item.category">分类: {{ item.category }}</span>
</div>
<div v-if="item.summary" class="news-summary">
{{ item.summary }}
</div>
<div class="news-footer">
<el-link
v-if="item.sourceUrl"
:href="item.sourceUrl"
target="_blank"
type="primary"
:underline="false"
>
查看原文
</el-link>
<span v-if="item.crawlTime" class="crawl-time">
采集时间: {{ item.crawlTime }}
</span>
</div>
</div>
</div>
</div>
</el-card>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
</template>
@@ -222,7 +314,7 @@ import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete, Search, Refresh } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import type { CrontabLog, PageParam } from '@/types';
import type { CrontabLog, PageParam, DataCollectionItem } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'LogManagementView'
@@ -233,6 +325,8 @@ const submitting = ref(false);
const logList = ref<CrontabLog[]>([]);
const total = ref(0);
const currentLog = ref<CrontabLog | null>(null);
const collectionItems = ref<DataCollectionItem[]>([]);
const loadingItems = ref(false);
// 搜索表单
const searchForm = reactive({
@@ -262,9 +356,18 @@ async function loadLogList() {
if (searchForm.executeStatus !== undefined) filter.executeStatus = searchForm.executeStatus;
const result = await crontabApi.getLogPage(filter, pageParam);
if (result.success && result.dataList) {
logList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
if (result.success) {
// 根据后端返回结构处理数据
if (result.pageDomain) {
logList.value = result.pageDomain.dataList || [];
total.value = result.pageDomain.pageParam?.totalElements || 0;
} else if (result.dataList) {
logList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
} else {
logList.value = [];
total.value = 0;
}
} else {
ElMessage.error(result.message || '加载日志列表失败');
logList.value = [];
@@ -310,16 +413,36 @@ function handleSizeChange(size: number) {
// 查看详情
async function handleViewDetail(row: CrontabLog) {
try {
const result = await crontabApi.getLogById(row.id!);
if (result.success && result.data) {
currentLog.value = result.data;
detailDialogVisible.value = true;
// 同时加载日志详情和采集项数据
loadingItems.value = true;
collectionItems.value = [];
const [logResult, itemsResult] = await Promise.all([
crontabApi.getLogById(row.id!),
crontabApi.getCollectionItemsByLogId(row.id!)
]);
if (logResult.success && logResult.data) {
currentLog.value = logResult.data;
} else {
ElMessage.error(result.message || '获取详情失败');
ElMessage.error(logResult.message || '获取日志详情失败');
return;
}
if (itemsResult.success) {
collectionItems.value = itemsResult.dataList || [];
} else {
console.warn('获取采集项失败:', itemsResult.message);
// 即使采集项加载失败,也显示日志详情
collectionItems.value = [];
}
detailDialogVisible.value = true;
} catch (error) {
console.error('获取日志详情失败:', error);
ElMessage.error('获取日志详情失败');
} finally {
loadingItems.value = false;
}
}
@@ -432,42 +555,165 @@ onMounted(() => {
}
.detail-content {
.detail-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
font-size: 14px;
.detail-card {
margin-bottom: 20px;
.detail-label {
min-width: 100px;
color: #606266;
font-weight: 500;
}
.detail-value {
flex: 1;
.card-header-title {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 16px;
color: #303133;
word-break: break-all;
}
}
.detail-message,
.detail-exception {
flex: 1;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
.detail-item {
display: flex;
align-items: flex-start;
font-size: 14px;
&.full-width {
grid-column: 1 / -1;
flex-direction: column;
.detail-label {
margin-bottom: 8px;
}
}
.detail-label {
min-width: 100px;
color: #606266;
font-weight: 500;
}
.detail-value {
flex: 1;
color: #303133;
word-break: break-all;
}
.detail-message,
.detail-exception {
width: 100%;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: #303133;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.detail-exception {
background-color: #fef0f0;
color: #f56c6c;
}
}
}
.detail-exception {
background-color: #fef0f0;
color: #f56c6c;
.news-list {
max-height: 500px;
overflow-y: auto;
.news-item {
padding: 16px;
margin-bottom: 16px;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #409eff;
transition: all 0.3s;
&:hover {
background-color: #ecf5ff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&:last-child {
margin-bottom: 0;
}
.news-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.news-index {
font-size: 12px;
font-weight: 600;
color: #409eff;
background-color: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
}
.news-title {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.5;
}
.news-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
font-size: 13px;
color: #909399;
span {
display: inline-flex;
align-items: center;
&:not(:last-child)::after {
content: '|';
margin-left: 16px;
color: #dcdfe6;
}
}
}
.news-summary {
margin-bottom: 12px;
padding: 12px;
background-color: #fff;
border-radius: 4px;
font-size: 14px;
color: #606266;
line-height: 1.6;
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.news-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
.crawl-time {
font-size: 12px;
color: #909399;
}
}
}
}
}

View File

@@ -55,9 +55,14 @@
</el-button>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && crawlerList.length === 0"
description="暂无爬虫配置"
style="margin-top: 40px"
/>
<!-- 爬虫配置列表 -->
<div class="crawler-list">
<div v-else class="crawler-list">
<el-row :gutter="20">
<el-col :span="8" v-for="crawler in crawlerList" :key="crawler.taskId">
<el-card class="crawler-card" shadow="hover">
@@ -146,13 +151,6 @@
</el-row>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && crawlerList.length === 0"
description="暂无爬虫配置"
style="margin-top: 40px"
/>
<!-- 分页 -->
<div class="pagination-container" v-if="total > 0">
<el-pagination
@@ -167,8 +165,8 @@
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑爬虫' : '新增爬虫'"
width="700px"
@close="resetForm"
@@ -176,45 +174,91 @@
<div class="form-content">
<div class="form-item">
<span class="form-label required">爬虫名称</span>
<el-input
v-model="formData.taskName"
<el-input
v-model="formData.taskName"
placeholder="请输入爬虫名称"
clearable
/>
</div>
<!-- 爬虫模板选择 -->
<div class="form-item">
<span class="form-label required">Bean名称</span>
<el-input
v-model="formData.beanName"
placeholder="请输入Spring Bean名称newsCrawlerTask"
clearable
/>
</div>
<div class="form-item">
<span class="form-label required">方法名称</span>
<el-input
v-model="formData.methodName"
placeholder="请输入要执行的方法名crawlNews"
clearable
/>
</div>
<div class="form-item">
<span class="form-label">方法参数</span>
<el-input
v-model="formData.methodParams"
type="textarea"
:rows="3"
placeholder="请输入方法参数JSON格式可选"
clearable
/>
<span class="form-label required">爬虫模板</span>
<el-select
v-model="selectedTemplate"
placeholder="请选择爬虫模板"
style="width: 100%"
>
<el-option
v-for="template in crawlerTemplates"
:key="template.name"
:label="template.name"
:value="template"
/>
</el-select>
<span class="form-tip">
示例{"source":"xinhua","category":"education"}
选择要使用的新闻爬虫类型
</span>
</div>
<!-- 爬取方法选择 -->
<div class="form-item" v-if="selectedTemplate">
<span class="form-label required">爬取方法</span>
<el-select
v-model="selectedMethod"
placeholder="请选择爬取方法"
style="width: 100%"
>
<el-option
v-for="method in selectedTemplate.methods"
:key="method.name"
:label="method.name"
:value="method"
/>
</el-select>
<span class="form-tip">
选择具体的爬取方式
</span>
</div>
<!-- 动态参数表单 -->
<div class="form-item" v-if="selectedMethod && selectedMethod.params && selectedMethod.params.length > 0">
<span class="form-label">方法参数</span>
<div class="params-container">
<div v-for="param in selectedMethod.params" :key="param.name" class="param-item">
<span class="param-label">
{{ param.description }}
<span class="param-type">({{ param.type }})</span>
</span>
<el-input
v-if="param.type === 'String'"
v-model="dynamicParams[param.name]"
:placeholder="`请输入${param.description}`"
clearable
/>
<el-input-number
v-else-if="param.type === 'Integer'"
v-model="dynamicParams[param.name]"
:placeholder="`请输入${param.description}`"
controls-position="right"
style="width: 100%"
/>
<el-switch
v-else-if="param.type === 'Boolean'"
v-model="dynamicParams[param.name]"
active-text=""
inactive-text=""
/>
</div>
</div>
</div>
<div class="form-item">
<span class="form-label required">Cron表达式</span>
<el-input
v-model="formData.cronExpression"
<el-input
v-model="formData.cronExpression"
placeholder="请输入Cron表达式"
clearable
>
@@ -231,8 +275,8 @@
</div>
<div class="form-item">
<span class="form-label">爬虫描述</span>
<el-input
v-model="formData.description"
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入爬虫描述"
@@ -249,11 +293,11 @@
</span>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
@@ -266,11 +310,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Search, Refresh, DocumentCopy, VideoPlay, VideoPause, Promotion, Edit, Delete } from '@element-plus/icons-vue';
import { crontabApi } from '@/apis/crontab';
import type { CrontabTask, PageParam } from '@/types';
import type { CrontabTask, CrontabItem, CrontabMethod, PageParam } from '@/types';
import { AdminLayout } from '@/views/admin';
defineOptions({
name: 'NewsCrawlerView'
@@ -281,6 +325,12 @@ const submitting = ref(false);
const crawlerList = ref<CrontabTask[]>([]);
const total = ref(0);
// 爬虫模板数据
const crawlerTemplates = ref<CrontabItem[]>([]);
const selectedTemplate = ref<CrontabItem | null>(null);
const selectedMethod = ref<CrontabMethod | null>(null);
const dynamicParams = ref<Record<string, any>>({});
// 搜索表单
const searchForm = reactive({
taskName: '',
@@ -300,7 +350,7 @@ const isEdit = ref(false);
// 表单数据
const formData = reactive<Partial<CrontabTask>>({
taskName: '',
taskGroup: 'NEWS_CRAWLER',
taskGroup: '',
beanName: '',
methodName: '',
methodParams: '',
@@ -311,21 +361,65 @@ const formData = reactive<Partial<CrontabTask>>({
description: ''
});
// 监听模板选择变化
watch(selectedTemplate, (newTemplate) => {
if (newTemplate) {
selectedMethod.value = null;
dynamicParams.value = {};
}
});
// 监听方法选择变化
watch(selectedMethod, (newMethod) => {
if (newMethod) {
dynamicParams.value = {};
// 遍历params数组提取默认值
if (newMethod.params && Array.isArray(newMethod.params)) {
newMethod.params.forEach(param => {
dynamicParams.value[param.name] = param.value;
});
}
}
});
// 加载爬虫模板
async function loadCrawlerTemplates() {
try {
const result = await crontabApi.getEnabledCrontabList();
if (result.success && result.dataList) {
crawlerTemplates.value = result.dataList;
} else {
ElMessage.error(result.message || '加载爬虫模板失败');
}
} catch (error) {
console.error('加载爬虫模板失败:', error);
ElMessage.error('加载爬虫模板失败');
}
}
// 加载爬虫列表
async function loadCrawlerList() {
loading.value = true;
try {
const filter: Partial<CrontabTask> = {
taskGroup: 'NEWS_CRAWLER'
taskGroup: ''
};
if (searchForm.taskName) filter.taskName = searchForm.taskName;
if (searchForm.status !== undefined) filter.status = searchForm.status;
const result = await crontabApi.getTaskPage(filter, pageParam);
if (result.success && result.dataList) {
const pageDomain = result.pageDomain!;
crawlerList.value = pageDomain.dataList!;
total.value = pageDomain.pageParam.totalElements!;
if (result.success) {
// 根据后端返回结构处理数据
if (result.pageDomain) {
crawlerList.value = result.pageDomain.dataList || [];
total.value = result.pageDomain.pageParam?.totalElements || 0;
} else if (result.dataList) {
crawlerList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
} else {
crawlerList.value = [];
total.value = 0;
}
} else {
ElMessage.error(result.message || '加载爬虫列表失败');
crawlerList.value = [];
@@ -371,6 +465,9 @@ function handleSizeChange(size: number) {
function handleAdd() {
isEdit.value = false;
resetFormData();
selectedTemplate.value = null;
selectedMethod.value = null;
dynamicParams.value = {};
dialogVisible.value = true;
}
@@ -378,6 +475,32 @@ function handleAdd() {
function handleEdit(row: CrontabTask) {
isEdit.value = true;
Object.assign(formData, row);
// 尝试解析methodParams来回填表单
if (row.methodParams) {
try {
const params = JSON.parse(row.methodParams);
// 如果有scriptPath,尝试匹配模板和方法
if (params.scriptPath) {
const template = crawlerTemplates.value.find(t =>
t.methods.some(m => m.path === params.scriptPath)
);
if (template) {
selectedTemplate.value = template;
const method = template.methods.find(m => m.path === params.scriptPath);
if (method) {
selectedMethod.value = method;
// 回填动态参数
const { scriptPath, ...restParams } = params;
dynamicParams.value = restParams;
}
}
}
} catch (error) {
console.warn('解析methodParams失败:', error);
}
}
dialogVisible.value = true;
}
@@ -495,12 +618,8 @@ async function handleSubmit() {
ElMessage.warning('请输入爬虫名称');
return;
}
if (!formData.beanName) {
ElMessage.warning('请输入Bean名称');
return;
}
if (!formData.methodName) {
ElMessage.warning('请输入方法名称');
if (!selectedTemplate.value || !selectedMethod.value) {
ElMessage.warning('请选择爬虫模板和爬取方法');
return;
}
if (!formData.cronExpression) {
@@ -508,14 +627,39 @@ async function handleSubmit() {
return;
}
// 验证必填参数
if (selectedMethod.value.params && Array.isArray(selectedMethod.value.params)) {
for (const param of selectedMethod.value.params) {
const value = dynamicParams.value[param.name];
if (param.type === 'String' && (!value || value.trim() === '')) {
ElMessage.warning(`请输入${param.description}`);
return;
}
if (param.type === 'Integer' && (value === undefined || value === null || value === '')) {
ElMessage.warning(`请输入${param.description}`);
return;
}
}
}
submitting.value = true;
try {
const data = {
const data = {
...formData,
taskGroup: 'NEWS_CRAWLER'
taskGroup: selectedTemplate.value.name, // 第一层name作为taskGroup
methodName: selectedMethod.value.name, // 第二层name作为methodName
methodParams: JSON.stringify({
scriptPath: selectedMethod.value.path,
...dynamicParams.value
})
};
let result;
console.log('📤 准备提交的数据:', data);
console.log('📤 taskGroup (模板名称):', data.taskGroup);
console.log('📤 methodName (方法名称):', data.methodName);
let result;
if (isEdit.value) {
result = await crontabApi.updateTask(data as CrontabTask);
} else {
@@ -546,7 +690,7 @@ function resetForm() {
function resetFormData() {
Object.assign(formData, {
taskName: '',
taskGroup: 'NEWS_CRAWLER',
taskGroup: '',
beanName: '',
methodName: '',
methodParams: '',
@@ -561,6 +705,7 @@ function resetFormData() {
// 初始化
onMounted(() => {
loadCrawlerList();
loadCrawlerTemplates();
});
</script>
@@ -569,7 +714,8 @@ onMounted(() => {
padding: 20px;
background-color: #fff;
border-radius: 4px;
max-height: 50%;
overflow: auto;
.header {
display: flex;
justify-content: space-between;
@@ -696,6 +842,35 @@ onMounted(() => {
color: #909399;
line-height: 1.6;
}
.params-container {
padding: 12px;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e4e7ed;
.param-item {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.param-label {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #606266;
font-weight: 500;
.param-type {
color: #909399;
font-weight: normal;
font-size: 12px;
}
}
}
}
}
}
}

View File

@@ -280,7 +280,7 @@ const isEdit = ref(false);
// 表单数据
const formData = reactive<Partial<CrontabTask>>({
taskName: '',
taskGroup: 'DEFAULT',
taskGroup: '',
beanName: '',
methodName: '',
methodParams: '',
@@ -301,9 +301,18 @@ const loadTaskList = async () => {
if (searchForm.status !== undefined) filter.status = searchForm.status;
const result = await crontabApi.getTaskPage(filter, pageParam);
if (result.success && result.dataList) {
taskList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
if (result.success) {
// 根据后端返回结构处理数据
if (result.pageDomain) {
taskList.value = result.pageDomain.dataList || [];
total.value = result.pageDomain.pageParam?.totalElements || 0;
} else if (result.dataList) {
taskList.value = result.dataList;
total.value = result.pageParam?.totalElements || 0;
} else {
taskList.value = [];
total.value = 0;
}
} else {
ElMessage.error(result.message || '加载任务列表失败');
taskList.value = [];
@@ -526,7 +535,7 @@ function resetForm() {
function resetFormData() {
Object.assign(formData, {
taskName: '',
taskGroup: 'DEFAULT',
taskGroup: '',
beanName: '',
methodName: '',
methodParams: '',

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>