Merge pull request #246 from scub-france/feature/240-inspect-tab
feat(#240,#241): E6 — DocInspectTab (Markdown / Elements / Images)
This commit is contained in:
commit
71434aeb48
5 changed files with 281 additions and 12 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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', () => ({
|
vi.mock('../../shared/api/http', () => ({
|
||||||
apiFetch: vi.fn(),
|
apiFetch: vi.fn(),
|
||||||
|
|
@ -66,4 +72,14 @@ describe('analysis API', () => {
|
||||||
|
|
||||||
expect(apiFetch).toHaveBeenCalledWith('/api/analyses/42', { method: 'DELETE' })
|
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> {
|
export function deleteAnalysis(id: string): Promise<unknown> {
|
||||||
return apiFetch(`/api/analyses/${id}`, { method: 'DELETE' })
|
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>
|
<template>
|
||||||
<div class="inspect-tab" data-e2e="inspect-tab">
|
<div class="inspect-tab" data-e2e="inspect-tab">
|
||||||
<div class="coming-soon">
|
<!-- Loading -->
|
||||||
<p class="coming-soon-title">{{ t('workspace.inspectComingSoon') }}</p>
|
<div v-if="loading" class="inspect-state">
|
||||||
<p class="coming-soon-sub">{{ t('workspace.inspectComingSoonHint') }}</p>
|
<span class="spinner" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from '../shared/i18n'
|
||||||
|
import { ROUTES } from '../shared/routing/names'
|
||||||
|
|
||||||
defineProps<{ docId: string }>()
|
const props = defineProps<{ docId: string }>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.inspect-tab {
|
.inspect-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inspect-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
gap: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coming-soon {
|
.inspect-state--error {
|
||||||
text-align: center;
|
color: var(--error);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coming-soon-title {
|
.inspect-empty-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coming-soon-sub {
|
.inspect-empty-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,17 @@ const messages: Messages = {
|
||||||
'settings.about': '\u00C0 propos',
|
'settings.about': '\u00C0 propos',
|
||||||
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
|
'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)
|
// Doc workspace (#216, #218)
|
||||||
'workspace.tabs.ask': 'Ask',
|
'workspace.tabs.ask': 'Ask',
|
||||||
'workspace.tabs.inspect': 'Inspect',
|
'workspace.tabs.inspect': 'Inspect',
|
||||||
|
|
@ -758,6 +769,16 @@ const messages: Messages = {
|
||||||
'settings.about': 'About',
|
'settings.about': 'About',
|
||||||
'settings.designArticle': 'How Docling Studio was designed',
|
'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)
|
// Doc workspace (#216, #218)
|
||||||
'workspace.tabs.ask': 'Ask',
|
'workspace.tabs.ask': 'Ask',
|
||||||
'workspace.tabs.inspect': 'Inspect',
|
'workspace.tabs.inspect': 'Inspect',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue