Files
tougao_web/src/components/page/crawlTaskMonitor.vue
2026-04-10 09:12:40 +08:00

373 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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() {
const raw = String(this.addForm.field || '').trim();
if (!raw) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
// 支持一次录入多个:按换行/逗号/分号拆分,去重
const fields = raw
.split(/[\r\n,;]+/g)
.map((s) => s.trim())
.filter(Boolean);
const uniqFields = Array.from(new Set(fields));
if (uniqFields.length === 0) return this.$message.warning(this.$t('crawlTask.enterKeyword'));
this.addLoading = true;
try {
const ok = [];
const fail = [];
for (const field of uniqFields) {
try {
const res = await this.$api.post('api/expert_manage/addFetchField', { field });
if (res && res.code === 0) {
ok.push(field);
if (this.addForm.runNow) {
// 不阻塞整体添加run once 失败也记录,不影响已添加成功
try {
await this.$api.post('api/expert_finder/fetchOneField', { field });
} catch (e) {
/* ignore */
}
}
} else {
fail.push({ field, msg: (res && res.msg) || this.$t('crawlTask.operationFail') });
}
} catch (e) {
fail.push({ field, msg: this.$t('crawlTask.operationFail') });
}
}
if (ok.length > 0) {
this.$message.success(`${this.$t('crawlTask.addKeywordSuccess')} (${ok.length}/${uniqFields.length})`);
}
if (fail.length > 0) {
this.$message.warning(`Failed: ${fail.map((x) => x.field).join(', ')}`);
}
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>