545 lines
17 KiB
Vue
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> |