Files
tougao_web/src/components/page/scholarCrawlersKeywords.vue
2026-03-27 13:24:04 +08:00

545 lines
17 KiB
Vue

<template>
<div class="keywords-container">
<transition name="el-zoom-in-top" appear>
<div class="page-header">
<div class="header-left">
<h2 class="page-title">{{ $t('crawlerKeywords.pageTitle') }}</h2>
<p class="page-desc">
<i class="el-icon-info"></i> {{ $t('crawlerKeywords.pageDesc') }}
</p>
</div>
<div class="header-actions">
<!-- <el-button
type="primary"
icon="el-icon-video-play"
@click="handleStartCrawl"
:loading="crawlLoading"
class="glow-button"
round
>
{{ $t('crawlerKeywords.startCrawl') }}
</el-button> -->
</div>
</div>
</transition>
<div class="add-section shadow-hover">
<el-input
v-model="newKeyword"
:placeholder="$t('crawlerKeywords.inputPlaceholder')"
class="add-input-custom"
prefix-icon="el-icon-collection-tag"
clearable
@keyup.enter.native="handleAdd"
/>
<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
:loading="addLoading"
class="add-btn"
>
{{ $t('crawlerKeywords.addKeyword') }}
</el-button>
</div>
<el-card shadow="never" class="table-card animated-card">
<div class="table-toolbar">
<el-input
v-model="searchText"
:placeholder="$t('crawlerKeywords.searchPlaceholder')"
prefix-icon="el-icon-search"
clearable
class="search-input"
@input="handleSearch"
/>
<el-button style="margin-left: 20px;"
type="primary"
icon="el-icon-search"
@click="handleSearchNow"
>
{{ $t('crawlerKeywords.searchBtn') }}
</el-button>
</div>
<el-table
:data="list"
v-loading="loading"
border
stripe
highlight-current-row
header-row-class-name="custom-header"
style="width: 100%"
class="modern-table"
>
<el-table-column prop="keyword" :label="$t('crawlerKeywords.colKeyword')" min-width="180">
<template slot-scope="scope">
<transition name="fade-slide" mode="out-in">
<span :key="'t'+scope.row.id" v-if="editingId !== scope.row.id" class="keyword-text">
{{ scope.row.keyword }}
</span>
<el-input
:key="'e'+scope.row.id"
v-else
v-model="editForm.keyword"
size="small"
v-focus
class="edit-input"
/>
</transition>
</template>
</el-table-column>
<el-table-column prop="category" :label="$t('crawlerKeywords.colCategory')" width="150">
<template slot-scope="scope">
<transition name="fade-slide" mode="out-in">
<div :key="editingId === scope.row.id">
<el-tag
v-if="editingId !== scope.row.id"
:type="scope.row.category ? 'success' : 'info'"
size="small"
effect="light"
>
{{ scope.row.category || 'Default' }}
</el-tag>
<el-input v-else v-model="editForm.category" size="small" />
</div>
</transition>
</template>
</el-table-column>
<el-table-column prop="state" :label="$t('crawlerKeywords.colStatus')" width="140" align="center">
<template slot-scope="scope">
<div class="status-container">
<span :class="['status-dot', 'pulse', 'status-' + scope.row.state]"></span>
<span :class="['status-label', 'text-' + scope.row.state]">
{{ getStateText(scope.row.state) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="expert_count" :label="$t('crawlerKeywords.colExpertCount')" width="120" align="center">
<template slot-scope="scope">
<span class="count-badge">{{ scope.row.expert_count }}</span>
</template>
</el-table-column>
<el-table-column prop="last_crawl_time" :label="$t('crawlerKeywords.colLastCrawl')" width="180" />
<el-table-column :label="$t('crawlerKeywords.colAction')" width="160" align="center" fixed="right">
<template slot-scope="scope">
<div class="action-wrapper">
<template v-if="editingId === scope.row.id">
<el-button type="text" icon="el-icon-circle-check" class="save-btn" @click="handleSaveEdit(scope.row)">
{{ $t('crawlerKeywords.save') }}
</el-button>
<el-button type="text" icon="el-icon-circle-close" class="cancel-btn" @click="editingId = null">
{{ $t('crawlerKeywords.cancel') }}
</el-button>
</template>
<template v-else>
<el-tooltip :content="$t('crawlerKeywords.refresh')" placement="top">
<el-button
type="text"
icon="el-icon-refresh"
:class="['refresh-icon', { 'is-spinning': scope.row._refreshing }]"
@click="handleRefresh(scope.row)"
/>
</el-tooltip>
<el-tooltip :content="$t('crawlerKeywords.edit')" placement="top">
<el-button type="text" icon="el-icon-edit-outline" @click="handleEdit(scope.row)" />
</el-tooltip>
<el-tooltip :content="$t('crawlerKeywords.delete')" placement="top">
<el-button type="text" icon="el-icon-delete" class="del-btn" @click="handleDelete(scope.row)" />
</el-tooltip>
</template>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="query.pageIndex"
:page-size="query.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script>
import Common from '@/components/common/common';
const API = {
list: 'api/expert_manage/getKeywordList',
add: 'api/expert_manage/addKeyword',
update: 'api/expert_manage/updateKeyword',
delete: 'api/expert_manage/deleteKeyword',
refresh: 'api/expert_manage/refreshKeyword',
startCrawl: 'api/expert_manage/startCrawl',
exportData: 'api/expert_manage/exportKeywords'
};
export default {
name: 'scholarCrawlersKeywords',
directives: {
focus: {
inserted: (el) => el.querySelector('input').focus()
}
},
data() {
return {
mediaUrl: Common.mediaUrl,
newKeyword: '',
searchText: '',
loading: false,
addLoading: false,
exportLoading: false,
crawlLoading: false,
editingId: null,
editForm: { keyword: '', category: '' },
query: { pageIndex: 1, pageSize: 10 },
list: [],
total: 0,
searchTimer: null
};
},
created() {
this.fetchList();
},
methods: {
getStateText(state) {
const map = {
running: this.$t('crawlerKeywords.stateRunning'),
paused: this.$t('crawlerKeywords.statePaused'),
error: this.$t('crawlerKeywords.stateError'),
done: this.$t('crawlerKeywords.stateDone')
};
return map[state] || state || '-';
},
async fetchList() {
this.loading = true;
try {
const params = {
keyword: this.searchText || '',
pageIndex: this.query.pageIndex,
pageSize: this.query.pageSize
};
const res = await this.$api.post(API.list, params);
if (res && res.code === 0 && res.data) {
this.list = (res.data.list || []).map(item => ({
...item,
_refreshing: false
}));
this.total = res.data.total || 0;
}
} catch (e) {
console.error(e);
} finally {
setTimeout(() => { this.loading = false; }, 300); // 增加微小延迟让加载动画更平滑
}
},
handleSearch() {
if (this.searchTimer) clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.query.pageIndex = 1;
this.fetchList();
}, 400);
},
handleSearchNow() {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
this.query.pageIndex = 1;
this.fetchList();
},
async handleAdd() {
const kw = (this.newKeyword || '').trim();
if (!kw) {
this.$message.warning(this.$t('crawlerKeywords.emptyKeywordWarn'));
return;
}
this.addLoading = true;
try {
const res = await this.$api.post(API.add, { keyword: kw });
if (res && res.code === 0) {
this.$notify({ title: 'Success', message: this.$t('crawlerKeywords.addSuccess'), type: 'success' });
this.newKeyword = '';
this.query.pageIndex = 1;
this.fetchList();
}
} finally {
this.addLoading = false;
}
},
handleEdit(row) {
this.editingId = row.id;
this.editForm = { keyword: row.keyword, category: row.category || '' };
},
async handleSaveEdit(row) {
if (!(this.editForm.keyword || '').trim()) {
this.$message.warning(this.$t('crawlerKeywords.emptyKeywordWarn'));
return;
}
try {
const res = await this.$api.post(API.update, {
id: String(row.id),
keyword: this.editForm.keyword.trim(),
category: this.editForm.category || ''
});
if (res && res.code === 0) {
this.$message.success(this.$t('crawlerKeywords.updateSuccess'));
this.editingId = null;
this.fetchList();
}
} catch (e) {
this.$message.error(this.$t('crawlerKeywords.updateFail'));
}
},
handleDelete(row) {
this.$confirm(`确定要删除关键词 "${row.keyword}" 吗?`, '安全警告', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
center: true,
roundButton: true
}).then(async () => {
const res = await this.$api.post(API.delete, { id: String(row.id) });
if (res && res.code === 0) {
this.$notify({ title: 'Deleted', type: 'success', duration: 2000 });
this.fetchList();
}
}).catch(() => {});
},
async handleRefresh(row) {
row._refreshing = true;
try {
const res = await this.$api.post(API.refresh, { id: String(row.id) });
if (res && res.code === 0) {
this.$message.success(this.$t('crawlerKeywords.refreshSuccess'));
this.fetchList();
}
} finally {
row._refreshing = false;
}
},
async handleStartCrawl() {
this.crawlLoading = true;
try {
const res = await this.$api.post(API.startCrawl, {});
if (res && res.code === 0) {
this.$message({ message: this.$t('crawlerKeywords.crawlStarted'), type: 'success', showClose: true });
this.fetchList();
}
} finally {
this.crawlLoading = false;
}
},
async handleExport() {
this.exportLoading = true;
try {
const res = await this.$api.post(API.exportData, {});
if (res && res.code === 0 && res.data.file_url) {
window.open(this.mediaUrl + res.data.file_url, '_blank');
}
} finally {
this.exportLoading = false;
}
},
handleSizeChange(size) {
this.query.pageSize = size;
this.fetchList();
},
handlePageChange(page) {
this.query.pageIndex = page;
this.fetchList();
}
}
};
</script>
<style scoped>
/* 容器及整体布局 */
.keywords-container {
padding: 10px;
margin: 0 auto;
background-color: #f8fafc;
/* min-height: 100vh; */
}
/* 头部样式 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
margin: 0 0 6px;
font-size: 24px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.5px;
}
.page-desc {
margin: 0;
font-size: 14px;
color: #64748b;
}
/* 新增区域:卡片化 */
.add-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.shadow-hover:hover {
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.05);
transform: translateY(-2px);
}
.add-input-custom ::v-deep .el-input__inner {
border-radius: 8px;
background-color: #f1f5f9;
border-color: transparent;
}
.add-input-custom ::v-deep .el-input__inner:focus {
background-color: #fff;
border-color: #409eff;
}
/* 按钮发光 */
.glow-button {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.glow-button:hover {
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
}
/* 表格卡片 */
.table-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 10px;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.search-input ::v-deep .el-input__inner {
border-radius: 20px;
}
/* 状态标签与脉冲 */
.status-container {
display: flex;
align-items: center;
justify-content: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.pulse {
animation: shadow-pulse 2s infinite;
}
@keyframes shadow-pulse {
0% { box-shadow: 0 0 0 0px rgba(103, 194, 58, 0.4); }
70% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
100% { box-shadow: 0 0 0 0px rgba(103, 194, 58, 0); }
}
.status-running { background: #67C23A; }
.status-paused { background: #E6A23C; }
.status-error { background: #F56C6C; animation-name: shadow-pulse-error; }
.status-done { background: #94a3b8; }
@keyframes shadow-pulse-error {
0% { box-shadow: 0 0 0 0px rgba(245, 108, 108, 0.4); }
70% { box-shadow: 0 0 0 8px rgba(245, 108, 108, 0); }
100% { box-shadow: 0 0 0 0px rgba(245, 108, 108, 0); }
}
.text-running { color: #67C23A; font-weight: 600; }
.text-error { color: #F56C6C; font-weight: 600; }
/* 表格定制 */
::v-deep .custom-header th {
/* background-color: #f8fafc !important; */
color: #475569;
font-weight: 600;
/* height: 50px; */
}
.count-badge {
background: #eff6ff;
color: #2563eb;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 13px;
}
/* 操作按钮 */
.action-wrapper .el-button {
font-size: 18px;
padding: 0 6px;
transition: all 0.2s;
}
.action-wrapper .el-button:hover {
transform: scale(1.2);
}
.del-btn { color: #ef4444 !important; }
.save-btn { color: #10b981 !important; font-size: 14px !important; }
.cancel-btn { color: #64748b !important; font-size: 14px !important; }
/* 刷新旋转 */
.is-spinning {
animation: rotate 1s linear infinite;
color: #409eff;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 过渡动画 */
.fade-slide-enter-active, .fade-slide-leave-active {
transition: all 0.25s ease;
}
.fade-slide-enter {
opacity: 0;
transform: translateY(5px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-5px);
}
.pagination-wrapper {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
::v-deep .el-card__body{
padding: 10px !important;
}
</style>