Includes search engine improvements, Alembic migrations, new services (pubmed_daily_update, query_expansion), frontend updates, and documentation sync.
242 lines
8.8 KiB
Vue
242 lines
8.8 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, h } from 'vue'
|
||
import { NDataTable, NButton, NTag, NAlert, NCard, NGrid, NGridItem, NPopover } from 'naive-ui'
|
||
import { api } from '../../api/client'
|
||
import { useToast } from '../../composables/useToast'
|
||
|
||
const toast = useToast()
|
||
const runs = ref<any[]>([])
|
||
const total = ref(0)
|
||
const loading = ref(true)
|
||
|
||
// Action loading states
|
||
const running = ref(false)
|
||
const refreshingCitations = ref(false)
|
||
const extractingPico = ref(false)
|
||
const backfillingDesign = ref(false)
|
||
const backfillingOA = ref(false)
|
||
|
||
function fmt(ts: string | null | undefined): string {
|
||
if (!ts) return '—'
|
||
const d = new Date(ts)
|
||
if (isNaN(d.getTime())) return ts
|
||
return d.toLocaleString('zh-CN', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit',
|
||
hour12: false,
|
||
}).replace(/\//g, '-')
|
||
}
|
||
|
||
onMounted(loadData)
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const { data } = await api.get('/admin/pipeline/runs', { params: { page_size: 50 } })
|
||
runs.value = data.items || []
|
||
total.value = data.total || 0
|
||
} catch (e) { toast.apiError(e, '加载管道记录失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
async function triggerPipeline() {
|
||
running.value = true
|
||
try {
|
||
await api.post('/admin/pipeline/run', { params: { mode: 'ftp' } })
|
||
toast.success('FTP 增量管道已触发')
|
||
await loadData()
|
||
} catch (e) { toast.apiError(e, '触发失败') }
|
||
finally { running.value = false }
|
||
}
|
||
async function refreshCitations() {
|
||
refreshingCitations.value = true
|
||
try { const { data } = await api.post('/admin/pipeline/refresh-citations'); toast.success('引用更新完成: ' + data.updated + ' 篇'); await loadData() }
|
||
catch (e) { toast.apiError(e, '刷新失败') }
|
||
finally { refreshingCitations.value = false }
|
||
}
|
||
async function extractPico() {
|
||
extractingPico.value = true
|
||
try { const { data } = await api.post('/admin/pipeline/extract-pico'); toast.success('PICO 提取完成: ' + data.processed + ' 篇'); await loadData() }
|
||
catch (e) { toast.apiError(e, '提取失败') }
|
||
finally { extractingPico.value = false }
|
||
}
|
||
async function backfillDesign() {
|
||
backfillingDesign.value = true
|
||
try { const { data } = await api.post('/admin/pipeline/backfill-study-designs'); toast.success('回填完成: ' + data.updated + ' 篇'); await loadData() }
|
||
catch (e) { toast.apiError(e, '回填失败') }
|
||
finally { backfillingDesign.value = false }
|
||
}
|
||
async function backfillOA() {
|
||
backfillingOA.value = true
|
||
try { const { data } = await api.post('/admin/pipeline/backfill-oa-text?limit=5000'); toast.success('OA全文回填完成: ' + data.fetched + ' 篇'); await loadData() }
|
||
catch (e) { toast.apiError(e, '回填失败') }
|
||
finally { backfillingOA.value = false }
|
||
}
|
||
|
||
function fmtDuration(started: string | null, completed: string | null): string {
|
||
if (!started || !completed) return '—'
|
||
const s = new Date(started).getTime()
|
||
const e = new Date(completed).getTime()
|
||
if (isNaN(s) || isNaN(e)) return '—'
|
||
const sec = Math.round((e - s) / 1000)
|
||
if (sec < 60) return sec + '秒'
|
||
return Math.floor(sec / 60) + '分' + (sec % 60) + '秒'
|
||
}
|
||
|
||
const typeLabels: Record<string, string> = {
|
||
daily_ftp_update: '📡 FTP 增量',
|
||
daily_pubmed_pipeline: '🔍 精搜(旧)',
|
||
weekly_broad_pipeline: '🌐 宽搜(旧)',
|
||
daily_mesh_retagger: '🏷️ Retagger(旧)',
|
||
baseline_import: '📦 基线导入',
|
||
manual_full: '⚡ 手动全量',
|
||
manual_majr: '⚡ 手动精搜',
|
||
manual_broad: '⚡ 手动宽搜',
|
||
manual_full_epmc: '⚡ EPMC 全量',
|
||
daily_citation_update: '📊 引用刷新',
|
||
}
|
||
|
||
const cols = [
|
||
{
|
||
title: '类型', key: 'type', width: 110, fixed: 'left' as const,
|
||
render: (r: any) => h(NTag, { size: 'tiny', type: r.type === 'daily_ftp_update' ? 'success' : 'default' },
|
||
() => typeLabels[r.type] || r.type),
|
||
},
|
||
{
|
||
title: '状态', key: 'status', width: 60,
|
||
render: (r: any) => h(NTag, { size: 'tiny', type: r.status === 'success' ? 'success' : 'error' },
|
||
() => r.status === 'success' ? '✅' : '❌'),
|
||
},
|
||
{ title: '开始', key: 'started', width: 130, render: (r: any) => fmt(r.started) },
|
||
{ title: '完成', key: 'completed', width: 130, render: (r: any) => fmt(r.completed) },
|
||
{
|
||
title: '耗时', key: 'duration', width: 70,
|
||
render: (r: any) => fmtDuration(r.started, r.completed),
|
||
},
|
||
{ title: '文件数', key: 'files_processed', width: 60 },
|
||
{ title: '总文献', key: 'total_articles', width: 60 },
|
||
{ title: '➕新增', key: 'articles_new', width: 60 },
|
||
{ title: '🔄更新', key: 'articles_updated', width: 60 },
|
||
{ title: '🗑️删除', key: 'articles_deleted', width: 60 },
|
||
{ title: '🚫过滤', key: 'articles_filtered', width: 60 },
|
||
{ title: '📨Feed', key: 'feeds_generated', width: 60 },
|
||
{
|
||
title: '检查点', key: 'processed_date', width: 90,
|
||
render: (r: any) => r.processed_date || '—',
|
||
},
|
||
{
|
||
title: '元数据', key: 'metadata', width: 140, ellipsis: true,
|
||
render: (r: any) => {
|
||
if (!r.metadata) return '—'
|
||
const parts: string[] = []
|
||
if (r.metadata.ftp_year) parts.push('年份:' + r.metadata.ftp_year)
|
||
if (r.metadata.last_sequence) parts.push('序号:' + r.metadata.last_sequence)
|
||
if (r.metadata.year) parts.push('基线:' + r.metadata.year)
|
||
return parts.join(' ')
|
||
},
|
||
},
|
||
{
|
||
title: '错误', key: 'error', width: 100, ellipsis: true,
|
||
render: (r: any) => {
|
||
if (!r.error) return '—'
|
||
return h(NPopover, { trigger: 'hover' }, () => h('pre', { style: 'max-width:400px;font-size:12px;white-space:pre-wrap;margin:0' }, r.error))
|
||
},
|
||
},
|
||
]
|
||
const scrollX = 1350
|
||
</script>
|
||
|
||
<template>
|
||
<div class="pipeline-view">
|
||
<div class="header-row">
|
||
<div class="header-left">
|
||
<h2>📡 数据管道</h2>
|
||
<span class="total-badge">共 {{ total }} 次运行</span>
|
||
</div>
|
||
<NButton type="primary" :loading="running" @click="triggerPipeline" class="sky-btn">🔄 FTP 增量更新</NButton>
|
||
</div>
|
||
|
||
<NAlert type="info" style="margin-bottom:16px" title="管道架构(FTP 每日增量)">
|
||
<div style="font-size:13px;line-height:1.8">
|
||
• 🟢 默认管道:每天 03:07 UTC FTP 下载当日更新文件 → 解析 → 过滤 → upsert<br>
|
||
• 2026-07-25 已从 E-utilities API 多路搜索切换为 FTP 每日更新文件<br>
|
||
• 旧管道(精搜 MAJR / 宽搜 / retagger)标记为 @deprecated,仅保留兼容
|
||
</div>
|
||
</NAlert>
|
||
|
||
<!-- Action cards -->
|
||
<n-grid :cols="4" :x-gap="12" responsive="screen" style="margin-bottom:20px">
|
||
<n-grid-item>
|
||
<NCard size="small" style="text-align:center">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">📊 刷新被引次数</div>
|
||
<NButton size="tiny" :loading="refreshingCitations" @click="refreshCitations" class="sky-btn">执行</NButton>
|
||
</NCard>
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<NCard size="small" style="text-align:center">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">📝 PICO 提取</div>
|
||
<NButton size="tiny" :loading="extractingPico" @click="extractPico" class="sky-btn">执行</NButton>
|
||
</NCard>
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<NCard size="small" style="text-align:center">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🏗️ 研究设计回填</div>
|
||
<NButton size="tiny" :loading="backfillingDesign" @click="backfillDesign" class="sky-btn">执行</NButton>
|
||
</NCard>
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<NCard size="small" style="text-align:center">
|
||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">📄 OA 全文回填</div>
|
||
<NButton size="tiny" :loading="backfillingOA" @click="backfillOA" class="sky-btn">执行</NButton>
|
||
</NCard>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
|
||
<!-- Run history table -->
|
||
<NCard title="运行历史" size="small">
|
||
<NDataTable
|
||
:columns="cols"
|
||
:data="runs"
|
||
:loading="loading"
|
||
size="small"
|
||
:bordered="false"
|
||
:max-height="600"
|
||
:scroll-x="scrollX"
|
||
:single-line="false"
|
||
/>
|
||
</NCard>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.pipeline-view {
|
||
padding: 16px;
|
||
}
|
||
.header-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.header-left h2 {
|
||
margin: 0;
|
||
white-space: nowrap;
|
||
}
|
||
.total-badge {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
background: var(--border-color);
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.pipeline-view { padding: 12px; }
|
||
.header-row { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||
.n-grid { grid-template-columns: 1fr 1fr; }
|
||
}
|
||
</style> |