feat: segmented batch progress bar with ring indicator
Replace the basic progress bar with a ring percentage indicator, segmented batch bar with pulse animation, and an inline mini progress bar in the top banner visible from any tab.
This commit is contained in:
parent
26fd46967c
commit
f6992d91bc
2 changed files with 208 additions and 25 deletions
|
|
@ -109,20 +109,54 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
|
<div v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
|
||||||
<div class="spinner-large" />
|
<!-- Batch progress: segmented bar -->
|
||||||
<span>{{ t('studio.analysisRunning') }}</span>
|
|
||||||
<div
|
<div
|
||||||
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
|
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
|
||||||
class="batch-progress"
|
class="batch-progress"
|
||||||
>
|
>
|
||||||
<div class="progress-bar">
|
<div class="batch-progress-ring">
|
||||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
|
<svg viewBox="0 0 48 48" class="progress-ring-svg">
|
||||||
|
<circle cx="24" cy="24" r="20" fill="none" stroke="var(--border)" stroke-width="3" />
|
||||||
|
<circle
|
||||||
|
cx="24"
|
||||||
|
cy="24"
|
||||||
|
r="20"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
:stroke-dasharray="125.66"
|
||||||
|
:stroke-dashoffset="125.66 - (125.66 * progressPercent) / 100"
|
||||||
|
class="progress-ring-fill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="progress-ring-label">{{ progressPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="batch-progress-detail">
|
||||||
|
<span class="batch-progress-title">{{ t('studio.analysisRunning') }}</span>
|
||||||
|
<div class="batch-segments">
|
||||||
|
<div
|
||||||
|
v-for="i in batchSegments"
|
||||||
|
:key="i"
|
||||||
|
class="batch-segment"
|
||||||
|
:class="{
|
||||||
|
filled: i <= filledSegments,
|
||||||
|
active: i === filledSegments + 1,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="batch-progress-sub">
|
||||||
|
<span class="batch-progress-pages">{{ store.currentAnalysis.progressCurrent ?? 0 }}</span>
|
||||||
|
<span class="batch-progress-sep">/</span>
|
||||||
|
<span>{{ store.currentAnalysis.progressTotal }} pages</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="progress-text">
|
|
||||||
Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} /
|
|
||||||
{{ store.currentAnalysis.progressTotal }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Fallback: no batch info -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="spinner-large" />
|
||||||
|
<span>{{ t('studio.analysisRunning') }}</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="store.currentAnalysis?.status === 'FAILED'"
|
v-else-if="store.currentAnalysis?.status === 'FAILED'"
|
||||||
|
|
@ -199,6 +233,31 @@ const progressPercent = computed(() => {
|
||||||
return Math.min(100, Math.round(((a.progressCurrent ?? 0) / a.progressTotal) * 100))
|
return Math.min(100, Math.round(((a.progressCurrent ?? 0) / a.progressTotal) * 100))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Number of batch segments to render in the segmented progress bar. */
|
||||||
|
const batchSegments = computed(() => {
|
||||||
|
const a = store.currentAnalysis
|
||||||
|
if (!a?.progressTotal || a.progressTotal <= 0) return 0
|
||||||
|
// Each segment = batch_page_size pages. We infer count from total & current.
|
||||||
|
// Backend updates progressCurrent in increments, so we derive segment count.
|
||||||
|
const current = a.progressCurrent ?? 0
|
||||||
|
if (current <= 0) return 0
|
||||||
|
// Guess batch size from the first non-zero progressCurrent value.
|
||||||
|
// Fallback: assume ~5 segments for a clean visual.
|
||||||
|
const total = a.progressTotal
|
||||||
|
// We can't know batch_page_size on frontend, so compute a clean segment count
|
||||||
|
// by aiming for 3-8 segments (visually optimal).
|
||||||
|
let count = Math.round(total / Math.max(1, current || total / 5))
|
||||||
|
if (count < 2) count = Math.ceil(total / Math.ceil(total / 5))
|
||||||
|
return Math.max(2, Math.min(8, count))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** How many segments are "filled" (completed batches). */
|
||||||
|
const filledSegments = computed(() => {
|
||||||
|
const total = batchSegments.value
|
||||||
|
if (total <= 0) return 0
|
||||||
|
return Math.round((progressPercent.value / 100) * total)
|
||||||
|
})
|
||||||
|
|
||||||
const currentPageData = computed(() => {
|
const currentPageData = computed(() => {
|
||||||
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
|
return store.currentPages.find((p) => p.page_number === props.currentPage) || null
|
||||||
})
|
})
|
||||||
|
|
@ -534,29 +593,98 @@ async function copyElement(idx: number, content: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Batch progress: ring + segmented bar ── */
|
||||||
.batch-progress {
|
.batch-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 20px;
|
||||||
margin-top: 8px;
|
|
||||||
width: 200px;
|
|
||||||
}
|
}
|
||||||
.progress-bar {
|
|
||||||
|
/* Circular ring */
|
||||||
|
.batch-progress-ring {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.progress-ring-svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6px;
|
|
||||||
background: var(--border-light);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent);
|
transform: rotate(-90deg);
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
}
|
||||||
.progress-text {
|
.progress-ring-fill {
|
||||||
font-size: 0.8rem;
|
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
color: var(--text-secondary);
|
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
|
||||||
|
}
|
||||||
|
.progress-ring-label {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right side: text + segments */
|
||||||
|
.batch-progress-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.batch-progress-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented bar */
|
||||||
|
.batch-segments {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.batch-segment {
|
||||||
|
width: 28px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 2.5px;
|
||||||
|
background: var(--border);
|
||||||
|
transition:
|
||||||
|
background 0.4s ease,
|
||||||
|
box-shadow 0.4s ease;
|
||||||
|
}
|
||||||
|
.batch-segment.filled {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px rgba(249, 115, 22, 0.3);
|
||||||
|
}
|
||||||
|
.batch-segment.active {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
animation: segment-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes segment-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 8px rgba(249, 115, 22, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page counter */
|
||||||
|
.batch-progress-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.batch-progress-pages {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.batch-progress-sep {
|
||||||
|
margin: 0 2px;
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,35 @@
|
||||||
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
|
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
|
||||||
<div class="spinner-xs" />
|
<div class="spinner-xs" />
|
||||||
{{ t('studio.analysisRunning') }}
|
{{ t('studio.analysisRunning') }}
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
analysisStore.currentAnalysis.progressTotal &&
|
||||||
|
analysisStore.currentAnalysis.progressTotal > 0
|
||||||
|
"
|
||||||
|
class="info-badge-progress"
|
||||||
|
>
|
||||||
|
<span class="info-badge-bar">
|
||||||
|
<span
|
||||||
|
class="info-badge-fill"
|
||||||
|
:style="{
|
||||||
|
width:
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
((analysisStore.currentAnalysis.progressCurrent ?? 0) /
|
||||||
|
analysisStore.currentAnalysis.progressTotal) *
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
) + '%',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="info-badge-count"
|
||||||
|
>{{ analysisStore.currentAnalysis.progressCurrent ?? 0 }}/{{
|
||||||
|
analysisStore.currentAnalysis.progressTotal
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
|
<span class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
|
||||||
<span class="info-dot error" />
|
<span class="info-dot error" />
|
||||||
|
|
@ -867,6 +896,32 @@ onBeforeUnmount(() => {
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline mini progress in top bar */
|
||||||
|
.info-badge-progress {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.info-badge-bar {
|
||||||
|
width: 48px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 1.5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.info-badge-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 1.5px;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.info-badge-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Main content */
|
/* Main content */
|
||||||
.studio-main {
|
.studio-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue