feat(#240,#241): DocInspectTab — Markdown/Elements/Images from Docling analysis
This commit is contained in:
parent
63dfcaab67
commit
7dd76e39f8
5 changed files with 281 additions and 12 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api'
|
||||
import {
|
||||
createAnalysis,
|
||||
fetchAnalyses,
|
||||
fetchAnalysis,
|
||||
deleteAnalysis,
|
||||
fetchDocumentAnalyses,
|
||||
} from './api'
|
||||
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
|
|
@ -66,4 +72,14 @@ describe('analysis API', () => {
|
|||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/42', { method: 'DELETE' })
|
||||
})
|
||||
|
||||
it('fetchDocumentAnalyses calls GET /api/analyses?documentId=:id', async () => {
|
||||
const analyses = [{ id: '1', documentId: 'doc-42', status: 'COMPLETED' }]
|
||||
apiFetch.mockResolvedValue(analyses)
|
||||
|
||||
const result = await fetchDocumentAnalyses('doc-42')
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/analyses?documentId=doc-42')
|
||||
expect(result).toEqual(analyses)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,3 +37,7 @@ export function fetchAnalysis(id: string): Promise<Analysis> {
|
|||
export function deleteAnalysis(id: string): Promise<unknown> {
|
||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function fetchDocumentAnalyses(docId: string): Promise<Analysis[]> {
|
||||
return apiFetch<Analysis[]>(`/api/analyses?documentId=${encodeURIComponent(docId)}`)
|
||||
}
|
||||
|
|
|
|||
131
frontend/src/features/analysis/ui/InspectResultTabs.vue
Normal file
131
frontend/src/features/analysis/ui/InspectResultTabs.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<div class="inspect-result-tabs">
|
||||
<div class="irt-tab-strip">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
:key="tab.id"
|
||||
class="irt-tab-btn"
|
||||
:class="{ active: activeTab === tab.id }"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="irt-content">
|
||||
<!-- Markdown — full document -->
|
||||
<div v-if="activeTab === 'markdown'" class="irt-markdown">
|
||||
<MarkdownViewer :content="analysis.contentMarkdown ?? undefined" />
|
||||
</div>
|
||||
|
||||
<!-- Elements — StructureViewer (page images + bbox overlay) -->
|
||||
<div v-else-if="activeTab === 'elements'" class="irt-elements">
|
||||
<div v-if="pages.length === 0" class="irt-empty">
|
||||
{{ t('inspect.noElements') }}
|
||||
</div>
|
||||
<StructureViewer v-else :pages="pages" :document-id="docId" />
|
||||
</div>
|
||||
|
||||
<!-- Images — picture elements extracted from all pages -->
|
||||
<div v-else-if="activeTab === 'images'" class="irt-images">
|
||||
<ImageGallery :pages="pages" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Analysis, Page } from '../../../shared/types'
|
||||
import MarkdownViewer from './MarkdownViewer.vue'
|
||||
import StructureViewer from './StructureViewer.vue'
|
||||
import ImageGallery from './ImageGallery.vue'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
analysis: Analysis
|
||||
docId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const TABS = [
|
||||
{ id: 'markdown', labelKey: 'inspect.tabMarkdown' },
|
||||
{ id: 'elements', labelKey: 'inspect.tabElements' },
|
||||
{ id: 'images', labelKey: 'inspect.tabImages' },
|
||||
] as const
|
||||
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
const activeTab = ref<TabId>('elements')
|
||||
|
||||
const pages = computed<Page[]>(() => {
|
||||
if (!props.analysis.pagesJson) return []
|
||||
try {
|
||||
return JSON.parse(props.analysis.pagesJson) as Page[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspect-result-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.irt-tab-strip {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.irt-tab-btn {
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.irt-tab-btn:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.irt-tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.irt-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.irt-markdown,
|
||||
.irt-elements,
|
||||
.irt-images {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.irt-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,44 +1,141 @@
|
|||
<template>
|
||||
<div class="inspect-tab" data-e2e="inspect-tab">
|
||||
<div class="coming-soon">
|
||||
<p class="coming-soon-title">{{ t('workspace.inspectComingSoon') }}</p>
|
||||
<p class="coming-soon-sub">{{ t('workspace.inspectComingSoonHint') }}</p>
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="inspect-state">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="inspect-state inspect-state--error">
|
||||
<p>{{ error }}</p>
|
||||
<button class="retry-btn" @click="load">{{ t('inspect.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- No analysis yet -->
|
||||
<div v-else-if="!analysis" class="inspect-state">
|
||||
<p class="inspect-empty-title">{{ t('inspect.noAnalysis') }}</p>
|
||||
<p class="inspect-empty-sub">{{ t('inspect.noAnalysisSub') }}</p>
|
||||
<RouterLink :to="{ name: ROUTES.STUDIO }" class="inspect-cta">
|
||||
{{ t('inspect.goToStudio') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<InspectResultTabs v-else :analysis="analysis" :doc-id="docId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Analysis } from '../shared/types'
|
||||
import { fetchDocumentAnalyses } from '../features/analysis/api'
|
||||
import InspectResultTabs from '../features/analysis/ui/InspectResultTabs.vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
|
||||
defineProps<{ docId: string }>()
|
||||
const props = defineProps<{ docId: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const analysis = ref<Analysis | null>(null)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const analyses = await fetchDocumentAnalyses(props.docId)
|
||||
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'Failed to load analysis'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspect-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inspect-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.inspect-state--error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.coming-soon-title {
|
||||
.inspect-empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.coming-soon-sub {
|
||||
.inspect-empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspect-cta {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.inspect-cta:hover {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -361,6 +361,17 @@ const messages: Messages = {
|
|||
'settings.about': '\u00C0 propos',
|
||||
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
|
||||
|
||||
// Inspect tab (#240, #241)
|
||||
'inspect.tabMarkdown': 'Markdown',
|
||||
'inspect.tabElements': '\u00c9l\u00e9ments',
|
||||
'inspect.tabImages': 'Images',
|
||||
'inspect.noAnalysis': 'Aucune analyse disponible',
|
||||
'inspect.noAnalysisSub': 'Analysez ce document dans le Studio pour voir sa structure.',
|
||||
'inspect.goToStudio': 'Aller dans le Studio',
|
||||
'inspect.retry': 'R\u00e9essayer',
|
||||
'inspect.noElements':
|
||||
'Aucun \u00e9l\u00e9ment \u2014 lancez une analyse pour g\u00e9n\u00e9rer la structure.',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
|
|
@ -758,6 +769,16 @@ const messages: Messages = {
|
|||
'settings.about': 'About',
|
||||
'settings.designArticle': 'How Docling Studio was designed',
|
||||
|
||||
// Inspect tab (#240, #241)
|
||||
'inspect.tabMarkdown': 'Markdown',
|
||||
'inspect.tabElements': 'Elements',
|
||||
'inspect.tabImages': 'Images',
|
||||
'inspect.noAnalysis': 'No analysis available',
|
||||
'inspect.noAnalysisSub': 'Analyze this document in Studio to see its structure.',
|
||||
'inspect.goToStudio': 'Go to Studio',
|
||||
'inspect.retry': 'Retry',
|
||||
'inspect.noElements': 'No elements — run an analysis to generate structure.',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
|
|
|
|||
Loading…
Reference in a new issue