feat: initial commit - oncology literature search platform
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.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user