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 v-else-if="store.currentAnalysis?.status === 'RUNNING'" class="result-placeholder">
|
||||
<div class="spinner-large" />
|
||||
<span>{{ t('studio.analysisRunning') }}</span>
|
||||
<!-- Batch progress: segmented bar -->
|
||||
<div
|
||||
v-if="store.currentAnalysis.progressTotal && store.currentAnalysis.progressTotal > 0"
|
||||
class="batch-progress"
|
||||
>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercent + '%' }" />
|
||||
<div class="batch-progress-ring">
|
||||
<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>
|
||||
<span class="progress-text">
|
||||
Pages {{ store.currentAnalysis.progressCurrent ?? 0 }} /
|
||||
{{ store.currentAnalysis.progressTotal }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Fallback: no batch info -->
|
||||
<template v-else>
|
||||
<div class="spinner-large" />
|
||||
<span>{{ t('studio.analysisRunning') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
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))
|
||||
})
|
||||
|
||||
/** 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(() => {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
width: 200px;
|
||||
gap: 20px;
|
||||
}
|
||||
.progress-bar {
|
||||
|
||||
/* Circular ring */
|
||||
.batch-progress-ring {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.progress-ring-svg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--border-light);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
.progress-ring-fill {
|
||||
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -118,6 +118,35 @@
|
|||
<span class="info-badge" v-if="analysisStore.currentAnalysis.status === 'RUNNING'">
|
||||
<div class="spinner-xs" />
|
||||
{{ 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 class="info-badge error" v-if="analysisStore.currentAnalysis.status === 'FAILED'">
|
||||
<span class="info-dot error" />
|
||||
|
|
@ -867,6 +896,32 @@ onBeforeUnmount(() => {
|
|||
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 */
|
||||
.studio-main {
|
||||
flex: 1;
|
||||
|
|
|
|||
Loading…
Reference in a new issue