chore: batch commit remaining changes
Includes search engine improvements, Alembic migrations, new services (pubmed_daily_update, query_expansion), frontend updates, and documentation sync.
This commit is contained in:
+35
-5
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user