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
+35 -5
View File
@@ -139,15 +139,45 @@ html.dark .sky-btn {
--n-text-color: #fff !important;
--n-text-color-hover: #fff !important;
--n-text-color-pressed: #fff !important;
--n-text-color-focus: #fff !important;
--n-border: 1px solid #4a7a9a !important;
--n-border-hover: 1px solid #6ab0e0 !important;
--n-border-pressed: 1px solid #8ac0ff !important;
--n-border-focus: 1px solid #8ac0ff !important;
}
html.dark .sky-btn.n-button--ghost {
--n-text-color: #6ab0e0 !important;
--n-text-color-hover: #8ac8ff !important;
--n-text-color-pressed: #8ac8ff !important;
--n-border: 1px solid #4a7a9a !important;
--n-border-hover: 1px solid #6ab0e0 !important;
--n-border-pressed: 1px solid #8ac0ff !important;
}
html.dark .sky-btn.n-button--ghost {
--n-text-color: #6ab0e0 !important;
--n-border: 1px solid #4a7a9a !important;
--n-border-hover: 1px solid #6ab0e0 !important;
--n-border-pressed: 1px solid #8ac0ff !important;
/* Dark mode NSelect text visibility */
html.dark .n-select .n-base-selection-label {
--n-text-color: #e0e0e0 !important;
}
/* Dark mode NSelect popup — hover/selected 选项文字颜色 */
html.dark .n-base-select-menu .n-base-select-option--hover {
color: #fff !important;
}
html.dark .n-base-select-menu .n-base-select-option--selected {
color: #fff !important;
}
html.dark .n-base-select-menu .n-base-select-option--hover .n-base-select-option__check,
html.dark .n-base-select-menu .n-base-select-option--selected .n-base-select-option__check {
color: #fff !important;
}
html.dark .sky-btn:focus,
html.dark .sky-btn:focus-visible,
html.dark .sky-btn:active {
color: #fff !important;
}
html.dark ::selection {
background: #1a6bb0;
color: #fff;
}
html.dark .sky-btn.n-button--error-type {
--n-color: #4a2020 !important;
@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { NTag } from 'naive-ui'
import { computeStudyTypes } from '../../constants/studyTypes'
import type { StudyDesign, TrialReg, RctDetection } from '../../types'
import type { LiteratureItem } from '../../types'
import type { LiteratureItem, DisplaySettings } from '../../types'
import { useMessage } from 'naive-ui'
import { copyToClipboard } from '../../utils/clipboard'
@@ -13,11 +13,14 @@ const props = defineProps<{
showSave?: boolean
savedPmids?: Set<number>
searchQuery?: string
displaySettings?: DisplaySettings
}>()
const emit = defineEmits(['detail', 'preview', 'save', 'dismiss'])
const message = useMessage()
const ds = computed(() => props.displaySettings || {} as DisplaySettings)
function onSave(e: Event) { e.stopPropagation(); emit('save', props.item) }
function onDismiss(e: Event) { e.stopPropagation(); emit('dismiss', props.item) }
@@ -105,14 +108,30 @@ const nctId = computed(() => {
})
// ── 搜索关键词高亮 ──
// 从查询中提取纯文本词(去掉 PubMed 字段标签如 [TI]、[AB] 等)
function extractPlainText(q: string): string {
return q
.replace(/\[[\w-]+\]/g, '')
.replace(/"?\b(AND|OR|NOT)\b"?/gi, '')
.replace(/"/g, '')
.replace(/\s+/g, ' ')
.trim()
}
const highlightedTitle = computed(() => {
const title = props.item.title || ''
const q = props.searchQuery?.trim()
if (!q) return ''
const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const parts = title.split(new RegExp(`(${escaped})`, 'gi'))
const raw = props.searchQuery?.trim()
if (!raw) return ''
const plain = extractPlainText(raw)
if (!plain) return ''
// 拆分为独立词项,逐词高亮(多词查询不拼成一个连写短语)
const terms = plain.split(/\s+/).filter(t => t.length > 0)
if (terms.length === 0) return ''
const escaped = terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
// 单字符词添加 \b 词边界避免过匹配("A" 不匹配每个字母 "a"
const pattern = escaped.map(e => e.length === 1 ? `\\b${e}\\b` : e).join('|')
const parts = title.split(new RegExp(`(${pattern})`, 'gi'))
return parts.map((part: string) =>
part.toLowerCase() === escaped.toLowerCase()
terms.some(t => part.toLowerCase() === t.toLowerCase())
? `<mark style="background:#fff3b0;padding:0 2px;border-radius:2px">${escapeHtml(part)}</mark>`
: escapeHtml(part)
).join('')
@@ -127,67 +146,69 @@ function escapeHtml(s: string) {
<div class="lit-card" :class="{ compact, 'is-read': isRead }">
<!-- 标题徽章内联点击跳转详情 -->
<div class="lit-title lit-title-link" @click.stop="emit('detail', item)">
<template v-if="ds.showBadges">
<NTag v-if="isNew" size="tiny" type="info" :bordered="false" class="badge-inline">🆕</NTag>
<NTag v-if="isUpdated" size="tiny" type="success" :bordered="false" class="badge-inline">🔄</NTag>
<NTag v-if="item.retracted" size="tiny" type="error" :bordered="false" class="badge-inline">已撤稿</NTag>
<NTag v-if="item.is_negative_result && !item.retracted" size="tiny" type="warning" :bordered="false" class="badge-inline">阴性结果</NTag>
<NTag v-if="rctInfo?.confidence === 'confirmed'" size="tiny" type="error" :bordered="false" class="badge-inline">RCT </NTag>
<NTag v-else-if="rctInfo?.confidence === 'suspected'" size="tiny" type="info" :bordered="false" class="badge-inline">RCT ?</NTag>
</template>
<span v-if="highlightedTitle" v-html="highlightedTitle"></span>
<span v-else>{{ item.title || '' }}</span>
</div>
<!-- 元信息行 -->
<div class="lit-meta">
<span class="lit-authors">{{ item.first_author || '' }} et al.</span>
<span v-if="item.affiliation" class="affil">🏛 {{ item.affiliation }}</span>
<span v-if="item.journal" class="meta-sep">|</span>
<strong class="lit-journal">{{ item.journal }}</strong>
<NTag v-if="tierType" :type="tierType" size="tiny" :bordered="false" class="tier-tag">Q{{ item.journal_tier }}</NTag>
<span v-if="ds.showAuthors" class="lit-authors">{{ item.first_author || '' }} et al.</span>
<span v-if="ds.showAffiliation && item.affiliation" class="affil">🏛 {{ item.affiliation }}</span>
<span v-if="ds.showJournal && item.journal" class="meta-sep">|</span>
<strong v-if="ds.showJournal && item.journal" class="lit-journal">{{ item.journal }}</strong>
<NTag v-if="ds.showJournal && tierType" :type="tierType" size="tiny" :bordered="false" class="tier-tag">Q{{ item.journal_tier }}</NTag>
<span class="meta-sep">|</span>
<span v-if="displayDate" class="lit-date">{{ displayDate }}</span>
</div>
<!-- DOI / PMID / 研究类型 / OA / 引用 -->
<div class="lit-doi" v-if="studyTypes.length || item.doi || item.pmid || item.is_oa">
<template v-if="studyTypes.length">
<div class="lit-doi" v-if="(ds.showStudyTypes && (studyTypes.length || designLabel)) || (item.doi && ds.showDoi) || (item.pmid && ds.showPmid) || (item.is_oa && ds.showStudyTypes) || (item.cited_by_count && ds.showCitedBy) || (nctId && ds.showStudyTypes)">
<template v-if="ds.showStudyTypes && studyTypes.length">
<NTag v-for="st in studyTypes" :type="st.type" size="tiny" :bordered="false">{{ st.label }}</NTag>
<NTag v-if="designLabel" type="info" size="tiny" :bordered="false">{{ designLabel }}</NTag>
<span class="meta-sep-lit">|</span>
</template>
<template v-if="item.is_oa">
<template v-if="ds.showStudyTypes && item.is_oa">
<NTag type="success" size="tiny" :bordered="false">🔓 免费全文</NTag>
<span class="meta-sep-lit">|</span>
</template>
<template v-if="item.doi">
<template v-if="ds.showDoi && item.doi">
<span class="doi-label">DOI:</span>
<a :href="'https://doi.org/' + item.doi" target="_blank" rel="noopener" class="doi-link" @click.stop>{{ item.doi }}</a>
<span class="doi-copy-btn" @click.stop="copyDoi" title="复制 DOI">📋</span>
</template>
<template v-if="item.doi && item.pmid">
<template v-if="ds.showDoi && item.doi && ds.showPmid && item.pmid">
<span class="meta-sep-lit">|</span>
</template>
<template v-if="item.pmid">
<template v-if="ds.showPmid && item.pmid">
<span class="doi-label">PMID:</span>
<span class="pmid-text">{{ item.pmid }}</span>
<span class="doi-copy-btn" @click.stop="copyPmid" title="复制 PMID">📋</span>
</template>
<span v-if="item.cited_by_count" class="meta-sep-lit">|</span>
<span v-if="item.cited_by_count" class="cited-count">📊 被引 {{ item.cited_by_count }}</span>
<span v-if="nctId" class="meta-sep-lit">|</span>
<a v-if="nctId" :href="'https://clinicaltrials.gov/study/' + nctId" target="_blank" rel="noopener" class="nct-link" @click.stop>{{ nctId }}</a>
<span v-if="ds.showCitedBy && item.cited_by_count" class="meta-sep-lit">|</span>
<span v-if="ds.showCitedBy && item.cited_by_count" class="cited-count">📊 被引 {{ item.cited_by_count }}</span>
<span v-if="ds.showStudyTypes && nctId" class="meta-sep-lit">|</span>
<a v-if="ds.showStudyTypes && nctId" :href="'https://clinicaltrials.gov/study/' + nctId" target="_blank" rel="noopener" class="nct-link" @click.stop>{{ nctId }}</a>
</div>
<!-- 标签 -->
<div class="lit-tags" v-if="item.tags?.length">
<div class="lit-tags" v-if="ds.showTags && item.tags?.length">
<NTag v-for="t in item.tags.slice(0,6)" :key="t.id||t.name_zh" size="tiny" :bordered="false">{{ t.name_zh || t.name_en }}</NTag>
</div>
<!-- AI 摘要 -->
<div v-if="item.ai_summary && !compact" class="lit-ai">{{ item.ai_summary }}</div>
<div v-if="ds.showAiSummary && item.ai_summary && !compact" class="lit-ai">{{ item.ai_summary }}</div>
<!-- 操作按钮 -->
<div class="lit-actions" v-if="!compact">
<div class="lit-actions" v-if="ds.showActions && !compact">
<button class="action-btn expand-btn" @click.stop="emit('preview', item)" title="快速预览"></button>
<button class="action-btn save-btn" :class="{ saved: isSaved }" @click="onSave">
{{ isSaved ? '' : '' }} 收藏
@@ -0,0 +1,46 @@
import { reactive, watch } from 'vue'
import type { DisplaySettings } from '../types'
const STORAGE_KEY = 'search:displaySettings'
const defaults: DisplaySettings = {
showAuthors: true,
showAffiliation: true,
showJournal: true,
showPmid: true,
showDoi: true,
showAbstract: true,
showStudyTypes: true,
showTags: true,
showCitedBy: true,
showAiSummary: true,
showActions: true,
showBadges: true,
showSummary: true,
showPubmed: true,
}
function loadSettings(): DisplaySettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
return { ...defaults, ...parsed }
}
} catch { /* ignore */ }
return { ...defaults }
}
const settings = reactive<DisplaySettings>(loadSettings())
watch(
() => ({ ...settings }),
(val) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
},
{ deep: true },
)
export function useDisplaySettings() {
return settings
}
@@ -0,0 +1,113 @@
import { ref } from 'vue'
const STORAGE_KEY = 'pub_search_history'
const MAX_ENTRIES = 50
export interface HistoryEntry {
id: string
query: string
expanded_query: string
result_count: number | null
timestamp: string
}
function loadAll(): HistoryEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveAll(entries: HistoryEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries))
}
/** 把 #N 引用替换为 expanded_query(加括号保护优先级) */
export function resolveQuery(query: string, entries: HistoryEntry[]): string {
return query.replace(/#(\d+)/g, (_m, num) => {
const found = entries.find(e => e.id === `#${num}`)
return found ? `(${found.expanded_query})` : _m
})
}
/** 递归展开所有 #N 引用为纯查询 */
export function expandQuery(query: string, entries: HistoryEntry[]): string {
let prev = ''
let current = query
for (let i = 0; i < 10; i++) {
if (current === prev) break
prev = current
current = resolveQuery(current, entries)
}
return current
}
export function useSearchHistory() {
const entries = ref<HistoryEntry[]>(loadAll())
function getAll(): HistoryEntry[] {
return [...entries.value]
}
function add(query: string, resultCount?: number | null): HistoryEntry {
const all = loadAll()
const nextNum = all.length > 0
? Math.max(...all.map(e => parseInt(e.id.slice(1), 10))) + 1
: 1
const id = `#${nextNum}`
const expanded = expandQuery(query, all)
const entry: HistoryEntry = {
id,
query,
expanded_query: expanded,
result_count: resultCount ?? null,
timestamp: new Date().toISOString(),
}
if (all.length >= MAX_ENTRIES) {
all.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
all.shift()
}
all.push(entry)
saveAll(all)
entries.value = [...all]
return entry
}
function remove(id: string) {
const all = loadAll().filter(e => e.id !== id)
saveAll(all)
entries.value = all
}
function clear() {
localStorage.removeItem(STORAGE_KEY)
entries.value = []
}
function download() {
const all = loadAll()
const lines = all.map(e =>
`${e.id}\t${e.result_count ?? ''}\t${e.timestamp}\t${e.query}`
)
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `pubmed-search-history-${new Date().toISOString().slice(0, 10)}.tsv`
a.click()
URL.revokeObjectURL(url)
}
return {
entries,
getAll,
add,
remove,
clear,
download,
}
}
+11
View File
@@ -12,6 +12,7 @@ const routes = [
{ path: 'feed', redirect: '/' },
{ path: 'literature/:pmid', name: 'public-detail', component: () => import('../views/public/LiteratureDetailView.vue') },
{ path: 'journals', name: 'journals', component: () => import('../views/public/JournalBrowseView.vue') },
{ path: 'search', name: 'public-search', component: () => import('../views/app/SearchView.vue') },
{ path: 'cancers', name: 'cancers', component: () => import('../views/public/CancerBrowseView.vue') },
{ path: 'drugs', name: 'drugs', component: () => import('../views/public/DrugApprovalsView.vue') },
{ path: 'guidelines', name: 'guidelines', component: () => import('../views/public/GuidelinesView.vue') },
@@ -19,6 +20,7 @@ const routes = [
{ path: 'guidelines/plans', name: 'treatment-plans', component: () => import('../views/public/TreatmentPlansView.vue') },
{ path: 'pricing', name: 'pricing', component: () => import('../views/public/PricingView.vue') },
{ path: 'about', name: 'about', component: () => import('../views/public/AboutView.vue') },
{ path: 'advanced-pub-search', name: 'advanced-pub-search', component: () => import('../views/public/AdvancedPubSearchView.vue') },
{ path: 'help', name: 'help', component: () => import('../views/public/HelpView.vue') },
],
},
@@ -109,6 +111,15 @@ router.beforeEach(async (to, _from, next) => {
next({ name: 'detail', params: { pmid: to.params.pmid }, query: to.query })
return
}
// 公开搜索 /search 和 App 内搜索 /app/search 互通
if (to.name === 'public-search' && auth.isAuthenticated) {
next({ name: 'search', query: to.query })
return
}
if (to.name === 'search' && !auth.isAuthenticated) {
next({ name: 'public-search', query: to.query })
return
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else if (to.meta.requiresSuperuser && !auth.user?.platform_role) {
+4
View File
@@ -27,6 +27,10 @@ export const themeOverrides: GlobalThemeOverrides = {
},
Input: {
paddingMedium: '0 10px',
border: '1px solid #bbb',
borderHover: '1px solid #2471a3',
borderFocus: '1px solid #2471a3',
boxShadowFocus: '0 0 0 2px rgba(26,82,118,0.1)',
},
}
+47 -1
View File
@@ -315,19 +315,47 @@ export interface TagOption {
[key: string]: unknown
}
/** 用户保存的自定义筛选器 */
export interface SavedFilter {
id: string
name: string
query_string: string
sort_order: number
}
/** 搜索请求体 */
export interface SearchRequestBody {
query: string
field: string
field?: string
page: number
page_size: number
sort?: string
cursor_date?: string
cursor_id?: string
year_from?: number
year_to?: number
date_from?: string
date_to?: string
journal_tiers?: string[]
tag_ids?: string[]
pub_types?: string[]
is_oa?: boolean | null
language?: string | null
languages?: string[]
nlm_subsets?: string[]
retracted?: string
negative_result?: string
precision_mode?: string
// PubMed 筛选器
has_abstract?: boolean
is_free_full_text?: boolean
has_full_text?: boolean
has_associated_data?: boolean
species?: string[]
sex?: string[]
age?: string[]
medline_only?: boolean
exclude_preprints?: boolean
}
/** 个人文件夹 */
@@ -349,3 +377,21 @@ export interface PaginatedResponse<T> {
page?: number
page_size?: number
}
/** 搜索结果卡片显示设置 */
export interface DisplaySettings {
showAuthors: boolean
showAffiliation: boolean
showJournal: boolean
showPmid: boolean
showDoi: boolean
showAbstract: boolean
showStudyTypes: boolean
showTags: boolean
showCitedBy: boolean
showAiSummary: boolean
showActions: boolean
showBadges: boolean
showSummary: boolean
showPubmed: boolean
}
+134 -73
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { NDataTable, NButton, NTag, NAlert, NCard, NGrid, NGridItem } from 'naive-ui'
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
@@ -13,18 +14,16 @@ const running = ref(false)
const refreshingCitations = ref(false)
const extractingPico = ref(false)
const backfillingDesign = ref(false)
const generatingSummary = ref(false)
const backfillingOA = ref(false)
const retagging = ref(false)
function fmtPipelineTime(ts: string | null | undefined): string {
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', second: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
}).replace(/\//g, '-')
}
@@ -32,15 +31,21 @@ function fmtPipelineTime(ts: string | null | undefined): string {
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get('/admin/pipeline/runs'); runs.value = data.items || [] }
catch (e) { toast.apiError(e, '加载管道记录失败') }
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'); toast.success('管道已触发'); await loadData() }
catch (e) { toast.apiError(e, '触发失败') }
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() {
@@ -61,49 +66,101 @@ async function backfillDesign() {
catch (e) { toast.apiError(e, '回填失败') }
finally { backfillingDesign.value = false }
}
async function generateSummary() {
generatingSummary.value = true
try { const { data } = await api.post('/admin/ai/summarize'); toast.success('摘要生成完成: ' + data.processed + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '生成失败') }
finally { generatingSummary.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 }
}
async function retagTags() {
retagging.value = true
try { const { data } = await api.post('/admin/pipeline/retag-tags'); toast.success('MeSH 回填完成: ' + data.tagged_articles + ' 篇 (' + data.tags_added + ' 个标签)'); await loadData() }
catch (e) { toast.apiError(e, '回填失败') }
finally { retagging.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: 120 },
{ title: '状态', key: 'status', width: 70, render: (r: any) => h(NTag, { size:'tiny', type: r.status==='success'?'success':'error' }, r.status) },
{ title: '新增', key: 'new', width: 50 },
{ title: '打标', key: 'filtered', width: 50 },
{ title: 'Feed', key: 'feeds', width: 50 },
{ title: '错误', key: 'error', width: 120, ellipsis: true, render: (r: any) => r.error || '—' },
{ title: '开始', key: 'started', width: 140, render: (r: any) => fmtPipelineTime(r.started) },
{ title: '完成', key: 'completed', width: 140, render: (r: any) => fmtPipelineTime(r.completed) },
{
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 = 740
const scrollX = 1350
</script>
<template>
<div class="pipeline-view">
<div class="header-row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">📡 数据管道</h2>
<NButton type="primary" :loading="running" @click="triggerPipeline" class="sky-btn">🔄 手动触发管道</NButton>
<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="PubMed E-utilities API · 自动 MeSH 打标 · 自动生成 Feed">
<NAlert type="info" style="margin-bottom:16px" title="管道架构(FTP 每日增量)">
<div style="font-size:13px;line-height:1.8">
每天 03:07 UTC 自动精搜MAJR· 周日 03:37 UTC 自动宽搜<br>
引用刷新 05:13 UTC 每日自动 · 11月中12月中 MeSH 年更期 in-process 文献仅宽搜覆盖
🟢 默认管道每天 03:07 UTC FTP 下载当日更新文件 解析 过滤 upsert<br>
2026-07-25 已从 E-utilities API 多路搜索切换为 FTP 每日更新文件<br>
旧管道精搜 MAJR / 宽搜 / retagger标记为 @deprecated仅保留兼容
</div>
</NAlert>
@@ -111,7 +168,7 @@ const scrollX = 740
<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>
<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>
@@ -123,59 +180,63 @@ const scrollX = 740
</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>
<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">🤖 AI 摘要生成</div>
<NButton size="tiny" :loading="generatingSummary" @click="generateSummary" 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-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🏷 MeSH 标签回填</div>
<NButton size="tiny" :loading="retagging" @click="retagTags" 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" :paginate="{ pageSize: 20 }" :scroll-x="scrollX" />
<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>
@media (max-width: 768px) {
.pipeline-view {
padding: 12px;
}
.header-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-row h2 {
font-size: 20px;
}
.n-grid {
grid-template-columns: 1fr 1fr;
}
.n-card:has(.n-data-table) {
overflow-x: auto;
}
.pipeline-view {
padding: 16px;
}
</style>
.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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,716 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NInput, NSelect, NIcon, useMessage } from 'naive-ui'
import { SearchOutline, CodeSlashOutline, AddOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useSearchHistory } from '../../composables/useSearchHistory'
const router = useRouter()
const message = useMessage()
const { entries: historyEntries, add, remove, clear: clearHistory, download: downloadHistory } = useSearchHistory()
// ── Query Box ──
const queryText = ref('')
// ── Translation Table ──
const fieldLabelMap: Record<string, string> = {
TI: 'Title',
TIAB: 'Title/Abstract',
AB: 'Abstract',
AU: 'Author',
AD: 'Affiliation',
CN: 'Author - Corporate',
FAU: 'Author - First',
LAU: 'Author - Last',
AUID: 'Author - Identifier',
TA: 'Journal',
MH: 'MeSH Terms',
MAJR: 'MeSH Major Topic',
SH: 'MeSH Subheading',
PT: 'Publication Type',
DP: 'Publication Date',
PMID: 'PubMed ID',
DOI: 'DOI',
LA: 'Language',
TW: 'Text Word',
BOOK: 'Book',
COIS: 'Conflict of Interest Statements',
DCOM: 'Date - Completion',
CRDT: 'Date - Create',
EDAT: 'Date - Entry',
MHDA: 'Date - MeSH',
LR: 'Date - Modification',
RN: 'EC/RN Number',
ED: 'Editor',
FILTER: 'Filter',
GR: 'Grants and Funding',
ISBN: 'ISBN',
IR: 'Investigator',
IP: 'Issue',
LID: 'Location ID',
OT: 'Other Term',
PG: 'Pagination',
PA: 'Pharmacological Action',
PUBN: 'Publisher',
SI: 'Secondary Source ID',
PS: 'Subject - Personal Name',
NM: 'Supplementary Concept',
TT: 'Transliterated Title',
VI: 'Volume',
}
interface TransItem {
field: string
value: string
}
// 模块级常量的 validTags Set,避免每次 validateQuery 时重新创建
const _VALID_TAGS = new Set(['AB', 'AD', 'AU', 'CN', 'FAU', 'AUID', 'LAU', 'BOOK', 'COIS', 'DCOM', 'CRDT', 'EDAT', 'MHDA', 'LR', 'DP', 'DOI', 'RN', 'ED', 'FILTER', 'GR', 'ISBN', 'IR', 'IP', 'TA', 'LA', 'LID', 'MAJR', 'SH', 'MH', 'OT', 'PG', 'PA', 'PMID', 'PT', 'PUBN', 'SI', 'PS', 'NM', 'TW', 'TI', 'TIAB', 'TT', 'VI'])
function useValidTags() { return _VALID_TAGS }
const translated = computed(() => {
const items: TransItem[] = []
const seen = new Set<string>()
// 先解析 queryText 中的 #N 引用
const displayText = resolveQuery(queryText.value)
const re = /(?:"([^"]+)"|([^\[]+?))\s*\[(\w+)\]/g
let m: RegExpExecArray | null
while ((m = re.exec(displayText)) !== null) {
const val = (m[1] || m[2] || '').trim().replace(/^(?:AND|OR|NOT)\s*/i, '')
if (!val) continue
const tag = (m[3] || '').toUpperCase()
if (tag === 'DP') continue // handled by date range regex below
const label = fieldLabelMap[tag] || tag
const key = `${m.index}|${tag}|${val}`
if (!seen.has(key)) {
seen.add(key)
items.push({ field: `[${tag}] ${label}`, value: `"${val}"` })
}
}
// Date range: YYYY:YYYY[DP] or YYYY/MM/DD:YYYY/MM/DD[DP]
const yrRe = /(\d{4}(?:\/\d{2}\/\d{2})?)\s*:\s*(\d{4}(?:\/\d{2}\/\d{2})?)\s*\[DP\]/g
while ((m = yrRe.exec(queryText.value)) !== null) {
const key = `dp_range_${m.index}`
if (!seen.has(key)) {
seen.add(key)
items.push({ field: '[DP] Publication Date', value: `${m[1]}:${m[2]}` })
}
}
return items
})
// ── Search Builder (顶部) ──
const builderOperator = ref('AND')
const builderField = ref('')
const builderValue = ref('')
const builderOpOptions = [
{ label: 'AND', value: 'AND' },
{ label: 'OR', value: 'OR' },
{ label: 'NOT', value: 'NOT' },
]
const fieldOptions = [
{ label: 'All Fields', value: '' },
{ label: 'Affiliation [AD]', value: 'AD' },
{ label: 'Author [AU]', value: 'AU' },
{ label: 'Author - Corporate [CN]', value: 'CN' },
{ label: 'Author - First [FAU]', value: 'FAU' },
{ label: 'Author - Identifier [AUID]', value: 'AUID' },
{ label: 'Author - Last [LAU]', value: 'LAU' },
{ label: 'Book [BOOK]', value: 'BOOK' },
{ label: 'Conflict of Interest Statements [COIS]', value: 'COIS' },
{ label: 'Date - Completion [DCOM]', value: 'DCOM' },
{ label: 'Date - Create [CRDT]', value: 'CRDT' },
{ label: 'Date - Entry [EDAT]', value: 'EDAT' },
{ label: 'Date - MeSH [MHDA]', value: 'MHDA' },
{ label: 'Date - Modification [LR]', value: 'LR' },
{ label: 'Date - Publication [DP]', value: 'DP' },
{ label: 'EC/RN Number [RN]', value: 'RN' },
{ label: 'Editor [ED]', value: 'ED' },
{ label: 'Filter [FILTER]', value: 'FILTER' },
{ label: 'Grants and Funding [GR]', value: 'GR' },
{ label: 'ISBN [ISBN]', value: 'ISBN' },
{ label: 'Investigator [IR]', value: 'IR' },
{ label: 'Issue [IP]', value: 'IP' },
{ label: 'Journal [TA]', value: 'TA' },
{ label: 'Language [LA]', value: 'LA' },
{ label: 'Location ID [LID]', value: 'LID' },
{ label: 'MeSH Major Topic [MAJR]', value: 'MAJR' },
{ label: 'MeSH Subheading [SH]', value: 'SH' },
{ label: 'MeSH Terms [MH]', value: 'MH' },
{ label: 'Other Term [OT]', value: 'OT' },
{ label: 'Pagination [PG]', value: 'PG' },
{ label: 'Pharmacological Action [PA]', value: 'PA' },
{ label: 'Publication Type [PT]', value: 'PT' },
{ label: 'Publisher [PUBN]', value: 'PUBN' },
{ label: 'Secondary Source ID [SI]', value: 'SI' },
{ label: 'Subject - Personal Name [PS]', value: 'PS' },
{ label: 'Supplementary Concept [NM]', value: 'NM' },
{ label: 'Text Word [TW]', value: 'TW' },
{ label: 'Title [TI]', value: 'TI' },
{ label: 'Title/Abstract [TIAB]', value: 'TIAB' },
{ label: 'Transliterated Title [TT]', value: 'TT' },
{ label: 'Volume [VI]', value: 'VI' },
{ label: 'Abstract [AB]', value: 'AB' },
{ label: 'DOI [DOI]', value: 'DOI' },
{ label: 'PMID [PMID]', value: 'PMID' },
]
function addToQuery() {
let val = builderValue.value.trim()
// 去除用户输入的多余引号
val = val.replace(/^['""“]+|['""”]+$/g, '')
if (!val) {
message.warning('请输入搜索词')
return
}
const tag = builderField.value
// 构建新词条
let term: string
if (!tag) {
term = val.includes(' ') ? `"${val}"` : val
} else {
term = val.includes(' ') ? `"${val}"[${tag}]` : `${val}[${tag}]`
}
// 如果 Query Box 非空,在前加逻辑条件
if (queryText.value.trim()) {
queryText.value += ` ${builderOperator.value} ${term}`
} else {
queryText.value += term
}
builderValue.value = ''
}
// ── Search History ──
const combineA = ref('')
const combineB = ref('')
const combineOp = ref('AND')
const combineOps = [
{ label: 'AND', value: 'AND' },
{ label: 'OR', value: 'OR' },
{ label: 'NOT', value: 'NOT' },
]
function combineQuery() {
const a = combineA.value.trim()
const b = combineB.value.trim()
if (!a || !b) {
message.warning('请输入两个查询编号')
return
}
// 校验仅接受数字或 #N 引用
const refRe = /^#?\d+$/
if (!refRe.test(a) || !refRe.test(b)) {
message.warning('查询编号必须为数字(如 1 或 #1)')
return
}
// 自动补 # 前缀
const refA = a.startsWith('#') ? a : `#${a}`
const refB = b.startsWith('#') ? b : `#${b}`
const sep = queryText.value && !queryText.value.endsWith(' ') ? ' ' : ''
queryText.value += `${sep}${refA} ${combineOp.value} ${refB}`
combineA.value = ''
combineB.value = ''
}
function clickHistoryId(id: string) {
const sep = queryText.value && !queryText.value.endsWith(' ') ? ' ' : ''
queryText.value += `${sep}${id}`
}
function removeEntry(id: string) {
remove(id)
}
// ── resolve #N ──
function resolveQuery(q: string): string {
if (!q.includes('#')) return q
const all = historyEntries.value
// 只替换不在引号字符串内的 #N 引用
return q.replace(/"[^"]*"|'[^']*'|#(\d+)/g, (m, num) => {
if (num === undefined) return m // 在引号内,不做替换
const found = all.find(e => e.id === `#${num}`)
if (!found) {
message.warning(`查询编号 ${m} 在历史中不存在,已保留原样`)
return m
}
return `(${found.expanded_query})`
})
}
function validateQuery(q: string): { valid: boolean; query: string; error?: string } {
if (!q.trim()) {
return { valid: false, query: q, error: '请输入查询' }
}
// 校验 #N 引用格式
const refRe = /#\d+/g
let refMatch: RegExpExecArray | null
while ((refMatch = refRe.exec(q)) !== null) {
const num = parseInt(refMatch[0].slice(1), 10)
const exists = historyEntries.value.find(e => e.id === `#${num}`)
if (!exists) {
return { valid: false, query: q, error: `历史引用 ${refMatch[0]} 不存在,请先执行相关查询` }
}
}
// 校验空括号 []
if (/\[\]/.test(q)) {
return { valid: false, query: q, error: '字段标签不能为空,请在 [] 中输入标签名' }
}
// 校验字段标签格式
const tagRe = /\[(\w+)\]/g
let tagMatch: RegExpExecArray | null
while ((tagMatch = tagRe.exec(q)) !== null) {
const tag = (tagMatch[1] || '').toUpperCase()
const validTags = useValidTags()
if (!validTags.has(tag)) {
return { valid: false, query: q, error: `字段标签 [${tag}] 不合法` }
}
}
// 校验布尔运算符格式(不能连续 OR/AND,不能以运算符开头/结尾)
// 注:AND NOT / OR NOT 是有效语法;前导 NOT 也是有效语法
const trimmed = q.trim()
const ops = ['AND', 'OR', 'NOT']
const upperTrimmed = trimmed.toUpperCase()
const words = upperTrimmed.split(/\s+/).filter(Boolean)
const firstWord = words[0] || ''
const lastWord = words[words.length - 1] || ''
if (firstWord !== 'NOT' && ops.includes(firstWord)) {
return { valid: false, query: q, error: '查询不能以布尔运算符(AND/OR)开头(前导 NOT 允许)' }
}
if (ops.includes(lastWord)) {
return { valid: false, query: q, error: '查询不能以布尔运算符(AND/OR/NOT)结束' }
}
for (let i = 0; i < words.length - 1; i++) {
if (ops.includes(words[i] || '') && ops.includes(words[i + 1] || '')) {
// 允许 AND NOT 和 OR NOT
if (words[i] === 'AND' && words[i + 1] === 'NOT') continue
if (words[i] === 'OR' && words[i + 1] === 'NOT') continue
return { valid: false, query: q, error: '不允许连续使用布尔运算符(AND NOT / OR NOT 除外)' }
}
}
// 校验括号匹配
let depth = 0
for (const ch of q) {
if (ch === '(') depth++
if (ch === ')') depth--
if (depth < 0) {
return { valid: false, query: q, error: '括号不匹配:多余的右括号' }
}
}
if (depth !== 0) {
return { valid: false, query: q, error: '括号不匹配:缺少右括号' }
}
return { valid: true, query: resolveQuery(q) }
}
const loading = ref(false)
const reversedHistory = computed(() => historyEntries.value.slice().reverse())
async function fetchCount(q: string): Promise<number | null> {
try {
const { data } = await api.post('/features/search/advanced', { query: q, page_size: 1 })
return (data && typeof data.total === 'number') ? data.total : null
} catch {
return null
}
}
async function pubSearch() {
const raw = queryText.value.trim()
const check = validateQuery(raw)
if (!check.valid) {
message.warning(check.error || '查询无效')
return
}
loading.value = true
try {
const count = await fetchCount(check.query)
add(raw, count)
await router.push({ name: 'public-search', query: { q: check.query } }).catch(() => {})
} finally {
loading.value = false
}
}
async function addToHistory() {
const raw = queryText.value.trim()
const check = validateQuery(raw)
if (!check.valid) {
message.warning(check.error || '查询无效')
return
}
loading.value = true
try {
const count = await fetchCount(check.query)
const entry = add(raw, count)
message.success(`${entry.id} 已加入历史(${count ?? '?'} 条结果)`)
} finally {
loading.value = false
}
}
function clearQuery() {
queryText.value = ''
}
function formatDate(ts: string): string {
try {
const d = new Date(ts)
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
} catch { return ts }
}
</script>
<template>
<div class="aps-page" style="max-width:1200px;margin:0 auto;padding:32px 24px">
<div class="aps-header">
<h1>
<NIcon size="24" style="vertical-align:-4px;margin-right:6px"><CodeSlashOutline /></NIcon>
Pub高级搜索
</h1>
<p class="aps-subtitle">使用 PubMed 语法构建精确查询支持 #N 历史组合引用</p>
</div>
<!-- 1. Search Builder (顶部) -->
<div class="aps-section">
<div class="aps-section-title">Search Builder</div>
<div class="aps-builder-row">
<NSelect
v-model:value="builderOperator"
:options="builderOpOptions"
class="aps-builder-op"
size="small"
/>
<NSelect
v-model:value="builderField"
:options="fieldOptions"
placeholder="All Fields"
clearable
class="aps-field-select"
size="small"
popup-class="aps-field-popup"
/>
<NInput
v-model:value="builderValue"
placeholder="输入搜索词..."
clearable
class="aps-builder-input"
size="small"
@keyup.enter="addToQuery"
/>
<NButton @click="addToQuery" class="sky-btn" size="small">
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>
Add
</NButton>
</div>
</div>
<!-- 2. Query Box -->
<div class="aps-section">
<div class="aps-section-title">Query Box</div>
<textarea
v-model="queryText"
class="aps-textarea"
placeholder='例如: "lung cancer"[TI] AND 2024:2025[DP]'
rows="4"
></textarea>
<!-- 搜索按钮紧接在 Query Box 下方 -->
<div class="aps-query-actions">
<NButton type="primary" :loading="loading" @click="pubSearch" class="sky-btn sky-btn-primary" size="small">
<template #icon><NIcon size="14"><SearchOutline /></NIcon></template>
Search
</NButton>
<NButton @click="clearQuery" class="sky-btn" size="small">Clear</NButton>
<NButton @click="addToHistory" class="sky-btn" size="small">
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>
Add to History
</NButton>
</div>
</div>
<!-- 3. Translation Table -->
<div class="aps-section" v-if="translated.length">
<div class="aps-section-title">Translation Table</div>
<div class="aps-tt-wrapper">
<table class="aps-table">
<thead>
<tr><th>Field</th><th>Query Terms</th></tr>
</thead>
<tbody>
<tr v-for="(t, i) in translated" :key="i">
<td class="aps-tt-field">{{ t.field }}</td>
<td class="aps-tt-value"><code>{{ t.value }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 4. Search History -->
<div class="aps-section">
<div class="aps-section-title">
Search History
<span class="aps-history-actions" v-if="historyEntries.length">
<NButton text size="tiny" @click="downloadHistory">Download</NButton>
<NButton text size="tiny" @click="clearHistory" style="margin-left:8px;color:var(--badge-error-text)">Clear All</NButton>
</span>
</div>
<div v-if="historyEntries.length === 0" class="aps-empty">暂无搜索历史输入查询后点击 Search Add to History</div>
<table v-else class="aps-table aps-history-table">
<thead>
<tr><th>#</th><th>Query</th><th>Results</th><th>Time</th><th></th></tr>
</thead>
<tbody>
<tr v-for="entry in reversedHistory" :key="entry.id" class="aps-history-row">
<td class="aps-h-id" @click="clickHistoryId(entry.id)" title="点击 #N 添加到 Query Box">{{ entry.id }}</td>
<td class="aps-h-query" @click="clickHistoryId(entry.id)" title="点击添加到 Query Box">
<code>{{ entry.expanded_query }}</code>
</td>
<td class="aps-h-count">
<span v-if="entry.result_count != null" class="aps-count-tag">{{ entry.result_count }}</span>
<span v-else class="aps-h-na"></span>
</td>
<td class="aps-h-time">{{ formatDate(entry.timestamp) }}</td>
<td class="aps-h-del"><NButton text size="tiny" @click="removeEntry(entry.id)" title="删除"></NButton></td>
</tr>
</tbody>
</table>
<!-- Combine -->
<div class="aps-combine-row" v-if="historyEntries.length >= 2">
<span class="aps-combine-label">Combine:</span>
<NInput v-model:value="combineA" placeholder="#1" class="aps-combine-input" size="tiny" />
<NSelect v-model:value="combineOp" :options="combineOps" class="aps-combine-op" size="tiny" />
<NInput v-model:value="combineB" placeholder="#2" class="aps-combine-input" size="tiny" />
<NButton size="tiny" @click="combineQuery" class="sky-btn">Combine</NButton>
</div>
</div>
</div>
</template>
<style scoped>
.aps-page {
min-height: 100vh;
}
.aps-header {
margin-bottom: 24px;
}
.aps-header h1 {
margin: 0 0 4px 0;
font-size: 22px;
color: var(--text-primary);
}
.aps-subtitle {
margin: 0;
font-size: 13px;
color: var(--text-muted);
}
.aps-section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.aps-section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── Search Builder ── */
.aps-builder-row {
display: flex;
gap: 8px;
align-items: center;
}
.aps-builder-op {
width: 90px;
flex-shrink: 0;
}
.aps-field-select {
width: 260px;
flex-shrink: 0;
}
.aps-builder-input {
flex: 1;
}
/* ── Query Box ── */
.aps-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: vertical;
background: var(--bg-page);
color: var(--text-primary);
box-sizing: border-box;
}
.aps-textarea:focus {
outline: none;
border-color: #6ab0e0;
}
.aps-query-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 12px;
}
/* ── Table ── */
.aps-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.aps-table th,
.aps-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.aps-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 12px;
background: var(--bg-page);
}
/* ── Translation Table ── */
.aps-tt-wrapper {
max-height: 200px;
overflow-y: auto;
}
.aps-tt-field {
color: #2a6a9a;
font-weight: 500;
width: 160px;
}
.aps-tt-value code {
background: var(--preview-ai-bg);
padding: 2px 6px;
border-radius: 3px;
font-size: 13px;
}
/* ── History ── */
.aps-history-actions {
font-size: 12px;
}
.aps-empty {
text-align: center;
padding: 20px 0;
font-size: 13px;
color: var(--text-muted);
}
.aps-history-row {
cursor: pointer;
}
.aps-history-row:hover td {
background: var(--bg-hover);
}
.aps-h-id {
font-weight: 600;
color: #2a6a9a;
width: 40px;
cursor: pointer;
}
.aps-h-id:hover {
text-decoration: underline;
}
.aps-h-query {
max-width: 400px;
}
.aps-h-query code {
font-size: 12px;
color: var(--text-primary);
word-break: break-all;
}
.aps-h-count {
width: 80px;
text-align: center;
}
.aps-count-tag {
display: inline-block;
background: var(--preview-ai-bg);
padding: 1px 8px;
border-radius: 10px;
font-size: 12px;
color: var(--text-primary);
}
.aps-h-na {
color: var(--text-muted);
font-size: 12px;
}
.aps-h-time {
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
width: 80px;
}
.aps-h-del {
width: 32px;
text-align: center;
}
/* ── Combine ── */
.aps-combine-row {
display: flex;
gap: 6px;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.aps-combine-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.aps-combine-input {
width: 70px;
}
.aps-combine-op {
width: 90px;
}
html.dark .aps-tt-field {
color: #6ab0e0;
}
html.dark .aps-h-id {
color: #6ab0e0;
}
:global(.aps-field-popup.n-base-select-menu) {
--n-option-container-max-height: 660px !important;
}
html.dark .aps-textarea::selection {
background: #1a6bb0;
color: #fff;
}
@media (max-width: 768px) {
.aps-page { padding: 16px; }
.aps-builder-row { flex-direction: column; align-items: stretch; }
.aps-field-select { width: 100%; }
.aps-combine-row { flex-wrap: wrap; }
}
</style>
+38 -21
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton, NEmpty, NIcon } from 'naive-ui'
import { ChevronDownOutline, ChevronUpOutline, CloseCircleOutline, HelpCircleOutline, OptionsOutline, RefreshOutline, SearchOutline } from '@vicons/ionicons5'
import { ChevronDownOutline, ChevronUpOutline, CloseCircleOutline, HelpCircleOutline, OptionsOutline, RefreshOutline, SearchCircleOutline, SearchOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useAuthStore } from '../../stores/auth'
import { useToast } from '../../composables/useToast'
@@ -206,13 +206,19 @@ const visibleTags = computed(() => {
// ── 行内搜索 ──
function doLocalSearch() {
showAdvanced.value = false
searchParams.value.field = 'all'
if (localQuery.value.trim()) {
searchParams.value.query = localQuery.value
} else {
searchParams.value.query = ''
}
fetchData()
const q = localQuery.value.trim()
const query: Record<string, string> = {}
if (q) query.q = q
// 传递所有活跃的搜索参数,确保搜索栏设置不丢失
if (searchParams.value.field && searchParams.value.field !== 'all') query.field = searchParams.value.field
if (searchParams.value.date_from) query.date_from = searchParams.value.date_from
if (searchParams.value.date_to) query.date_to = searchParams.value.date_to
if (searchParams.value.sort && searchParams.value.sort !== 'date') query.sort = searchParams.value.sort
if (searchParams.value.precision_mode && searchParams.value.precision_mode !== 'majr') query.precision = searchParams.value.precision_mode
if (searchParams.value.retracted) query.retracted = searchParams.value.retracted
if (searchParams.value.negative_result) query.negative = searchParams.value.negative_result
if (selectedTagIds.value.length > 0) query.tag = selectedTagIds.value.join(',')
router.push({ name: 'public-search', query })
}
function clearSearch() {
@@ -224,7 +230,6 @@ function clearSearch() {
}
selectedTagIds.value = []
showAdvanced.value = false
fetchData()
}
// ── 高级搜索 ──
@@ -240,18 +245,19 @@ function handleAdvancedSearch(params: {
precision_mode: string
sort: string
}) {
searchParams.value.query = params.query
searchParams.value.field = params.field
searchParams.value.date_from = params.date_from
searchParams.value.date_to = params.date_to
searchParams.value.tag_ids = params.tag_ids
searchParams.value.retracted = params.retracted
searchParams.value.negative_result = params.negative_result
searchParams.value.precision_mode = params.precision_mode
searchParams.value.sort = params.sort
selectedTagIds.value = [...params.tag_ids]
showAdvanced.value = false
fetchData()
const q: Record<string, string> = {}
if (params.query) q.q = params.query
if (params.field !== 'all') q.field = params.field
if (params.date_from) q.date_from = params.date_from
if (params.date_to) q.date_to = params.date_to
if (params.journal_tiers.length) q.tier = params.journal_tiers.join(',')
if (params.tag_ids.length) q.tag = params.tag_ids.join(',')
if (params.retracted) q.retracted = params.retracted
if (params.negative_result) q.negative = params.negative_result
if (params.sort !== 'date') q.sort = params.sort
if (params.precision_mode !== 'majr') q.precision = params.precision_mode
router.push({ name: 'public-search', query: q })
}
// ── 收藏 ──
@@ -431,6 +437,9 @@ onBeforeUnmount(() => {
<NButton text size="tiny" @click="router.push('/help?tab=syntax')" class="help-icon-btn" title="搜索帮助">
<template #icon><NIcon size="19"><HelpCircleOutline /></NIcon></template>
</NButton>
<NButton text size="tiny" @click="router.push('/advanced-pub-search')" title="Pub高级搜索" class="pub-adv-icon-btn">
<template #icon><NIcon size="19"><SearchCircleOutline /></NIcon></template>
</NButton>
<NButton text size="tiny" @click="showAdvanced = !showAdvanced" :title="showAdvanced?'收起筛选':'高级搜索'" class="adv-icon-btn">
<template #icon><NIcon size="18"><OptionsOutline /></NIcon></template>
</NButton>
@@ -662,9 +671,17 @@ html.dark .search-input { color: #fff; }
html.dark .search-input::placeholder { color: #bbb; }
.search-input-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; }
.adv-icon-btn { color: var(--kw-pill-color); margin-left: -3px; }
html.dark .adv-icon-btn { color: #6ab0e0; }
.adv-icon-btn:hover { color: var(--text-primary) !important; }
.help-icon-btn { color: var(--kw-pill-color); }
.help-icon-btn:hover { color: var(--text-primary) !important; }
.pub-adv-icon-btn { color: var(--kw-pill-color); }
.pub-adv-icon-btn:hover { color: var(--text-primary) !important; }
html.dark .help-icon-btn { color: #6ab0e0; }
html.dark .help-icon-btn:hover { color: #fff !important; }
html.dark .pub-adv-icon-btn { color: #6ab0e0; }
html.dark .pub-adv-icon-btn:hover { color: #fff !important; }
html.dark .adv-icon-btn { color: #6ab0e0; }
html.dark .adv-icon-btn:hover { color: #fff !important; }
.clear-search-btn { color: var(--text-muted); flex-shrink: 0; }
.search-submit-btn { height: 28px; border-radius: 6px; }
@@ -157,7 +157,7 @@ const tierInfo: Record<string, { label: string; color: string; desc: string }> =
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--border-color);
border-left: 3px solid transparent;
border-left: 3px solid rgba(153,153,153,0.35);
background: var(--bg-card);
transition: box-shadow .15s;
}