Files
tougao_web/src/components/page/crawlTaskMonitor.vue
2026-04-03 09:58:39 +08:00

344 lines
12 KiB
Vue

<template>
<div class="monitor-container">
<div class="control-panel">
<div class="panel-left">
<el-radio-group v-model="filterStatus" size="small" class="status-group" @change="handleFilter">
<el-radio-button label="">{{ $t('crawlTask.allKeywords') }}</el-radio-button>
<el-radio-button label="0">{{ $t('crawlTask.enabled') }}</el-radio-button>
<el-radio-button label="1">{{ $t('crawlTask.disabled') }}</el-radio-button>
</el-radio-group>
<el-input
v-model="searchText"
:placeholder="$t('crawlTask.searchPlaceholder')"
prefix-icon="el-icon-search"
size="small"
class="search-box"
clearable
@keyup.enter.native="handleFilter"
/>
<el-button type="primary" size="small" icon="el-icon-search" @click="handleSearchClick">
{{ $t('crawlTask.searchBtn') }}
</el-button>
</div>
<div class="panel-right">
<el-button type="primary" size="small" icon="el-icon-plus" @click="openAddDialog">
{{ $t('crawlTask.addKeyword') }}
</el-button>
</div>
</div>
<div v-loading="loading" class="task-list">
<el-empty v-if="list.length === 0" :description="$t('crawlTask.emptyResult')" />
<div
v-for="item in list"
:key="item.id"
class="task-row"
:class="item.state === 'running' ? 'is-active' : 'is-paused'"
>
<div class="col-base">
<div class="status-indicator">
<div class="status-dot"></div>
</div>
<div class="info-content">
<div class="task-name-row">
<span class="task-id">#{{ item.id }}</span>
<span class="task-name">{{ item.task_name }}</span>
</div>
<div class="task-meta">
<el-tag size="mini" effect="plain" type="info">{{ item.source }}</el-tag>
<span class="time-label"><i class="el-icon-time"></i> {{ item.create_time }}</span>
</div>
</div>
</div>
<div class="col-metrics">
<div class="metric-block main">
<span class="m-label">{{ $t('crawlTask.metricExperts') }}</span>
<span class="m-value expert-count">{{ item.expert_count }}</span>
</div>
<div class="divider"></div>
<div class="metric-block">
<span class="m-label">{{ $t('crawlTask.metricPages') }}</span>
<span class="m-value">{{ item.last_page }} <small>/ {{ item.total_pages }}</small></span>
</div>
</div>
<div class="col-progress">
<div class="prog-text">
<span>{{ $t('crawlTask.progress') }}</span>
<span class="percent">{{ item.progress }}%</span>
</div>
<el-progress
:percentage="item.progress"
:show-text="false"
:stroke-width="6"
:color="progressStrokeColor(item)"
/>
</div>
<div class="col-action">
<div class="switch-wrapper">
<span class="state-text" :class="item.state">{{
item.state === 'running' ? $t('crawlTask.stateRunning') : $t('crawlTask.stateStopped')
}}</span>
<el-switch
v-model="item.state"
active-value="running"
inactive-value="paused"
active-color="#13ce66"
inactive-color="#c0c4cc"
@change="handleToggleTask(item)"
/>
</div>
<el-button
type="primary"
size="mini"
plain
icon="el-icon-refresh-right"
:loading="runOnceLoadingId === item.id"
@click="handleRunOnce(item)"
>
{{ $t('crawlTask.runOnceBtn') }}
</el-button>
</div>
</div>
</div>
<div class="pagination-container">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page.sync="currentPage"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<el-dialog :title="$t('crawlTask.addKeyword')" :visible.sync="addDialogVisible" width="460px" @closed="resetAddForm">
<el-form label-width="100px" size="small">
<el-form-item :label="$t('crawlTask.keyword')">
<el-input v-model="addForm.field" :placeholder="$t('crawlTask.keywordPlaceholder')" clearable />
</el-form-item>
<el-form-item :label="$t('crawlTask.runOnce')">
<el-switch v-model="addForm.runNow" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button size="small" @click="addDialogVisible = false">{{ $t('crawlTask.cancel') }}</el-button>
<el-button type="primary" size="small" :loading="addLoading" @click="submitAddKeyword">{{ $t('crawlTask.confirm') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
export default {
data() {
return {
loading: false,
searchText: '',
filterStatus: '',
currentPage: 1,
pageSize: 10,
total: 0,
list: [],
addDialogVisible: false,
addLoading: false,
runOnceLoadingId: null,
addForm: { field: '', runNow: false }
};
},
created() {
this.fetchList();
},
methods: {
/** 停止态用灰条,避免 exception 大红;运行中未满用蓝,满格用绿 */
progressStrokeColor(item) {
if (item.state !== 'running') return '#c0c4cc';
if (item.progress >= 100) return '#67c23a';
return '#409eff';
},
// 数据标准化逻辑
normalizeItem(item) {
const total = Number(item.total_pages || 0);
const current = Number(item.last_page || 0);
let progress = total > 0 ? (current / total) * 100 : 0;
return {
id: item.expert_fetch_id || item.id,
task_name: item.field || '-',
source: (item.source || 'PubMed'),
expert_count: item.expert_count || 0,
total_pages: total,
last_page: current,
progress: Number(progress.toFixed(1)),
state: Number(item.state) === 0 ? 'running' : 'paused',
create_time: item.ctime_text || '-'
};
},
async fetchList() {
this.loading = true;
try {
const params = {
keyword: this.searchText,
pageIndex: this.currentPage,
pageSize: this.pageSize,
state: this.filterStatus !== '' ? this.filterStatus : undefined
};
const res = await this.$api.post('api/expert_manage/getFetchList', params);
if (res.code === 0) {
this.list = res.data.list.map(this.normalizeItem);
this.total = res.data.total;
}
} finally {
this.loading = false;
}
},
async handleToggleTask(item) {
const isNowRunning = item.state === 'running';
const newState = isNowRunning ? '0' : '1';
try {
const res = await this.$api.post('api/expert_manage/editFetchField', {
expert_fetch_id: item.id,
state: newState
});
if (res.code === 0) {
this.$message.success(isNowRunning ? this.$t('crawlTask.taskRunningMsg') : this.$t('crawlTask.taskStoppedMsg'));
} else {
item.state = isNowRunning ? 'paused' : 'running';
this.$message.error(res.msg || this.$t('crawlTask.operationFail'));
}
} catch (e) {
item.state = isNowRunning ? 'paused' : 'running';
}
},
async handleRunOnce(item) {
this.runOnceLoadingId = item.id;
try {
const res = await this.$api.post('/api/expert_finder/fetchOneField', { field: item.task_name });
if (res.code === 0) {
this.$message.success(this.$t('crawlTask.runOnceQueued'));
this.fetchList();
}
} finally {
this.runOnceLoadingId = null;
}
},
// 其余分页/弹窗逻辑
handleFilter() { this.currentPage = 1; this.fetchList(); },
handleSearchClick() { this.handleFilter(); },
handleSizeChange(val) { this.pageSize = val; this.fetchList(); },
handlePageChange(val) { this.currentPage = val; this.fetchList(); },
openAddDialog() { this.addDialogVisible = true; },
resetAddForm() { this.addForm = { field: '', runNow: false }; this.addLoading = false; },
async submitAddKeyword() {
if (!this.addForm.field.trim()) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
this.addLoading = true;
try {
const res = await this.$api.post('api/expert_manage/addFetchField', { field: this.addForm.field });
if (res.code === 0) {
this.$message.success(this.$t('crawlTask.addKeywordSuccess'));
if (this.addForm.runNow) {
await this.$api.post('api/expert_finder/fetchOneField', { field: this.addForm.field });
}
this.addDialogVisible = false;
this.fetchList();
}
} finally { this.addLoading = false; }
}
}
};
</script>
<style scoped>
.monitor-container {
padding: 20px;
background: #f5f7fa;
min-height: calc(100vh - 100px);
}
/* 控制面板 */
.control-panel {
background: #fff;
padding: 12px 20px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
}
.panel-left { display: flex; gap: 15px; }
.search-box { width: 220px; }
/* 任务行卡片设计 */
.task-row {
background: #fff;
margin-bottom: 12px;
padding: 4px 15px;
border-radius: 10px;
display: flex;
align-items: center;
transition: all 0.3s;
border-left: 5px solid #dcdfe6;
}
.task-row:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.task-row.is-active { border-left-color: #409eff; }
.task-row.is-paused { background: #fbfbfc; border-left-color: #909399; opacity: 0.9; }
/* 1. 基础信息列 */
.col-base { flex: 1.5; display: flex; align-items: flex-start; gap: 15px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #909399; margin-top: 6px; }
.is-active .status-dot { background: #13ce66; box-shadow: 0 0 8px #13ce66; animation: pulse 2s infinite; }
.task-id { font-family: monospace; color: #909399; font-size: 12px; margin-right: 8px; }
.task-name { font-size: 14px; font-weight: bold; color: #303133; }
.task-meta { margin-top: 6px; display: flex; align-items: center; gap: 12px; font-size: 12px; color: #909399; }
/* 2. 指标展示列 */
.col-metrics { flex: 1.2; display: flex; align-items: center; justify-content: space-around; }
.metric-block { text-align: center; }
.m-label { font-size: 12px; color: #909399; display: block; margin-bottom: 4px; }
.m-value { font-size: 18px; font-weight: 600; color: #606266; }
.m-value small { font-weight: normal; font-size: 12px; color: #888; }
.expert-count { color: #006699; font-size: 16px; } /* 专家数高亮 */
.divider { width: 1px; height: 35px; background: #ebeef5; }
/* 3. 进度条列 */
.col-progress { flex: 1.2; padding: 0 30px; }
.prog-text { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px; color: #606266; }
.percent { font-weight: bold; color: #409eff; }
/* 4. 操作列 */
.col-action { flex: 1.1; display: flex; align-items: center; justify-content: flex-end; gap: 14px; }
.switch-wrapper {
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
margin-right: 6px;
transform: translateX(-6px);
}
.state-text { font-size: 11px; font-weight: bold; }
.state-text.running { color: #13ce66; }
.state-text.paused { color: #909399; }
/* 分页 */
.pagination-container { margin-top: 25px; text-align: right; }
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>