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,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
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(() => { /* 静默失败 */ })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user