OncoLit: a multi-tenant oncology literature search, feed, and collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL. Includes PubMed pipeline, drug approvals, AI summaries, and systematic review tools.
217 lines
6.9 KiB
TypeScript
217 lines
6.9 KiB
TypeScript
import { ref, onMounted, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { api } from '../api/client'
|
|
import { useToast } from './useToast'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { toBeijingDateTime } from '../utils/date'
|
|
import { copyToClipboard } from '../utils/clipboard'
|
|
import { computeStudyTypes } from '../constants/studyTypes'
|
|
import { trackAction } from './useAnalytics'
|
|
import type { LiteratureDetail, NoteItem, PersonalTag, SavedItem } from '../types'
|
|
|
|
export function useLiteratureDetail(pmid: number) {
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
|
|
const lit = ref<LiteratureDetail | null>(null)
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const saved = ref(false)
|
|
const rating = ref(0)
|
|
const readingStatus = ref('')
|
|
const notes = ref<NoteItem[]>([])
|
|
const personalTags = ref<PersonalTag[]>([])
|
|
const relatedItems = ref<any[]>([])
|
|
const relatedSource = ref('')
|
|
const relatedLoading = ref(true)
|
|
|
|
const studyTypes = computed(() => computeStudyTypes(lit.value?.pub_types || []))
|
|
|
|
const field_labels: Record<string, string> = {
|
|
sample_size: '样本量',
|
|
age: '年龄',
|
|
female: '女性',
|
|
male: '男性',
|
|
ecog: 'ECOG',
|
|
stage: '分期',
|
|
bmi: 'BMI',
|
|
smoking: '吸烟',
|
|
alcohol: '饮酒',
|
|
prior_therapy: '既往治疗',
|
|
histology: '病理类型',
|
|
subtype: '亚型',
|
|
race: '种族',
|
|
surgery: '手术史',
|
|
comorbidity: '合并症',
|
|
}
|
|
|
|
const tables = computed(() => lit.value?.full_text_sections?.tables || [])
|
|
|
|
const isUpdated = computed(() => {
|
|
if (!lit.value?.created_at || !lit.value?.updated_at) return false
|
|
return new Date(lit.value.updated_at).getTime() > new Date(lit.value.created_at).getTime()
|
|
})
|
|
|
|
const exportOptions = [
|
|
{ label: 'BibTeX', key: 'bibtex' },
|
|
{ label: 'RIS', key: 'ris' },
|
|
{ label: 'MLA', key: 'mla' },
|
|
{ label: 'EndNote', key: 'endnote' },
|
|
{ label: 'CSV', key: 'csv' },
|
|
]
|
|
|
|
const authStore = useAuthStore()
|
|
const planType = computed(() => authStore.currentTenant?.plan_type || 'free')
|
|
|
|
const exportFormats = computed(() => {
|
|
const freeFormats = ['bibtex', 'ris', 'mla']
|
|
return exportOptions.filter(o => {
|
|
if (planType.value === 'free') return freeFormats.includes(o.key)
|
|
return true
|
|
})
|
|
})
|
|
|
|
function formatDate(d: string | undefined | null) {
|
|
if (!d) return ''
|
|
// 有时间的完整时间戳 → 北京时间;纯日期 → 直接返回
|
|
return d.length > 10 ? toBeijingDateTime(d) : d.slice(0, 10)
|
|
}
|
|
|
|
function goBack() {
|
|
router.back()
|
|
}
|
|
|
|
function copyPmid() {
|
|
const pmid = lit.value?.pmid
|
|
if (!pmid) return
|
|
copyToClipboard(String(pmid)).then(ok => {
|
|
if (ok) toast.success('已复制 PMID')
|
|
else toast.info(String(pmid))
|
|
})
|
|
}
|
|
|
|
function copyDoi() {
|
|
const doi = lit.value?.doi
|
|
if (!doi) return
|
|
copyToClipboard(doi).then(ok => {
|
|
if (ok) toast.success('已复制 DOI')
|
|
else toast.info(doi)
|
|
})
|
|
}
|
|
|
|
function openPubMed(pmid: number) {
|
|
window.open(`https://pubmed.ncbi.nlm.nih.gov/${pmid}/`, '_blank')
|
|
}
|
|
|
|
function openDOI(doi: string) {
|
|
window.open(`https://doi.org/${doi}`, '_blank')
|
|
}
|
|
|
|
async function copyCitation() {
|
|
if (!lit.value?.pmid) return
|
|
try {
|
|
const { data } = await api.get(`/literature/export/${lit.value.pmid}/citation`, { params: { fmt: 'mla' } })
|
|
const ok = await copyToClipboard(data.citation)
|
|
if (ok) toast.success('已复制引用 (MLA)')
|
|
else toast.info(data.citation)
|
|
} catch (e) { toast.apiError(e, '复制失败') }
|
|
}
|
|
|
|
function handleExport(fmt: string) {
|
|
if (!lit.value?.pmid) return
|
|
trackAction('export', 'literature', String(lit.value.pmid), { format: fmt })
|
|
const url = `/api/v1/literature/export/${lit.value.pmid}/${fmt}`
|
|
window.open(url, '_blank')
|
|
}
|
|
|
|
async function handleSave() {
|
|
saving.value = true
|
|
try {
|
|
if (saved.value) {
|
|
await api.delete(`/literature/${pmid}/save`)
|
|
saved.value = false
|
|
toast.success('已取消收藏')
|
|
} else {
|
|
await api.post(`/literature/${pmid}/save`)
|
|
saved.value = true
|
|
toast.success('已收藏')
|
|
trackAction('save_literature', 'literature', String(pmid))
|
|
}
|
|
} catch (e) { toast.apiError(e, '操作失败') }
|
|
finally { saving.value = false }
|
|
}
|
|
|
|
async function handleRate(star: number) {
|
|
try {
|
|
// 如果未收藏,先自动收藏再评分
|
|
if (!saved.value) {
|
|
await api.post(`/literature/${pmid}/save`)
|
|
saved.value = true
|
|
}
|
|
await api.post(`/settings/literature/${pmid}/rate`, { rating: star })
|
|
rating.value = star
|
|
} catch (e) { toast.apiError(e, '评分失败,请重试') }
|
|
}
|
|
|
|
async function updateReadingStatus(status: string) {
|
|
try {
|
|
await api.put(`/literature/${pmid}/reading-status`, { status })
|
|
readingStatus.value = status
|
|
} catch (e) { toast.apiError(e, '更新阅读状态失败') }
|
|
}
|
|
|
|
const READING_STATUS_OPTIONS = [
|
|
{ value: 'unread', label: '未读', color: '#999' },
|
|
{ value: 'reading', label: '在读', color: '#f0a020' },
|
|
{ value: 'completed', label: '已读', color: '#18a058' },
|
|
]
|
|
|
|
function readingStatusLabel(status: string): string {
|
|
return READING_STATUS_OPTIONS.find(o => o.value === status)?.label || status
|
|
}
|
|
|
|
function readingStatusColor(status: string): string {
|
|
return READING_STATUS_OPTIONS.find(o => o.value === status)?.color || '#999'
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const [litRes, notesRes, savedRes] = await Promise.all([
|
|
api.get(`/literature/${pmid}`),
|
|
api.get(`/notes/literature/${pmid}`),
|
|
api.get('/literature/saved'),
|
|
])
|
|
lit.value = litRes.data
|
|
notes.value = notesRes.data || []
|
|
const savedItem = (savedRes.data.items || []).find((i: SavedItem) => i.pmid === Number(pmid))
|
|
if (savedItem) {
|
|
saved.value = true
|
|
rating.value = savedItem.rating || 0
|
|
personalTags.value = savedItem.personal_tags || []
|
|
readingStatus.value = savedItem.reading_status || ''
|
|
}
|
|
// detail 端返回的 reading_status 优先级更高(可能刚被自动更新为 reading)
|
|
if (litRes.data.reading_status) {
|
|
readingStatus.value = litRes.data.reading_status
|
|
}
|
|
trackAction('view_literature', 'literature', String(pmid))
|
|
} finally { loading.value = false }
|
|
|
|
try {
|
|
const { data } = await api.get(`/literature/${pmid}/related`)
|
|
relatedItems.value = data.items || []
|
|
relatedSource.value = data.source || ''
|
|
} finally { relatedLoading.value = false }
|
|
})
|
|
|
|
return {
|
|
lit, loading, saving, saved, rating, readingStatus,
|
|
studyTypes, tables, field_labels, isUpdated,
|
|
notes, personalTags, relatedItems, relatedSource, relatedLoading,
|
|
formatDate, goBack, copyPmid, copyDoi, openPubMed, openDOI,
|
|
exportOptions, exportFormats, handleExport, copyCitation,
|
|
handleSave, handleRate, updateReadingStatus,
|
|
READING_STATUS_OPTIONS, readingStatusLabel, readingStatusColor,
|
|
}
|
|
}
|