373 lines
13 KiB
Vue
373 lines
13 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() {
|
||
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> |