chore: batch commit remaining changes
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

Includes search engine improvements, Alembic migrations,
new services (pubmed_daily_update, query_expansion),
frontend updates, and documentation sync.
This commit is contained in:
34047007@qq.com
2026-07-27 08:35:12 +08:00
parent 35b0a5c565
commit 62ca8fa6b8
82 changed files with 314248 additions and 1519 deletions
+242
View File
@@ -0,0 +1,242 @@
<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>