fix: remove Identity processor option and deprecated trace_chunks

- Removed Identity checkbox from processor selection in context menu
- Removed trace_chunks from PostgreSQL section (deprecated)
- Added relationship_chunks to PostgreSQL section
This commit is contained in:
2026-07-02 16:22:48 +08:00
parent 98a3d79701
commit bf7fbce458

View File

@@ -90,7 +90,11 @@
<div class="ms-fm-badge-row" :class="ingestedBadgeClass(f)">{{ ingestedBadgeIcon(f) }} {{ ingestedBadgeLabel(f) }}</div>
</div>
</div>
<div v-if="getProgress(f.file_uuid)?.overall_progress < 100" class="ms-fm-progress">
<div v-if="getPipelineStats(f.file_uuid) && getPipelineStats(f.file_uuid).overall_progress < 1.0" class="ms-fm-progress">
<div class="ms-fm-progress-bar" :style="{ width: (getPipelineStats(f.file_uuid).overall_progress * 100) + '%' }"></div>
<div class="ms-fm-progress-text">{{ Math.round(getPipelineStats(f.file_uuid).overall_progress * 100) }}%</div>
</div>
<div v-else-if="getProgress(f.file_uuid) && getProgress(f.file_uuid).overall_progress < 100" class="ms-fm-progress">
<div class="ms-fm-progress-bar" :style="{ width: getProgress(f.file_uuid)?.overall_progress + '%' }"></div>
<div class="ms-fm-progress-text">{{ getProgress(f.file_uuid)?.overall_progress || 0 }}%</div>
</div>
@@ -108,7 +112,7 @@
<div class="ms-ctx-filename">{{ ctxMenu.file?.file_name }}</div>
<div class="ms-ctx-info">{{ formatSize(ctxMenu.file?.file_size || 0) }} · {{ formatDate(ctxMenu.file?.modified_time || '') }}</div>
<div class="ms-ctx-info">狀態<span :class="ctxMenu.file?.status === 'completed' ? 'ms-ctx-done' : ctxMenu.file?.status === 'registered' ? 'ms-ctx-pending' : 'ms-ctx-none'">{{ statusLabel(ctxMenu.file) }}</span></div>
<div v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.file_uuid" class="ms-ctx-info">UUID{{ ctxMenu.file.file_uuid?.slice(0, 12) }}...</div>
<div v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.file_uuid" class="ms-ctx-info">UUID{{ ctxMenu.file.file_uuid }}</div>
</div>
<hr class="ms-ctx-divider">
<button v-if="!ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="ctxRegister(ctxMenu.file)">📥 註冊此檔案</button>
@@ -124,13 +128,115 @@
<div class="ms-ctx-procs-title">處理器選擇</div>
<label class="ms-fm-check-label" :class="procStatusClass('asr')"><input type="checkbox" v-model="procAsr"> ASR <span class="ms-proc-count">{{ procCountLabel('asr', 'segment') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('asrx')"><input type="checkbox" v-model="procAsrx"> ASRX <span class="ms-proc-count">{{ procCountLabel('asrx', 'segment') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('yolo')"><input type="checkbox" v-model="procYolo"> YOLO <span class="ms-proc-count">{{ procCountLabel('yolo', 'frame') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('face')"><input type="checkbox" v-model="procFace"> Face <span class="ms-proc-count">{{ procCountLabel('face', 'frame') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('ocr')"><input type="checkbox" v-model="procOcr"> OCR <span class="ms-proc-count">{{ procCountLabel('ocr', 'frame') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('pose')"><input type="checkbox" v-model="procPose"> Pose <span class="ms-proc-count">{{ procCountLabel('pose', 'frame') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('appearance')"><input type="checkbox" v-model="procAppearance"> Appearance <span class="ms-proc-count">{{ procCountLabel('appearance', 'frame') }}</span></label>
<label class="ms-fm-check-label" :class="procStatusClass('cut')"><input type="checkbox" v-model="procCut"> CUT <span class="ms-proc-count">{{ procCountLabel('cut', 'frame') }}</span></label>
</div>
<div v-if="ctxMenu.file?.file_uuid" class="ms-ctx-progress">
<div v-if="statsLoading" class="ms-ctx-loading">
<div class="ms-ctx-spinner"></div>
<span class="ms-ctx-loading-text">載入中...</span>
</div>
<template v-else>
<div class="ms-ctx-overall-progress" v-if="getPipelineStats(ctxMenu.file.file_uuid)">
<div class="ms-ctx-progress-title">整體進度</div>
<div class="ms-ctx-progress-bar-wrap">
<div class="ms-ctx-progress-bar" :style="{ width: formatPipelineProgress(getPipelineStats(ctxMenu.file.file_uuid).overall_progress) }"></div>
<span class="ms-ctx-progress-text">{{ formatPipelineProgress(getPipelineStats(ctxMenu.file.file_uuid).overall_progress) }}</span>
</div>
</div>
<div v-if="getPipelineStats(ctxMenu.file.file_uuid)" class="ms-ctx-stages">
<div v-for="stage in getPipelineStats(ctxMenu.file.file_uuid).stages" :key="stage.name" class="ms-ctx-stage">
<div class="ms-ctx-stage-header">
<span class="ms-ctx-stage-icon" :style="{ color: getStageColor(stage.status) }">{{ getStageIcon(stage.status) }}</span>
<span class="ms-ctx-stage-name">{{ formatStageName(stage.name) }}</span>
<span class="ms-ctx-stage-weight">{{ Math.round(stage.weight * 100) }}%</span>
<span class="ms-ctx-stage-progress">{{ formatPipelineProgress(stage.progress) }}</span>
</div>
<div class="ms-ctx-stage-bar-wrap">
<div class="ms-ctx-stage-bar" :style="{ width: formatPipelineProgress(stage.progress), backgroundColor: getStageColor(stage.status) }"></div>
</div>
</div>
</div>
<div v-if="getFileStats(ctxMenu.file.file_uuid)" class="ms-ctx-details">
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).json?.processors">
<div class="ms-ctx-phase-title">Rule1: 處理器輸出</div>
<div v-for="(info, proc) in getFileStats(ctxMenu.file.file_uuid).json.processors" :key="proc" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ proc.toUpperCase() }}</span>
<span class="ms-ctx-proc-count">{{ formatProcCount(proc, info) }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).postgresql">
<div class="ms-ctx-phase-title">Rule1: PostgreSQL 入庫</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Sentence Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).postgresql.sentence_chunks }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Relationship Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).postgresql.relationship_chunks }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).qdrant">
<div class="ms-ctx-phase-title">Rule1: Qdrant 入庫</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Text Chunks</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.text_chunks }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Face Points</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.faces }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Speakers</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.speakers }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).tkg?.nodes">
<div class="ms-ctx-phase-title">Rule2: TKG Nodes</div>
<div v-for="(count, type) in getFileStats(ctxMenu.file.file_uuid).tkg.nodes" :key="type" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ formatNodeName(type) }}</span>
<span class="ms-ctx-proc-count">{{ count }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).tkg?.edges">
<div class="ms-ctx-phase-title">Rule2: TKG Edges</div>
<div v-for="(count, type) in getFileStats(ctxMenu.file.file_uuid).tkg.edges" :key="type" class="ms-ctx-proc-item">
<span class="ms-ctx-proc-name">{{ formatEdgeName(type) }}</span>
<span class="ms-ctx-proc-count">{{ count }}</span>
</div>
</div>
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).identity_agent">
<div class="ms-ctx-phase-title">Identity Agent</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Identities Created</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.identities_created }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">TMDB Matches</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.tmdb_matches }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Speaker Bindings</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.speaker_bindings }}</span>
</div>
<div class="ms-ctx-stat-item">
<span class="ms-ctx-proc-name">Confirmations</span>
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.confirmations }}</span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
@@ -141,7 +247,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { apiCall } from '@/api'
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, getFileStatus, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, fileStatusCache } from '@/store'
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, getFileStatus, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, fileStatusCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, pollPipelineProgress, startStatsAutoRefresh, stopStatsAutoRefresh, JsonProcessorInfo, pipelineStatsCache } from '@/store'
import VideoPlayer from '../components/VideoPlayer.vue'
const files = computed(() => filesCache.value)
@@ -235,8 +341,8 @@ const completedCount = computed(() => files.value.filter((f: any) => f.status ==
const ingestedCount = computed(() => files.value.filter((f: any) => f.ingested).length)
const notIngestedCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.ingested).length)
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh() })
onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh() })
function docClickClose(e: MouseEvent) {
if (e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
@@ -251,19 +357,24 @@ async function loadFiles() {
for (const f of files.value.slice(0, 6)) {
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
}
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid && f.status !== 'completed')
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid)
for (const f of registeredFiles) {
try {
const progress: any = await apiCall('get_progress', { fileUuid: f.file_uuid })
if (progress && progress.overall_progress < 100 && progress.processors?.some((p: any) => p.status === 'running')) {
// 强制重新加载,忽略缓存
const pipeline = await loadPipelineStats(f.file_uuid, true)
console.log('[loadFiles] pipeline result', f.file_uuid, pipeline)
if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) {
f.status = 'processing'
fileProgressCache.value[f.file_uuid] = {
file_uuid: f.file_uuid,
overall_progress: progress.overall_progress,
processors: progress.processors,
}
pollProgress(f.file_uuid)
fileStatusCache.value[f.file_uuid] = 'processing'
pollPipelineProgress(f.file_uuid)
} else if (pipeline && pipeline.overall_progress >= 1.0) {
f.status = 'completed'
fileStatusCache.value[f.file_uuid] = 'completed'
}
// 同时加载文件统计(包含降级逻辑)
await loadFileStats(f.file_uuid, true)
} catch (e) {
console.error('check progress failed:', f.file_uuid, e)
}
@@ -326,7 +437,6 @@ const unregistering = ref(false)
const actionMsg = ref('')
const procAsr = ref(true)
const procAsrx = ref(true)
const procYolo = ref(true)
const procFace = ref(true)
const procOcr = ref(true)
const procPose = ref(true)
@@ -334,12 +444,72 @@ const procAppearance = ref(true)
const procCut = ref(true)
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
const statsLoading = ref(false)
function formatProcCount(proc: string, info: JsonProcessorInfo): string {
if (info.segment_count != null) return `${info.segment_count} segments`
if (info.frame_count != null) return `${info.frame_count} frames`
if (info.chunk_count != null) return `${info.chunk_count} chunks`
return info.status
}
function formatNodeName(type: string): string {
const names: Record<string, string> = {
character: 'Character', object: 'Object', scene: 'Scene',
action: 'Action', location: 'Location', event: 'Event',
dialogue: 'Dialogue', emotion: 'Emotion', relation: 'Relation'
}
return names[type] || type
}
function formatEdgeName(type: string): string {
const names: Record<string, string> = {
appear_in: 'Appear_In', interact_with: 'Interact_With',
located_at: 'Located_At', perform_action: 'Perform_Action',
cause_effect: 'Cause_Effect', temporal_link: 'Temporal_Link',
speak_to: 'Speak_To', feel: 'Feel'
}
return names[type] || type
}
function formatPipelineProgress(progress: number): string {
return `${Math.round(progress * 100)}%`
}
function getStageIcon(status: string): string {
switch (status) {
case 'completed': return '✓'
case 'running': return '⟳'
case 'failed': return '✗'
default: return '○'
}
}
function formatStageName(name: string): string {
const names: Record<string, string> = {
processors: '處理器',
rule1_ingestion: 'Rule1 入庫',
face_tracing: 'Face Tracing',
tkg_nodes: 'TKG Nodes',
tkg_edges: 'TKG Edges',
rule2_ingestion: 'Rule2 入庫'
}
return names[name] || name
}
function getStageColor(status: string): string {
switch (status) {
case 'completed': return '#34a853'
case 'running': return '#4285f4'
case 'failed': return '#ea4335'
default: return '#dadce0'
}
}
function selectedProcessors(): string[] {
const procs: string[] = []
if (procAsr.value) procs.push('asr')
if (procAsrx.value) procs.push('asrx')
if (procYolo.value) procs.push('yolo')
if (procFace.value) procs.push('face')
if (procOcr.value) procs.push('ocr')
if (procPose.value) procs.push('pose')
@@ -369,6 +539,12 @@ function procCountLabel(proc: string, type: 'frame' | 'segment' | 'chunk'): stri
function statusLabel(f: any): string {
if (!f) return ''
// 优先使用缓存的状态
const cachedStatus = getFileStatus(f.file_uuid)
if (cachedStatus === 'completed') return '已完成'
if (cachedStatus === 'processing') return '處理中'
// 否则使用文件对象的状态
if (f.status === 'completed') return '已完成'
if (f.status === 'processing') return '處理中'
if (f.status === 'failed') return '處理失敗'
@@ -426,12 +602,23 @@ async function openContextMenu(e: MouseEvent, f: any) {
e.stopPropagation()
ctxMenu.value = {
show: true,
x: Math.min(e.clientX, window.innerWidth - 280),
y: Math.min(e.clientY, window.innerHeight - 320),
x: Math.min(e.clientX, window.innerWidth - 360),
y: Math.min(e.clientY, window.innerHeight * 0.8),
file: f
}
if (f.isRegistered && f.file_uuid) {
await loadProcessorCounts(f.file_uuid, true)
statsLoading.value = true
try {
await Promise.all([
loadPipelineStats(f.file_uuid, true),
loadFileStats(f.file_uuid, true),
loadProcessorCounts(f.file_uuid, true)
])
} catch (e) {
console.error('Failed to load stats:', e)
} finally {
statsLoading.value = false
}
}
}
@@ -442,8 +629,6 @@ function closeContextMenu() {
async function ctxRegister(f: any) {
closeContextMenu()
if (registering.value) return
const procs = selectedProcessors()
if (!procs.length) { actionMsg.value = '請至少選擇一個處理器'; return }
registering.value = true
actionMsg.value = '註冊中...'
const filePath = f.file_path || f.relative_path || f.file_name
@@ -452,12 +637,9 @@ async function ctxRegister(f: any) {
const result: any = await apiCall('register_file', { filePath })
if (result.success) {
actionMsg.value = `註冊成功:${f.file_name}`
if (result.file_uuid) {
try {
const pResult: any = await apiCall('process_file', { fileUuid: result.file_uuid, processors: procs })
if (pResult.success) actionMsg.value += ' → 處理已觸發'
} catch (e: any) { console.error('[process]', e) }
}
f.isRegistered = true
f.file_uuid = result.file_uuid
f.status = 'registered'
} else {
actionMsg.value = `註冊失敗:${result.message}`
}
@@ -484,7 +666,7 @@ async function ctxProcess(f: any) {
actionMsg.value = `處理已觸發:${f.file_name}`
f.status = 'processing'
fileStatusCache.value[f.file_uuid] = 'processing'
pollProgress(f.file_uuid)
pollPipelineProgress(f.file_uuid)
} else {
actionMsg.value = `處理失敗:${result.message}`
}
@@ -600,7 +782,9 @@ function resetFilters() {
.ms-fm-action-msg { margin-left: 12px; color: #1e8e3e; font-weight: 600; }
.ms-fm-btn-danger { background: #d93025; color: #fff; border-color: #d93025; }
.ms-fm-btn-danger:hover:not(:disabled) { background: #c5221f; }
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 220px; max-width: 320px; font-size: 13px; color: #222; }
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 280px; max-width: 360px; max-height: 80vh; overflow-y: auto; font-size: 13px; color: #222; }
.ms-ctx-menu::-webkit-scrollbar { width: 4px; }
.ms-ctx-menu::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 2px; }
.ms-ctx-header { padding: 8px 10px 4px; }
.ms-ctx-filename { font-weight: 600; font-size: 13px; color: #202124; word-break: break-all; }
.ms-ctx-info { font-size: 11px; color: #7a7f87; margin-top: 2px; }
@@ -614,6 +798,14 @@ function resetFilters() {
.ms-ctx-danger:hover { background: #fce8e6 !important; }
.ms-ctx-procs { padding: 6px 10px 8px; display: flex; flex-wrap: wrap; gap: 6px 10px; }
.ms-ctx-procs-title { width: 100%; font-size: 11px; color: #7a7f87; font-weight: 600; margin-bottom: 2px; }
.ms-ctx-progress { padding: 8px 12px; background: #f8f9fa; border-radius: 8px; margin-top: 8px; }
.ms-ctx-progress-title { font-size: 11px; color: #5f6368; font-weight: 600; margin-bottom: 6px; }
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: #e8eaed; border-radius: 4px; overflow: hidden; }
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, #1a73e8, #4285f4); border-radius: 4px; transition: width 0.3s; }
.ms-ctx-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: #202124; }
.ms-ctx-proc-status { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11px; }
.ms-ctx-proc-name { font-weight: 600; color: #1a73e8; }
.ms-ctx-proc-detail { color: #5f6368; }
.ms-proc-done { color: #059669; }
.ms-proc-done .ms-proc-count { color: #059669; font-weight: 600; }
.ms-proc-count { margin-left: 4px; font-size: 11px; color: #8a919c; }
@@ -654,4 +846,34 @@ function resetFilters() {
.ms-fm-axis-bar { font-size: 12px; color: #5f6368; margin: 2px 0 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.ms-fm-axis-item strong { color: #202124; }
.ms-fm-axis-sep { color: #dadce0; }
/* 加载状态 */
.ms-ctx-loading { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 32px 0; }
.ms-ctx-spinner { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #4285f4; border-radius: 50%; animation: spin 0.8s linear infinite; }
.ms-ctx-loading-text { font-size: 13px; color: #5f6368; }
/* 进度条和阶段样式 */
.ms-ctx-overall-progress { margin-bottom: 16px; }
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: #e8eaed; border-radius: 10px; overflow: hidden; }
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, #4285f4, #34a853); transition: width 0.3s ease; }
.ms-ctx-progress-text { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 12px; font-weight: 600; color: #333; }
.ms-ctx-stages { margin-bottom: 16px; }
.ms-ctx-stage { margin-bottom: 12px; }
.ms-ctx-stage-header { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-bottom: 4px; }
.ms-ctx-stage-icon { width: 16px; text-align: center; font-size: 12px; font-weight: 600; }
.ms-ctx-stage-name { flex: 1; font-weight: 500; color: #333; }
.ms-ctx-stage-weight { color: #5f6368; font-size: 12px; }
.ms-ctx-stage-progress { color: #5f6368; font-size: 12px; font-weight: 600; }
.ms-ctx-stage-bar-wrap { height: 6px; background: #e8eaed; border-radius: 3px; overflow: hidden; }
.ms-ctx-stage-bar { height: 100%; transition: width 0.3s ease; }
/* 详细数据样式 */
.ms-ctx-details { border-top: 1px solid #eee; padding-top: 12px; }
.ms-ctx-phase { margin-bottom: 16px; }
.ms-ctx-phase:last-child { margin-bottom: 0; }
.ms-ctx-phase-title { font-size: 11px; color: #5f6368; font-weight: 600; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.ms-ctx-proc-item, .ms-ctx-stat-item { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.ms-ctx-proc-name, .ms-ctx-stat-name { color: #333; }
.ms-ctx-proc-count, .ms-ctx-stat-count { color: #666; font-weight: 500; }
</style>