fix(workspace): background vector indexing and correct diagram previews
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -10,6 +10,7 @@ import SecondarySidebar from './SecondarySidebar.vue'
|
||||
import StatusBar from './StatusBar.vue'
|
||||
import TitleBar from './TitleBar.vue'
|
||||
import CommandPalette from './CommandPalette.vue'
|
||||
import { getIndexStatus } from '@/services/indexService'
|
||||
import { navigateToCitation } from '@/composables/useCitationNavigation'
|
||||
|
||||
defineProps<{
|
||||
@@ -23,7 +24,14 @@ const settingsStore = useSettingsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => { void settingsStore.loadDiagnostics() })
|
||||
let statusTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let disposed = false
|
||||
async function pollIndex() {
|
||||
try { settingsStore.indexStatus = await getIndexStatus() } catch { /* retain last status; retry */ }
|
||||
if (!disposed) statusTimer = setTimeout(pollIndex, 5000)
|
||||
}
|
||||
onMounted(() => { void settingsStore.loadDiagnostics(); void pollIndex() })
|
||||
onUnmounted(() => { disposed = true; clearTimeout(statusTimer) })
|
||||
watch(() => settingsStore.defaultEditorMode, (mode) => editorStore.setMode(mode), { immediate: true })
|
||||
watch(() => settingsStore.editorLineWidth, (width) => {
|
||||
document.documentElement.style.setProperty('--editor-line-width', `${width}ch`)
|
||||
|
||||
@@ -82,3 +82,59 @@ it('zooms directly in the viewer with bounded speed even for a large wheel delta
|
||||
expect(Number(dialog.querySelector('output')!.textContent!.replace('%', ''))).toBeLessThanOrEqual(105)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
it('starts wheel zoom from the fitted width instead of the intrinsic SVG width', async () => {
|
||||
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 4000 2000"></svg></div>' }, attachTo: document.body })
|
||||
const svg = wrapper.get('svg').element as SVGSVGElement
|
||||
vi.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ width: 400, height: 200, left: 0, top: 0 } as DOMRect)
|
||||
await wrapper.get('svg').trigger('mousedown', { button: 1 })
|
||||
svg.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }))
|
||||
expect(parseFloat(svg.style.width)).toBeGreaterThan(400)
|
||||
expect(parseFloat(svg.style.width)).toBeLessThanOrEqual(420)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the cursor point fixed by adjusting the scroll container during zoom', async () => {
|
||||
let frame: FrameRequestCallback | undefined
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frame = callback; return 1 })
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid" style="overflow-x:auto;overflow-y:auto"><svg viewBox="0 0 400 200"></svg></div>' }, attachTo: document.body })
|
||||
try {
|
||||
const container = wrapper.get('.markdown-mermaid').element as HTMLElement
|
||||
const svg = wrapper.get('svg').element as SVGSVGElement
|
||||
vi.spyOn(svg, 'getBoundingClientRect').mockImplementation(() => {
|
||||
const width = parseFloat(svg.style.width) || 400
|
||||
return { width, height: width / 2, left: -container.scrollLeft, top: -container.scrollTop } as DOMRect
|
||||
})
|
||||
await wrapper.get('svg').trigger('mousedown', { button: 1, clientX: 100, clientY: 50 })
|
||||
svg.dispatchEvent(new WheelEvent('wheel', { clientX: 100, clientY: 50, deltaY: -100, bubbles: true, cancelable: true }))
|
||||
frame?.(performance.now())
|
||||
const rect = svg.getBoundingClientRect()
|
||||
expect(rect.left + rect.width * .25).toBeCloseTo(100)
|
||||
expect(rect.top + rect.height * .25).toBeCloseTo(50)
|
||||
expect(container.scrollLeft).toBeGreaterThan(0)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
raf.mockRestore(); cancel.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('fits the full chart on open and clears the previous viewport scroll', async () => {
|
||||
const wrapper = mount(DiagramInteractions, { slots: { default: '<div class="markdown-mermaid"><svg viewBox="0 0 2400 200"><text>Final task</text></svg><button data-diagram-action="view">View</button></div>' }, attachTo: document.body })
|
||||
const dialog = document.querySelector('dialog')!
|
||||
dialog.showModal = vi.fn()
|
||||
const viewport = dialog.querySelector('.diagram-viewer-scroll') as HTMLElement
|
||||
Object.defineProperty(viewport, 'clientWidth', { value: 1000 })
|
||||
Object.defineProperty(viewport, 'clientHeight', { value: 600 })
|
||||
viewport.scrollLeft = 900
|
||||
viewport.scrollTop = 30
|
||||
await wrapper.get('[data-diagram-action="view"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect((dialog.querySelector('.diagram-viewer-image') as HTMLElement).style.width).toBe('1000px')
|
||||
expect(viewport.scrollLeft).toBe(0)
|
||||
expect(viewport.scrollTop).toBe(0)
|
||||
expect(dialog.textContent).toContain('Final task')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -13,6 +13,38 @@ let wheelTarget: HTMLElement | null = null
|
||||
let anchor = { x: 0, y: 0 }
|
||||
const wheelActive = ref(false)
|
||||
let lastWheel = 0
|
||||
const zoomBases = new WeakMap<HTMLElement, number>()
|
||||
let anchorFrame = 0
|
||||
let anchorUntil = 0
|
||||
function stopAnchoring() { cancelAnimationFrame(anchorFrame); anchorFrame = 0 }
|
||||
function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
|
||||
stopAnchoring()
|
||||
const rect = svg.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
|
||||
const screenX = rect.left + x * rect.width
|
||||
const screenY = rect.top + y * rect.height
|
||||
const scrollers: HTMLElement[] = []
|
||||
for (let node = svg.parentElement; node; node = node.parentElement) {
|
||||
const style = getComputedStyle(node)
|
||||
if (/(auto|scroll)/.test(`${style.overflowX} ${style.overflowY}`)) scrollers.push(node)
|
||||
if (node === viewer.value) break
|
||||
}
|
||||
anchorUntil = performance.now() + 240
|
||||
const follow = () => {
|
||||
if (!svg.isConnected) return
|
||||
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
|
||||
// Re-measure after each scroll, letting the outer container take the remainder.
|
||||
for (const node of scrollers) {
|
||||
const current = svg.getBoundingClientRect()
|
||||
node.scrollLeft += current.left + x * current.width - screenX
|
||||
node.scrollTop += current.top + y * current.height - screenY
|
||||
}
|
||||
if (performance.now() < anchorUntil) anchorFrame = requestAnimationFrame(follow)
|
||||
}
|
||||
anchorFrame = requestAnimationFrame(follow)
|
||||
}
|
||||
function wheelFactor(event: WheelEvent) {
|
||||
const now = performance.now()
|
||||
const elapsed = lastWheel ? Math.min(100, Math.max(0, now - lastWheel)) : 80
|
||||
@@ -22,9 +54,12 @@ function wheelFactor(event: WheelEvent) {
|
||||
}
|
||||
function viewerWheel(event: WheelEvent) {
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
const svg = viewer.value?.querySelector<SVGSVGElement>('.diagram-viewer-image svg')
|
||||
if (svg) anchorZoom(svg, event)
|
||||
scale.value = Math.max(.2, Math.min(5, scale.value * wheelFactor(event)))
|
||||
}
|
||||
function disarm() {
|
||||
stopAnchoring(); lastWheel = 0
|
||||
wheelTarget?.removeAttribute('data-wheel-zoom')
|
||||
wheelTarget = null; wheelActive.value = false
|
||||
document.removeEventListener('mousemove', moved, true)
|
||||
@@ -46,6 +81,8 @@ function arm(event: MouseEvent) {
|
||||
function wheel(event: WheelEvent) {
|
||||
if (!wheelTarget?.isConnected || !(event.target instanceof Node) || !wheelTarget.contains(event.target)) { disarm(); return }
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
const svg = wheelTarget.querySelector<SVGSVGElement>('svg')
|
||||
if (svg) anchorZoom(svg, event)
|
||||
const factor = wheelFactor(event)
|
||||
if (wheelTarget.classList.contains('diagram-viewer-image')) scale.value = Math.max(.2, Math.min(5, scale.value * factor))
|
||||
else zoom(wheelTarget, Math.max(.2, Math.min(5, Number(wheelTarget.dataset.diagramScale || 1) * factor)))
|
||||
@@ -53,10 +90,12 @@ function wheel(event: WheelEvent) {
|
||||
function zoom(diagram: HTMLElement, next: number) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>('svg')
|
||||
if (!svg) return
|
||||
if (!zoomBases.has(diagram)) zoomBases.set(diagram, svg.getBoundingClientRect().width || widthOf(svg))
|
||||
diagram.dataset.diagramScale = String(next)
|
||||
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px`
|
||||
svg.style.width = next === 1 ? '' : `${zoomBases.get(diagram)! * next}px`
|
||||
svg.style.maxWidth = next === 1 ? '' : 'none'
|
||||
svg.style.height = 'auto'
|
||||
if (next === 1) zoomBases.delete(diagram)
|
||||
}
|
||||
onBeforeUnmount(disarm)
|
||||
function widthOf(svg: SVGSVGElement) {
|
||||
@@ -72,8 +111,9 @@ async function interact(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
const action = button.dataset.diagramAction
|
||||
if (action === 'view') {
|
||||
disarm()
|
||||
opener = button
|
||||
baseWidth.value = widthOf(svg)
|
||||
const intrinsicWidth = widthOf(svg)
|
||||
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
|
||||
// integration point while still sanitizing the embedded HTML and handlers.
|
||||
const copy = svg.cloneNode(true) as SVGSVGElement
|
||||
@@ -88,6 +128,15 @@ async function interact(event: MouseEvent) {
|
||||
scale.value = 1
|
||||
await nextTick()
|
||||
viewer.value?.showModal()
|
||||
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
|
||||
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
|
||||
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
|
||||
// Opening is independent of the inline preview's zoom and any previous modal scroll.
|
||||
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
|
||||
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
|
||||
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
|
||||
await nextTick()
|
||||
if (viewport) { viewport.scrollLeft = 0; viewport.scrollTop = 0 }
|
||||
return
|
||||
}
|
||||
const previous = Number(diagram.dataset.diagramScale || 1)
|
||||
@@ -122,14 +171,15 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
.diagram-controls button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
|
||||
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
|
||||
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
|
||||
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||
.diagram-viewer { margin: auto; width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||
.diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; }
|
||||
.diagram-viewer::backdrop { background: var(--color-background-overlay); }
|
||||
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
|
||||
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
|
||||
.diagram-viewer-scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; }
|
||||
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
|
||||
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
|
||||
.diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
|
||||
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -40,7 +40,8 @@ const saveStatusColor = computed(() => {
|
||||
|
||||
const indexStatusText = computed(() => {
|
||||
const s = settingsStore.indexStatus.status
|
||||
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? `${t('索引中', 'Indexing')} (${settingsStore.indexStatus.pending_jobs})` : t('索引错误', 'Index error')
|
||||
if (s === 'idle' && settingsStore.indexStatus.vector_refresh_required) return t('全文可用 · 向量待重建', 'Full text ready · vectors need rebuilding')
|
||||
return s === 'unknown' ? t('索引状态未获取', 'Index status unavailable') : s === 'idle' ? t('索引就绪', 'Index ready') : s === 'indexing' ? t('后台计算索引', 'Indexing in background') : t('索引错误', 'Index error')
|
||||
})
|
||||
|
||||
const aiCoreStatusText = computed(() => {
|
||||
|
||||
@@ -507,6 +507,7 @@ export interface ThemeConfig {
|
||||
// ============ Index ============
|
||||
|
||||
export interface IndexStatus {
|
||||
vector_refresh_required?: boolean
|
||||
status: 'unknown' | 'idle' | 'indexing' | 'error'
|
||||
pending_jobs: number
|
||||
total_notes: number | null
|
||||
@@ -800,6 +801,7 @@ export interface ApiTask {
|
||||
}
|
||||
|
||||
export interface ApiIndexStatus {
|
||||
vector_refresh_required?: boolean
|
||||
total_notes: number
|
||||
total_blocks: number
|
||||
status: 'idle' | 'queued' | 'running' | 'failed'
|
||||
|
||||
@@ -408,6 +408,9 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
|
||||
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
|
||||
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
|
||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||
.milkdown-host :deep(.milkdown-code-block) { overflow: visible; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsStore } from '@/stores/settings'
|
||||
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
import { ApiErrorClass } from '@/services/apiClient'
|
||||
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -15,29 +16,34 @@ const settingsStore = useSettingsStore()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const aiCoreStatus = ref<'checking' | 'running' | 'stopped'>('checking')
|
||||
const openError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([workspaceStore.loadRecentVaults(), settingsStore.loadDiagnostics()])
|
||||
onMounted(() => { void initializeVault() })
|
||||
|
||||
async function initializeVault() {
|
||||
openError.value = ''
|
||||
void settingsStore.loadDiagnostics().then(() => {
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
}).catch(() => { aiCoreStatus.value = 'stopped' })
|
||||
try { await workspaceStore.loadRecentVaults() }
|
||||
catch (reason) { openError.value = reason instanceof Error ? reason.message : String(reason); return }
|
||||
const lastVaultPath = localStorage.getItem('last-vault-path')
|
||||
if (settingsStore.restoreLastVault && lastVaultPath) {
|
||||
try {
|
||||
await openVault(lastVaultPath)
|
||||
return
|
||||
} catch {
|
||||
// 历史保存的旧路径可能与当前后端 Vault 不同,清除后让用户重新选择。
|
||||
localStorage.removeItem('last-vault-path')
|
||||
}
|
||||
await openVault(lastVaultPath)
|
||||
}
|
||||
{
|
||||
aiCoreStatus.value = settingsStore.aiCoreStatus === 'running' ? 'running' : 'stopped'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openVault(path: string) {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
openError.value = ''
|
||||
try {
|
||||
await workspaceStore.openVault(path)
|
||||
router.push('/workspace')
|
||||
await router.push('/workspace')
|
||||
void settingsStore.loadDiagnostics()
|
||||
} catch (reason) {
|
||||
openError.value = reason instanceof Error ? reason.message : String(reason)
|
||||
if (reason instanceof ApiErrorClass && reason.code === 'WORKSPACE_PATH_MISMATCH') localStorage.removeItem('last-vault-path')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -60,6 +66,8 @@ async function openFolderPicker() {
|
||||
</div>
|
||||
|
||||
<div class="vault-card">
|
||||
<div v-if="openError" class="error-banner" role="alert">{{ openError }} <button class="btn" @click="initializeVault" :disabled="isLoading">{{ t('重试', 'Retry') }}</button></div>
|
||||
<p v-if="isLoading" role="status">{{ t('正在打开知识库…', 'Opening knowledge base…') }}</p>
|
||||
<h2 class="card-title">{{ t('选择知识库', 'Select Knowledge Base') }}</h2>
|
||||
<p class="card-desc">{{ t('Web 联调模式连接 AI Core 当前配置的 Vault', 'Web development mode connects to the Vault configured in AI Core') }}</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import apiClient from './apiClient'
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
|
||||
|
||||
it('aborts a stuck request with an actionable timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
let signal: AbortSignal | undefined
|
||||
vi.stubGlobal('fetch', vi.fn((_url, options) => new Promise((_resolve, reject) => {
|
||||
signal = options.signal
|
||||
signal?.addEventListener('abort', () => reject(new Error('aborted')))
|
||||
})))
|
||||
const assertion = expect(apiClient.get('/api/workspace', { timeoutMs: 15000 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT' })
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
await assertion
|
||||
expect(signal?.aborted).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the deadline after success', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"ok":true}', { headers: { 'Content-Type': 'application/json' } })))
|
||||
expect(await apiClient.get('/health', { timeoutMs: 10000 })).toEqual({ ok: true })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ export function resolveApiUrl(path: string): string {
|
||||
}
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
timeoutMs?: number
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
token?: string
|
||||
}
|
||||
@@ -26,7 +27,13 @@ export class ApiErrorClass extends Error {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, token, headers, ...rest } = options
|
||||
const { params, token, headers, timeoutMs, ...rest } = options
|
||||
const controller = timeoutMs ? new AbortController() : null
|
||||
let timedOut = false
|
||||
const abort = () => controller?.abort()
|
||||
if (rest.signal?.aborted) abort()
|
||||
rest.signal?.addEventListener('abort', abort, { once: true })
|
||||
const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined
|
||||
|
||||
let url = resolveApiUrl(path)
|
||||
|
||||
@@ -54,6 +61,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
...rest,
|
||||
signal: controller?.signal ?? rest.signal,
|
||||
headers: reqHeaders,
|
||||
})
|
||||
|
||||
@@ -78,8 +86,12 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
throw new ApiErrorClass(code, message, details)
|
||||
} catch (e) {
|
||||
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
|
||||
if (e instanceof ApiErrorClass) throw e
|
||||
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
rest.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiIndexJob, ApiIndexStatus, IndexStatus } from '@/contracts'
|
||||
|
||||
function toIndexStatus(status: ApiIndexStatus): IndexStatus {
|
||||
return {
|
||||
vector_refresh_required: status.vector_refresh_required ?? false,
|
||||
status: status.status === 'idle' ? 'idle' : status.status === 'failed' ? 'error' : 'indexing',
|
||||
pending_jobs: status.pending_jobs,
|
||||
total_notes: status.total_notes ?? null,
|
||||
@@ -13,7 +14,7 @@ function toIndexStatus(status: ApiIndexStatus): IndexStatus {
|
||||
}
|
||||
|
||||
export async function getIndexStatus(): Promise<IndexStatus> {
|
||||
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status'))
|
||||
return toIndexStatus(await apiClient.get<ApiIndexStatus>('/api/index/status', { timeoutMs: 10000 }))
|
||||
}
|
||||
|
||||
export async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full'): Promise<ApiIndexJob> {
|
||||
|
||||
@@ -2,13 +2,13 @@ import apiClient from './apiClient'
|
||||
import type { SystemStatus } from '@/contracts'
|
||||
|
||||
export function healthCheck(): Promise<{ status: string }> {
|
||||
return apiClient.get('/health')
|
||||
return apiClient.get('/health', { timeoutMs: 10000 })
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<SystemStatus> {
|
||||
return apiClient.get('/api/status')
|
||||
return apiClient.get('/api/status', { timeoutMs: 10000 })
|
||||
}
|
||||
|
||||
export function getPermissionPolicy(): Promise<Record<string, 'allow' | 'confirm' | 'deny'>> {
|
||||
return apiClient.get('/api/permissions/policy')
|
||||
return apiClient.get('/api/permissions/policy', { timeoutMs: 10000 })
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ async function requireNoteId(filePath: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function getWorkspaceInfo(): Promise<ApiWorkspaceInfo> {
|
||||
return apiClient.get('/api/workspace')
|
||||
return apiClient.get('/api/workspace', { timeoutMs: 15000 })
|
||||
}
|
||||
|
||||
export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
@@ -88,7 +88,7 @@ export async function getRecentVaults(): Promise<VaultInfo[]> {
|
||||
}
|
||||
|
||||
export async function openVault(path: string): Promise<VaultInfo> {
|
||||
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path })
|
||||
const snapshot = await apiClient.post<ApiWorkspaceSnapshot>('/api/workspace/open', { path }, { timeoutMs: 15000 })
|
||||
cacheEntries(snapshot.items)
|
||||
return {
|
||||
vault_id: snapshot.workspace.vault_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
if (currentFilePath.value === targetPath) saveStatus.value = 'save_failed'
|
||||
} finally {
|
||||
pendingSave = null
|
||||
if (currentFilePath.value === targetPath && saveStatus.value === 'dirty') scheduleAutoSave()
|
||||
}
|
||||
})()
|
||||
return pendingSave
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useEditorStore } from './editor'
|
||||
import * as workspace from '@/services/workspaceService'
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
|
||||
it('saves text typed while the previous save is still pending', async () => {
|
||||
vi.useFakeTimers()
|
||||
setActivePinia(createPinia())
|
||||
const store = useEditorStore()
|
||||
store.currentFilePath = '/draft.md'
|
||||
let release!: () => void
|
||||
const write = vi.spyOn(workspace, 'saveFileContent').mockImplementationOnce(() => new Promise<void>(resolve => { release = resolve })).mockResolvedValue()
|
||||
store.updateContent('first')
|
||||
const saving = store.save()
|
||||
store.updateContent('latest')
|
||||
release()
|
||||
await saving
|
||||
expect(store.saveStatus).toBe('dirty')
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(write).toHaveBeenLastCalledWith('/draft.md', 'latest')
|
||||
expect(store.saveStatus).toBe('saved')
|
||||
})
|
||||
Reference in New Issue
Block a user