feat: initial commit - oncology literature search platform
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

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:
34047007@qq.com
2026-07-27 07:59:18 +08:00
commit a6cd99a4ca
473 changed files with 151472 additions and 0 deletions
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from 'vitest'
import { useAiSummary } from '../useAiSummary'
vi.mock('../../api/client', () => ({
api: { post: vi.fn() },
}))
describe('useAiSummary', () => {
it('initial state is empty', () => {
const { aiLoading, aiSummary, aiMode } = useAiSummary(12345)
expect(aiLoading.value).toBe(false)
expect(aiSummary.value).toBe('')
expect(aiMode.value).toBe('one_liner')
})
it('generateAI sets loading and returns summary', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockResolvedValue({ data: { summary: 'AI summary text' } })
const { aiLoading, aiSummary, generateAI } = useAiSummary(12345)
const promise = generateAI('one_liner')
expect(aiLoading.value).toBe(true)
await promise
expect(aiSummary.value).toBe('AI summary text')
expect(aiLoading.value).toBe(false)
})
it('handles 503 error with service unavailable message', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockRejectedValue({ response: { status: 503 } })
const { aiSummary, generateAI } = useAiSummary(12345)
await generateAI('structured')
expect(aiSummary.value).toContain('AI 服务暂不可用')
})
it('handles generic error with retry message', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockRejectedValue(new Error('network error'))
const { aiSummary, generateAI } = useAiSummary(12345)
await generateAI('implication')
expect(aiSummary.value).toBe('生成失败,请重试。')
})
})
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { useApiRequest } from '../useApiRequest'
describe('useApiRequest', () => {
it('starts with null data, false loading, null error', () => {
const { data, loading, error } = useApiRequest(() => Promise.resolve('ok'))
expect(data.value).toBeNull()
expect(loading.value).toBe(false)
expect(error.value).toBeNull()
})
it('execute sets data and resets error on success', async () => {
const { data, error, loading, execute } = useApiRequest(() => Promise.resolve('hello'))
const result = await execute()
expect(result).toBe('hello')
expect(data.value).toBe('hello')
expect(error.value).toBeNull()
expect(loading.value).toBe(false)
})
it('execute sets error on failure', async () => {
const { data, error, execute } = useApiRequest(() => Promise.reject(new Error('fail')))
const result = await execute()
expect(result).toBeNull()
expect(data.value).toBeNull()
expect(error.value).toBe('fail')
})
it('loading is true during execution', async () => {
let resolvePromise!: () => void
const fetcher = () => new Promise<string>(r => { resolvePromise = () => r('done') })
const { loading, execute } = useApiRequest(fetcher)
const promise = execute()
expect(loading.value).toBe(true)
resolvePromise()
await promise
expect(loading.value).toBe(false)
})
it('extracts detail from axios-like error shape', async () => {
const axiosError = { response: { data: { detail: '配额超限' } } }
const { error, execute } = useApiRequest(() => Promise.reject(axiosError))
await execute()
expect(error.value).toBe('配额超限')
})
it('returns readonly refs', () => {
const { data, loading, error } = useApiRequest(() => Promise.resolve('ok'))
expect((data as any).value).toBeNull()
expect((loading as any).value).toBe(false)
expect((error as any).value).toBeNull()
})
})
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useHighlights } from '../useHighlights'
vi.mock('../../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), delete: vi.fn() },
}))
vi.mock('../useToast', () => ({
useToast: () => ({ apiError: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}))
describe('useHighlights', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('initial state is empty with no form visible', () => {
const { highlights, showHlForm, hlToolbarVisible, hlText } = useHighlights(12345)
expect(highlights.value).toEqual([])
expect(showHlForm.value).toBe(false)
expect(hlToolbarVisible.value).toBe(false)
expect(hlText.value).toBe('')
})
it('handleCreateHighlight posts and reloads', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockResolvedValue({ data: {} })
vi.mocked(api.get).mockResolvedValue({ data: [{ id: 'h1', text: 'existing' }] })
const { hlText, handleCreateHighlight, highlights } = useHighlights(12345)
hlText.value = 'test highlight'
await handleCreateHighlight()
expect(api.post).toHaveBeenCalledWith('/highlights/12345', expect.objectContaining({
highlight_type: 'highlight',
text: 'test highlight',
}))
expect(highlights.value).toHaveLength(1)
expect(hlText.value).toBe('')
})
it('handleCreateHighlight does nothing for empty text', async () => {
const { handleCreateHighlight, highlights } = useHighlights(12345)
await handleCreateHighlight()
expect(highlights.value).toEqual([])
})
it('handleDeleteHighlight removes from list', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.get).mockResolvedValue({ data: [] })
vi.mocked(api.delete).mockResolvedValue({})
const { highlights: hls, handleDeleteHighlight } = useHighlights(12345)
hls.value = [{ id: 'h1', text: 'a' }, { id: 'h2', text: 'b' }] as any
await handleDeleteHighlight('h1')
expect(hls.value).toHaveLength(1)
expect(api.delete).toHaveBeenCalledWith('/highlights/h1')
})
it('onAbstractMouseup shows toolbar when text is selected', () => {
const mockSelection = {
isCollapsed: false,
toString: () => 'selected text',
} as any
vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection)
const { hlToolbarVisible, hlToolbarPos, onAbstractMouseup } = useHighlights(12345)
const event = { clientX: 100, clientY: 200 } as MouseEvent
onAbstractMouseup(event)
expect(hlToolbarVisible.value).toBe(true)
expect(hlToolbarPos.value).toEqual({ x: 100, y: 200 })
})
it('onAbstractMouseup hides toolbar when no selection', () => {
vi.spyOn(window, 'getSelection').mockReturnValue({
isCollapsed: true,
toString: () => '',
} as any)
const { hlToolbarVisible, onAbstractMouseup } = useHighlights(12345)
onAbstractMouseup({} as MouseEvent)
expect(hlToolbarVisible.value).toBe(false)
})
it('useSelectedText sets hlText from selection', () => {
const mockRemoveAllRanges = vi.fn()
const mockSelection = {
isCollapsed: false,
toString: () => 'selected text',
removeAllRanges: mockRemoveAllRanges,
} as any
vi.spyOn(window, 'getSelection').mockReturnValue(mockSelection)
const { hlText, showHlForm, hlToolbarVisible, useSelectedText } = useHighlights(12345)
useSelectedText()
expect(hlText.value).toBe('selected text')
expect(showHlForm.value).toBe(true)
expect(hlToolbarVisible.value).toBe(false)
expect(mockRemoveAllRanges).toHaveBeenCalled()
})
})
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest'
import { useLiteratureDetail } from '../useLiteratureDetail'
const mockApiResponse = (data: unknown) => ({ data })
vi.mock('../../api/client', () => ({
api: { get: vi.fn(), post: vi.fn(), delete: vi.fn() },
}))
describe('useLiteratureDetail', () => {
it('initial state is correct before mount', () => {
const result = useLiteratureDetail(12345)
expect(result.lit.value).toBeNull()
expect(result.loading.value).toBe(true)
expect(result.saving.value).toBe(false)
expect(result.saved.value).toBe(false)
expect(result.rating.value).toBe(0)
expect(result.notes.value).toEqual([])
expect(result.personalTags.value).toEqual([])
expect(result.studyTypes.value).toEqual([])
})
it('handleSave toggles saved state', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockResolvedValue(mockApiResponse(null))
const result = useLiteratureDetail(12345)
expect(result.saved.value).toBe(false)
await result.handleSave()
expect(result.saved.value).toBe(true)
expect(api.post).toHaveBeenCalledWith('/literature/12345/save')
await result.handleSave()
expect(result.saved.value).toBe(false)
expect(api.delete).toHaveBeenCalledWith('/literature/12345/save')
})
it('handleRate calls API and updates rating', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockResolvedValue(mockApiResponse(null))
const result = useLiteratureDetail(12345)
await result.handleRate(5)
expect(result.rating.value).toBe(5)
expect(api.post).toHaveBeenCalledWith('/settings/literature/12345/rate', { rating: 5 })
})
it('formatDate returns first 10 chars', () => {
const result = useLiteratureDetail(12345)
expect(result.formatDate('2024-01-15T00:00:00Z')).toBe('2024-01-15')
expect(result.formatDate(null)).toBe('')
expect(result.formatDate(undefined)).toBe('')
})
it('exportOptions returns correct formats', () => {
const result = useLiteratureDetail(12345)
expect(result.exportOptions).toHaveLength(4)
expect(result.exportOptions[0]!.key).toBe('bibtex')
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { useLiteratureNotes } from '../useLiteratureNotes'
import type { NoteItem } from '../../types'
vi.mock('../../api/client', () => ({
api: { post: vi.fn(), delete: vi.fn() },
}))
vi.mock('../useToast', () => ({
useToast: () => ({ apiError: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}))
describe('useLiteratureNotes', () => {
it('initial state has empty content and not saving', () => {
const notes = ref<NoteItem[]>([])
const { noteContent, noteSaving } = useLiteratureNotes(12345, notes)
expect(noteContent.value).toBe('')
expect(noteSaving.value).toBe(false)
})
it('handleAddNote prepends to notes ref', async () => {
const { api } = await import('../../api/client')
const newNote: NoteItem = {
id: 'note1', content: 'test note', created_at: '2024-01-01T00:00:00Z',
}
vi.mocked(api.post).mockResolvedValue({ data: newNote })
const notes = ref<NoteItem[]>([])
const { noteContent, handleAddNote } = useLiteratureNotes(12345, notes)
noteContent.value = 'test note'
await handleAddNote()
expect(notes.value).toHaveLength(1)
expect(notes.value[0]!.content).toBe('test note')
expect(noteContent.value).toBe('')
})
it('handleAddNote does nothing for empty content', async () => {
const notes = ref<NoteItem[]>([])
const { handleAddNote } = useLiteratureNotes(12345, notes)
await handleAddNote()
expect(notes.value).toHaveLength(0)
})
it('handleDeleteNote removes from notes ref', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.delete).mockResolvedValue({})
const notes = ref<NoteItem[]>([
{ id: 'n1', content: 'first', created_at: '2024-01-01' },
{ id: 'n2', content: 'second', created_at: '2024-01-02' },
])
const { handleDeleteNote } = useLiteratureNotes(12345, notes)
await handleDeleteNote('n1')
expect(notes.value).toHaveLength(1)
expect(notes.value[0]!.id).toBe('n2')
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { useLiteraturePreview } from '../useLiteraturePreview'
describe('useLiteraturePreview', () => {
it('starts closed with null pmid', () => {
const { previewPmid, showPreview } = useLiteraturePreview()
expect(previewPmid.value).toBeNull()
expect(showPreview.value).toBe(false)
})
it('openPreview sets pmid and shows', () => {
const { previewPmid, showPreview, openPreview } = useLiteraturePreview()
openPreview(12345)
expect(previewPmid.value).toBe(12345)
expect(showPreview.value).toBe(true)
})
it('closePreview resets state', () => {
const { previewPmid, showPreview, openPreview, closePreview } = useLiteraturePreview()
openPreview(12345)
closePreview()
expect(previewPmid.value).toBeNull()
expect(showPreview.value).toBe(false)
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { usePagination } from '../usePagination'
describe('usePagination', () => {
it('starts at page 1 with default pageSize', () => {
const { page, pageSize, total } = usePagination({ fetchFn: vi.fn() })
expect(page.value).toBe(1)
expect(pageSize.value).toBe(20)
expect(total.value).toBe(0)
})
it('accepts custom pageSize', () => {
const { pageSize } = usePagination({ fetchFn: vi.fn(), pageSize: 50 })
expect(pageSize.value).toBe(50)
})
it('totalPages is computed correctly', () => {
const { total, totalPages } = usePagination({ fetchFn: vi.fn(), pageSize: 10 })
total.value = 25
expect(totalPages.value).toBe(3)
})
it('hasMore is true when not on last page', () => {
const { total, totalPages, hasMore } = usePagination({ fetchFn: vi.fn(), pageSize: 10 })
total.value = 25
expect(totalPages.value).toBe(3)
expect(hasMore.value).toBe(true)
})
it('hasMore is false on last page', () => {
const { total, page, hasMore } = usePagination({ fetchFn: vi.fn(), pageSize: 10 })
total.value = 10
page.value = 1
expect(hasMore.value).toBe(false)
})
it('goToPage calls fetchFn and updates page', async () => {
const fetchFn = vi.fn()
const { page, goToPage } = usePagination({ fetchFn })
await goToPage(3)
expect(page.value).toBe(3)
expect(fetchFn).toHaveBeenCalledWith(3)
})
it('totalPages is at least 1 for zero items', () => {
const { totalPages } = usePagination({ fetchFn: vi.fn() })
expect(totalPages.value).toBe(1)
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { usePersonalTags } from '../usePersonalTags'
import type { PersonalTag } from '../../types'
vi.mock('../../api/client', () => ({
api: { post: vi.fn(), delete: vi.fn() },
}))
vi.mock('../useToast', () => ({
useToast: () => ({ apiError: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}))
describe('usePersonalTags', () => {
it('initial newTag is empty', () => {
const tags = ref<PersonalTag[]>([])
const { newTag } = usePersonalTags(12345, tags)
expect(newTag.value).toBe('')
})
it('handleAddTag adds tag and clears input', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.post).mockResolvedValue({
data: { personal_tags: [{ name: 'oncology' }, { name: 'surgery' }] },
})
const tags = ref<PersonalTag[]>([])
const { newTag, handleAddTag } = usePersonalTags(12345, tags)
newTag.value = 'oncology'
await handleAddTag()
expect(tags.value).toHaveLength(2)
expect(tags.value[0]!.name).toBe('oncology')
expect(newTag.value).toBe('')
})
it('handleAddTag does nothing for empty input', async () => {
const tags = ref<PersonalTag[]>([])
const { handleAddTag } = usePersonalTags(12345, tags)
await handleAddTag()
expect(tags.value).toHaveLength(0)
})
it('handleRemoveTag removes from ref', async () => {
const { api } = await import('../../api/client')
vi.mocked(api.delete).mockResolvedValue({})
const tags = ref<PersonalTag[]>([
{ name: 'oncology' },
{ name: 'surgery' },
])
const { handleRemoveTag } = usePersonalTags(12345, tags)
await handleRemoveTag('oncology')
expect(tags.value).toHaveLength(1)
expect(tags.value[0]!.name).toBe('surgery')
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useSaveLiterature } from '../useSaveLiterature'
import { api } from '../../api/client'
vi.mock('../../api/client', () => ({
api: {
post: vi.fn(),
delete: vi.fn(),
},
}))
describe('useSaveLiterature', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with isSaving false', () => {
const { isSaving } = useSaveLiterature()
expect(isSaving.value).toBe(false)
})
it('toggleSave calls api.post when not saved', async () => {
vi.mocked(api.post).mockResolvedValue({ data: {} } as any)
const { toggleSave } = useSaveLiterature()
const result = await toggleSave(12345, false)
expect(api.post).toHaveBeenCalledWith('/literature/12345/save')
expect(result).toBe(true)
})
it('toggleSave calls api.delete when already saved', async () => {
vi.mocked(api.delete).mockResolvedValue({ data: {} } as any)
const { toggleSave } = useSaveLiterature()
const result = await toggleSave(12345, true)
expect(api.delete).toHaveBeenCalledWith('/literature/12345/save')
expect(result).toBe(false)
})
it('sets isSaving during request', async () => {
let resolvePromise!: () => void
vi.mocked(api.post).mockReturnValue(new Promise(r => { resolvePromise = () => r({ data: {} } as any) }) as any)
const { isSaving, toggleSave } = useSaveLiterature()
const promise = toggleSave(12345, false)
expect(isSaving.value).toBe(true)
resolvePromise()
await promise
expect(isSaving.value).toBe(false)
})
it('returns original saved state on error', async () => {
vi.mocked(api.post).mockRejectedValue(new Error('Network error'))
const { toggleSave } = useSaveLiterature()
const result = await toggleSave(12345, false)
expect(result).toBe(false) // original state preserved
})
})
+46
View File
@@ -0,0 +1,46 @@
import { ref } from 'vue'
import { api } from '../api/client'
export function useAiSummary(pmid: number) {
const aiLoading = ref(false)
const aiSummary = ref('')
const aiMode = ref('one_liner')
// 尝试加载已缓存的 AI 摘要(按 mode 匹配)
async function tryLoadCached() {
try {
const { data } = await api.get(`/ai/preview/${pmid}`)
const summariesByMode = data?.summaries_by_mode || {}
if (summariesByMode[aiMode.value]) {
aiSummary.value = summariesByMode[aiMode.value]
}
} catch { /* ignore */ }
}
tryLoadCached()
async function generateAI(mode: string) {
aiMode.value = mode
// 先查缓存
try {
const { data } = await api.get(`/ai/preview/${pmid}`)
const summariesByMode = data?.summaries_by_mode || {}
if (summariesByMode[mode]) {
aiSummary.value = summariesByMode[mode]
return
}
} catch { /* ignore */ }
// 缓存未命中,调用生成
aiLoading.value = true
try {
const { data } = await api.post(`/ai/generate/${pmid}?mode=${mode}`)
aiSummary.value = data.summary
} catch (e) {
const axiosErr = e as { response?: { status?: number } }
if (axiosErr.response?.status === 503) aiSummary.value = 'AI 服务暂不可用(需配置 OpenAI API Key),部署后即可自动生成。'
else aiSummary.value = '生成失败,请重试。'
} finally { aiLoading.value = false }
}
return { aiLoading, aiSummary, aiMode, generateAI }
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 用户行为追踪 composable
* - 自动页面访问追踪(在 router.afterEach 中调用)
* - 手动行为埋点(在关键交互处调用 trackAction
*/
import type { RouteLocationNormalized } from 'vue-router'
import { api } from '../api/client'
import { useAuthStore } from '../stores/auth'
function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
// HTTP 环境 fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
const _sessionId = generateUUID()
let _lastPath = ''
let _pageEnterTime = Date.now()
const ANONYMOUS_ID_KEY = 'scilit_anonymous_id'
function getOrCreateAnonymousId(): string {
let id = localStorage.getItem(ANONYMOUS_ID_KEY)
if (!id) {
id = generateUUID()
localStorage.setItem(ANONYMOUS_ID_KEY, id)
}
return id
}
/** 路由名称 → 中文页面名称映射 */
const ROUTE_TITLE: Record<string, string> = {
// 公开路由
home: '首页',
'public-detail': '文献详情',
journals: '期刊导航',
cancers: '癌种浏览',
drugs: '药品审批',
guidelines: '临床指南',
'guideline-compare': '指南对比',
'treatment-plans': '治疗方案',
pricing: '定价',
about: '关于',
help: '帮助',
// 认证路由
login: '登录',
'admin-login': '管理员登录',
register: '注册',
'accept-invite': '接受邀请',
'forgot-password': '忘记密码',
'reset-password': '重置密码',
// 应用路由
feed: '我的文献推送',
profile: '个人主页',
search: '文献搜索',
library: '我的文库',
detail: '文献详情',
team: '团队管理',
settings: '设置',
interests: '关注领域',
domains: '领域中心',
'journal-club': '期刊俱乐部',
mdt: 'MDT 多学科会诊',
activity: '浏览历史',
'api-management': 'API 管理',
notifications: '通知中心',
notes: '我的笔记',
reviews: '文献评阅',
'review-detail': '评阅详情',
'journal-detail': '期刊详情',
// 管理后台
'admin-dashboard': '管理后台',
'admin-tenants': '租户管理',
'admin-users': '用户管理',
'admin-pipeline': '数据管道',
'admin-tags': '标签管理',
'admin-journals': '期刊管理',
'admin-drug-approvals': '药品审批管理',
'admin-guidelines': '指南管理',
'admin-notifications': '通知管理',
'admin-page-views': '访问记录',
'admin-feedback': '用户反馈',
'admin-analytics': '数据分析',
'admin-config': '系统配置',
'admin-roles': '平台角色',
}
/** 记录页面访问(供 router.afterEach 调用,不阻塞导航) */
export function trackPageView(to: RouteLocationNormalized) {
if (to.path === _lastPath) return
const duration = Math.floor((Date.now() - _pageEnterTime) / 1000)
const dur = duration > 0 ? duration : undefined
const auth = useAuthStore()
if (auth.isAuthenticated) {
api.post('/analytics/track', {
activity_type: 'page_view',
target_type: 'route',
target_id: (to.name as string) || to.path,
detail: { path: to.path, title: ROUTE_TITLE[(to.name as string)] || document.title },
session_id: _sessionId,
duration_seconds: dur,
}).catch(() => { /* 静默失败 */ })
} else {
api.post('/public/page-view', {
anonymous_id: getOrCreateAnonymousId(),
path: to.path,
page_title: ROUTE_TITLE[(to.name as string)] || document.title,
duration_seconds: dur,
}).catch(() => { /* 静默失败 */ })
}
_lastPath = to.path
_pageEnterTime = Date.now()
}
/**
* 手动记录用户行为
*
* @param type activity_type, 如 'search' / 'view_literature' / 'save_literature'
* @param targetType target_type, 如 'literature' / 'tag' / 'journal'
* @param targetId target_id, 如 PMID / 标签ID
* @param detail 附加信息(如搜索关键词)
*/
export function trackAction(
type: string,
targetType?: string,
targetId?: string,
detail?: Record<string, unknown>,
) {
const auth = useAuthStore()
if (!auth.isAuthenticated) return
api.post('/analytics/track', {
activity_type: type,
target_type: targetType,
target_id: targetId,
detail,
session_id: _sessionId,
}).catch(() => { /* 静默失败 */ })
}
+25
View File
@@ -0,0 +1,25 @@
import { ref, readonly, type Ref } from 'vue'
export function useApiRequest<T>(fetcher: (...args: any[]) => Promise<T>) {
const data: Ref<T | null> = ref(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function execute(...args: any[]): Promise<T | null> {
loading.value = true
error.value = null
try {
const result = await fetcher(...args)
data.value = result
return result
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.message || '请求失败'
error.value = msg
return null
} finally {
loading.value = false
}
}
return { data: readonly(data), loading: readonly(loading), error: readonly(error), execute }
}
@@ -0,0 +1,30 @@
/** 全局请求加载状态 — 由 axios 拦截器自动维护 */
import { ref, watch } from 'vue'
/** 当前在途请求数 */
const pendingCount = ref(0)
/** 是否显示加载条(延迟 300ms 后为 true,避免闪烁) */
const showBar = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
watch(pendingCount, (count) => {
if (count > 0) {
// 请求开始后 300ms 才显示(快请求不闪条)
if (timer === null) {
timer = setTimeout(() => { showBar.value = true }, 300)
}
} else {
// 所有请求完成 → 立即隐藏
showBar.value = false
if (timer !== null) { clearTimeout(timer); timer = null }
}
})
export function useGlobalLoading() {
function increment() { pendingCount.value++ }
function decrement() { if (pendingCount.value > 0) pendingCount.value-- }
return { pendingCount, showBar, increment, decrement }
}
+92
View File
@@ -0,0 +1,92 @@
import { ref, onMounted } from 'vue'
import { api } from '../api/client'
import { useToast } from './useToast'
interface Highlight {
id: string
type: string
page: number
position: Record<string, unknown>
text: string | null
note: string | null
color: string
created_at: string | null
}
export function useHighlights(pmid: number) {
const toast = useToast()
const highlights = ref<Highlight[]>([])
const hlsLoading = ref(false)
const showHlForm = ref(false)
const hlText = ref('')
const hlNote = ref('')
const hlType = ref<'highlight' | 'underline' | 'strikethrough' | 'free_text'>('highlight')
const hlColor = ref('#ffff00')
const hlToolbarVisible = ref(false)
const hlToolbarPos = ref({ x: 0, y: 0 })
async function loadHighlights() {
hlsLoading.value = true
try {
const { data } = await api.get(`/highlights/${pmid}`)
highlights.value = data || []
} catch { highlights.value = [] }
finally { hlsLoading.value = false }
}
async function handleCreateHighlight() {
if (!hlText.value.trim()) return
try {
await api.post(`/highlights/${pmid}`, {
highlight_type: hlType.value,
page_number: 0,
position: {},
text: hlText.value.trim(),
note: hlNote.value.trim() || null,
color: hlColor.value,
})
hlText.value = ''
hlNote.value = ''
showHlForm.value = false
await loadHighlights()
} catch (e) { toast.apiError(e, '保存标注失败') }
}
async function handleDeleteHighlight(hid: string) {
try {
await api.delete(`/highlights/${hid}`)
highlights.value = highlights.value.filter((h: Highlight) => h.id !== hid)
} catch (e) { toast.apiError(e, '删除标注失败') }
}
function onAbstractMouseup(e: MouseEvent) {
const sel = window.getSelection()
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
hlToolbarVisible.value = false
return
}
hlToolbarPos.value = { x: e.clientX, y: e.clientY }
hlToolbarVisible.value = true
}
function useSelectedText() {
const sel = window.getSelection()
if (sel && !sel.isCollapsed) {
hlText.value = sel.toString().trim()
sel.removeAllRanges()
}
hlToolbarVisible.value = false
showHlForm.value = true
}
onMounted(() => { loadHighlights() })
return {
highlights, hlsLoading, showHlForm,
hlText, hlNote, hlType, hlColor,
hlToolbarVisible, hlToolbarPos,
handleCreateHighlight, handleDeleteHighlight,
onAbstractMouseup, useSelectedText,
}
}
@@ -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,
}
}
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import { api } from '../api/client'
import { useToast } from './useToast'
import type { NoteItem } from '../types'
import type { Ref } from 'vue'
export function useLiteratureNotes(pmid: number, notes: Ref<NoteItem[]>) {
const toast = useToast()
const noteContent = ref('')
const noteSaving = ref(false)
const showEditModal = ref(false)
const editingNoteId = ref('')
const editingContent = ref('')
const editIsPrivate = ref(true)
const editSaving = ref(false)
async function handleAddNote() {
if (!noteContent.value.trim()) return
noteSaving.value = true
try {
const { data } = await api.post(`/notes/literature/${pmid}`, { content: noteContent.value })
notes.value.unshift(data)
noteContent.value = ''
} catch (e) { toast.apiError(e, '保存笔记失败,请重试') } finally { noteSaving.value = false }
}
function handleOpenEdit(n: NoteItem) {
editingNoteId.value = n.id
editingContent.value = n.content
editIsPrivate.value = n.is_private !== false
showEditModal.value = true
}
async function handleSaveEdit() {
if (!editingContent.value.trim()) { toast.warning('笔记内容不能为空'); return }
editSaving.value = true
try {
const { data } = await api.put(`/notes/${editingNoteId.value}`, { content: editingContent.value, is_private: editIsPrivate.value })
const idx = notes.value.findIndex((n: NoteItem) => n.id === editingNoteId.value)
if (idx !== -1) notes.value[idx] = data
showEditModal.value = false
toast.success('笔记已更新')
} catch (e) { toast.apiError(e, '更新失败') }
finally { editSaving.value = false }
}
async function handleDeleteNote(id: string) {
try {
await api.delete(`/notes/${id}`)
notes.value = notes.value.filter((n: NoteItem) => n.id !== id)
} catch (e) { toast.apiError(e, '删除笔记失败,请重试') }
}
return { noteContent, noteSaving, handleAddNote, handleDeleteNote, showEditModal, editingContent, editIsPrivate, editSaving, handleOpenEdit, handleSaveEdit }
}
@@ -0,0 +1,18 @@
import { ref } from 'vue'
export function useLiteraturePreview() {
const previewPmid = ref<number | null>(null)
const showPreview = ref(false)
function openPreview(pmid: number) {
previewPmid.value = pmid
showPreview.value = true
}
function closePreview() {
previewPmid.value = null
showPreview.value = false
}
return { previewPmid, showPreview, openPreview, closePreview }
}
+23
View File
@@ -0,0 +1,23 @@
import { ref, computed } from 'vue'
interface UsePaginationOptions {
pageSize?: number
fetchFn: (page: number) => Promise<void>
}
export function usePagination(opts: UsePaginationOptions) {
const { fetchFn, pageSize: initialPageSize = 20 } = opts
const page = ref(1)
const pageSize = ref(initialPageSize)
const total = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const hasMore = computed(() => page.value < totalPages.value)
async function goToPage(n: number) {
page.value = n
await fetchFn(n)
}
return { page, pageSize, total, totalPages, hasMore, goToPage }
}
@@ -0,0 +1,28 @@
import { ref } from 'vue'
import { api } from '../api/client'
import { useToast } from './useToast'
import type { PersonalTag } from '../types'
import type { Ref } from 'vue'
export function usePersonalTags(pmid: number, personalTags: Ref<PersonalTag[]>) {
const toast = useToast()
const newTag = ref('')
async function handleAddTag() {
if (!newTag.value.trim()) return
try {
const { data } = await api.post(`/settings/literature/${pmid}/tag`, { tag: newTag.value })
personalTags.value = data.personal_tags || []
newTag.value = ''
} catch (e) { toast.apiError(e, '添加标签失败,请重试') }
}
async function handleRemoveTag(tagName: string) {
try {
await api.delete(`/settings/literature/${pmid}/tag?tag_name=${encodeURIComponent(tagName)}`)
personalTags.value = personalTags.value.filter((t: PersonalTag) => t.name !== tagName)
} catch (e) { toast.apiError(e, '删除标签失败,请重试') }
}
return { newTag, handleAddTag, handleRemoveTag }
}
@@ -0,0 +1,30 @@
import { ref } from 'vue'
import { api } from '../api/client'
import { useToast } from './useToast'
export function useSaveLiterature() {
const isSaving = ref(false)
async function toggleSave(pmid: number, isSaved: boolean): Promise<boolean> {
isSaving.value = true
const toast = useToast()
try {
if (isSaved) {
await api.delete(`/literature/${pmid}/save`)
toast.success('已取消收藏')
return false
} else {
await api.post(`/literature/${pmid}/save`)
toast.success('已收藏')
return true
}
} catch (e) {
toast.apiError(e, '操作失败')
return isSaved
} finally {
isSaving.value = false
}
}
return { isSaving, toggleSave }
}
+47
View File
@@ -0,0 +1,47 @@
import { useMessage } from 'naive-ui'
/** 前端错误消息翻译映射(覆盖后端返回的英文消息,与 backend/app/core/i18n.py 保持同步) */
const FRONTEND_ERRORS: Record<string, string> = {
'Invalid credentials': '邮箱或密码错误',
'Account disabled': '账号已停用',
'Invalid or expired token': '登录已过期,请重新登录',
'Refresh token revoked': '刷新令牌已被吊销',
'String should have at least 8 characters': '密码长度不能少于8位',
'Invalid or expired reset token': '重置链接无效或已过期',
'If approved, a confirmation email will be sent': '该邮箱已注册,请查看邮件确认',
'If the email exists, a reset link has been sent.': '如果该邮箱已注册,重置链接已发送',
'Not authenticated': '未登录,请先登录',
'Invalid token type': '令牌类型无效',
'Token has been revoked': '令牌已被吊销',
'Missing tenant context': '缺少租户信息',
'Not a member of this tenant': '不属于该组织',
'Invalid role': '角色无效',
'Insufficient permissions': '权限不足',
'An unexpected error occurred': '服务器内部错误,请稍后重试',
}
export function useToast() {
const message = useMessage()
return {
success(msg: string) { message.success(msg) },
error(msg: string) { message.error(msg) },
warning(msg: string) { message.warning(msg) },
info(msg: string) { message.info(msg) },
apiError(e: unknown, fallback = '操作失败') {
const err = e as { response?: { data?: { detail?: unknown; error?: { message?: string } } } }
let detail = err?.response?.data?.error?.message || ''
const rawDetail = err?.response?.data?.detail
if (Array.isArray(rawDetail)) {
const first = rawDetail[0] as { msg?: string; loc?: string[] } | undefined
detail = first?.msg || ''
} else if (typeof rawDetail === 'string') {
detail = rawDetail
}
// 去掉 Pydantic "Value error, " 前缀
detail = detail.replace(/^Value error,\s*/, '')
const translated = FRONTEND_ERRORS[detail] || detail || fallback
message.error(translated)
},
}
}
@@ -0,0 +1,90 @@
import { ref } from 'vue'
import { api } from '../api/client'
export function useVerification(toast: { success: (msg: string) => void; apiError: (e: any, msg: string) => void; warning: (msg: string) => void }) {
const emailCodeSent = ref(false)
const phoneCodeSent = ref(false)
const emailVerified = ref(false)
const phoneVerified = ref(false)
const sendingEmail = ref(false)
const sendingPhone = ref(false)
const emailCountdown = ref(0)
const phoneCountdown = ref(0)
let emailTimer: ReturnType<typeof setInterval> | null = null
let phoneTimer: ReturnType<typeof setInterval> | null = null
function startCountdown(type: 'email' | 'phone') {
if (type === 'email') {
emailCountdown.value = 60
if (emailTimer) clearInterval(emailTimer)
emailTimer = setInterval(() => {
if (emailCountdown.value > 0) emailCountdown.value--
else if (emailTimer) { clearInterval(emailTimer); emailTimer = null }
}, 1000)
} else {
phoneCountdown.value = 60
if (phoneTimer) clearInterval(phoneTimer)
phoneTimer = setInterval(() => {
if (phoneCountdown.value > 0) phoneCountdown.value--
else if (phoneTimer) { clearInterval(phoneTimer); phoneTimer = null }
}, 1000)
}
}
async function sendEmailCode() {
sendingEmail.value = true
try {
await api.post('/auth/verification/send-email', { email: '' })
emailCodeSent.value = true
startCountdown('email')
toast.success('验证码已发送到您的邮箱')
} catch (e: any) {
toast.apiError(e, '发送失败')
} finally {
sendingEmail.value = false
}
}
async function verifyEmail(code: string) {
try {
await api.post('/auth/verification/verify-email', { code })
emailVerified.value = true
toast.success('邮箱已通过验证')
} catch (e: any) {
toast.apiError(e, '验证失败')
throw e
}
}
async function sendPhoneCode(phone: string) {
sendingPhone.value = true
try {
await api.post('/auth/verification/send-phone', { phone })
phoneCodeSent.value = true
startCountdown('phone')
toast.success('验证码已发送到您的手机')
} catch (e: any) {
toast.apiError(e, '发送失败')
} finally {
sendingPhone.value = false
}
}
async function verifyPhone(code: string) {
try {
await api.post('/auth/verification/verify-phone', { code })
phoneVerified.value = true
toast.success('手机已通过验证')
} catch (e: any) {
toast.apiError(e, '验证失败')
throw e
}
}
return {
emailCodeSent, phoneCodeSent, emailVerified, phoneVerified,
sendingEmail, sendingPhone, emailCountdown, phoneCountdown,
sendEmailCode, verifyEmail, sendPhoneCode, verifyPhone,
}
}