Spaces:
Sleeping
Sleeping
| <template> | |
| <aside class="result-sidebar" :class="{ collapsed: !open }"> | |
| <button | |
| v-if="!open" | |
| type="button" | |
| class="result-sidebar-toggle" | |
| :title="data?.label || t('common.analysisResult')" | |
| @click="$emit('toggle')" | |
| > | |
| <span class="header-icon-ring" aria-hidden="true" v-html="resultIconHtml"></span> | |
| <span class="toggle-label-vertical">{{ data?.label || t('common.analysisResult') }}</span> | |
| </button> | |
| <div v-if="open" class="result-sidebar-body"> | |
| <div class="result-sidebar-header"> | |
| <div class="result-sidebar-title"> | |
| <span class="header-icon-ring" aria-hidden="true" v-html="resultIconHtml"></span> | |
| <span class="node-label">{{ data?.label || t('common.analysisResult') }}</span> | |
| </div> | |
| <button class="header-close-btn" type="button" @click="$emit('close')" :title="t('common.close')"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="result-sidebar-content"> | |
| <div v-if="plotEntries.length" class="plot-grid"> | |
| <button | |
| v-for="p in plotEntries" | |
| :key="p.key" | |
| type="button" | |
| class="plot-card" | |
| @click.stop="openPreview(p)" | |
| :title="p.label" | |
| > | |
| <img class="plot-thumb" :src="p.url" :alt="p.label" loading="lazy"> | |
| <div class="plot-caption">{{ p.label }}</div> | |
| </button> | |
| </div> | |
| <div v-else class="image-placeholder"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/> | |
| <path d="M8 10l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| <p>{{ t('result.noImage') }}</p> | |
| <p class="hint">{{ t('result.hint') }}</p> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- 图片预览(点击遮罩关闭) --> | |
| <div v-if="preview.url" class="img-preview-mask" @click.self="closePreview"> | |
| <div class="img-preview-panel"> | |
| <div class="img-preview-actions"> | |
| <div class="img-preview-title">{{ preview.label }}</div> | |
| <div class="img-preview-actions-right"> | |
| <button | |
| class="img-preview-icon-btn" | |
| type="button" | |
| @click="downloadPreview" | |
| :disabled="isDownloadingPreview" | |
| :title="isDownloadingPreview ? t('editor.downloading') : t('result.download')" | |
| > | |
| <svg v-if="!isDownloadingPreview" viewBox="0 0 24 24" fill="none"> | |
| <path d="M12 3v10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <path d="M8 11l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M5 21h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| <svg v-else class="spinning" viewBox="0 0 24 24" fill="none"> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="31.416" stroke-dashoffset="31.416"> | |
| <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416;0 31.416" repeatCount="indefinite"/> | |
| <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416;-31.416" repeatCount="indefinite"/> | |
| </circle> | |
| </svg> | |
| </button> | |
| <a class="img-preview-icon-btn" :href="preview.url" target="_blank" rel="noopener noreferrer" :title="t('result.openInNewTab')"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M15 3h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M10 14L21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| </a> | |
| <button class="img-preview-icon-btn" type="button" @click="closePreview" :title="t('common.close')"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| <img class="img-preview" :src="preview.url" :alt="preview.label"> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { computed, ref } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { paletteIconSvg } from '../utils/paletteIcons' | |
| const { t, te } = useI18n() | |
| const plotLabel = (key: string) => { | |
| const path = `result.plotTypes.${key}` | |
| return te(path) ? t(path) : key | |
| } | |
| const resultIconHtml = paletteIconSvg('result') | |
| interface ResultData { | |
| label?: string | |
| plots?: Record<string, string | string[]> | |
| analyses?: unknown | |
| message?: string | |
| exportId?: string | |
| } | |
| interface Props { | |
| data: ResultData | null | |
| open: boolean | |
| } | |
| const props = defineProps<Props>() | |
| defineEmits<{ | |
| close: [] | |
| toggle: [] | |
| }>() | |
| const preview = ref<{ url: string; label: string }>({ url: '', label: '' }) | |
| // 部分分析(如 IRCA 全局/局部分析)每个分组各生成一张图,从文件名里还原分组名作为图注 | |
| const extractGroupLabel = (url: string): string => { | |
| const filename = url.split(/[/\\]/).pop() ?? '' | |
| const withoutExt = filename.replace(/\.[a-zA-Z0-9]+$/, '') | |
| const match = withoutExt.match(/^IRCA_(?:global|local)_negative_(.+)$/) | |
| return match?.[1] ?? withoutExt | |
| } | |
| const plotEntries = computed(() => { | |
| const plots = props.data?.plots ?? {} | |
| const entries: Array<{ key: string; url: string; label: string }> = [] | |
| Object.entries(plots).forEach(([key, value]) => { | |
| const urls = Array.isArray(value) ? value : [value] | |
| const baseLabel = plotLabel(key) | |
| urls.forEach((url, index) => { | |
| if (!url) return | |
| const label = urls.length > 1 ? `${baseLabel} · ${extractGroupLabel(url)}` : baseLabel | |
| entries.push({ key: `${key}-${index}`, url, label }) | |
| }) | |
| }) | |
| return entries | |
| }) | |
| const openPreview = (p: { url: string; label: string }) => { | |
| preview.value = { url: p.url, label: p.label } | |
| } | |
| const isDownloadingPreview = ref(false) | |
| const guessExtension = (url: string, mimeType: string) => { | |
| const path = url.split(/[?#]/)[0] ?? '' | |
| const fromUrl = path.split('.').pop() | |
| if (fromUrl && fromUrl.length <= 5) return fromUrl | |
| const fromMime = mimeType.split('/')[1]?.split('+')[0] | |
| return fromMime || 'png' | |
| } | |
| const downloadPreview = async () => { | |
| const { url, label } = preview.value | |
| if (!url) return | |
| isDownloadingPreview.value = true | |
| try { | |
| const resp = await fetch(url) | |
| const blob = await resp.blob() | |
| const ext = guessExtension(url, blob.type) | |
| const objectUrl = URL.createObjectURL(blob) | |
| const a = document.createElement('a') | |
| a.href = objectUrl | |
| a.download = `${label || 'plot'}.${ext}` | |
| document.body.appendChild(a) | |
| a.click() | |
| a.remove() | |
| URL.revokeObjectURL(objectUrl) | |
| } catch (e) { | |
| console.warn('图片下载失败,改为在新窗口打开', e) | |
| window.open(url, '_blank', 'noopener,noreferrer') | |
| } finally { | |
| isDownloadingPreview.value = false | |
| } | |
| } | |
| const closePreview = () => { | |
| preview.value = { url: '', label: '' } | |
| } | |
| </script> | |
| <style scoped> | |
| .result-sidebar { | |
| flex-shrink: 0; | |
| width: 420px; | |
| background: #ffffff; | |
| border-left: 1px solid #e5e7eb; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| transition: width 0.2s ease; | |
| } | |
| .result-sidebar.collapsed { | |
| width: 44px; | |
| } | |
| .result-sidebar-toggle { | |
| flex-shrink: 0; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 10px 12px; | |
| background: #f8fafc; | |
| border: none; | |
| border-bottom: 1px solid #e5e7eb; | |
| cursor: pointer; | |
| width: 100%; | |
| } | |
| .result-sidebar.collapsed .result-sidebar-toggle { | |
| flex-direction: column; | |
| padding: 12px 6px; | |
| height: 100%; | |
| border-bottom: none; | |
| } | |
| .toggle-label-vertical { | |
| writing-mode: vertical-rl; | |
| font-size: 12px; | |
| font-weight: 600; | |
| color: #475569; | |
| white-space: nowrap; | |
| } | |
| .header-icon-ring { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 50%; | |
| background: #f1f5f9; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .header-icon-ring :deep(svg) { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .result-sidebar-body { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| flex: 1; | |
| } | |
| .result-sidebar-header { | |
| padding: 10px 12px; | |
| background: #ffffff; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 1px solid #e5e7eb; | |
| flex: 0 0 auto; | |
| } | |
| .result-sidebar-title { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.6rem; | |
| min-width: 0; | |
| } | |
| .node-label { | |
| font-weight: 600; | |
| font-size: 14px; | |
| color: #0f172a; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .header-close-btn { | |
| width: 24px; | |
| height: 24px; | |
| padding: 0; | |
| background: transparent; | |
| border: none; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| color: #64748b; | |
| flex-shrink: 0; | |
| } | |
| .header-close-btn:hover { | |
| background: #f1f5f9; | |
| color: #0f172a; | |
| } | |
| .header-close-btn svg { | |
| width: 14px; | |
| height: 14px; | |
| } | |
| .result-sidebar-content { | |
| flex: 1; | |
| min-height: 0; | |
| overflow-y: auto; | |
| padding: 12px; | |
| background: #f8fafc; | |
| } | |
| .plot-grid { | |
| display: grid; | |
| grid-template-columns: repeat(1, minmax(0, 1fr)); | |
| gap: 0.6rem; | |
| } | |
| .plot-card { | |
| display: flex; | |
| flex-direction: column; | |
| border: 1px solid rgba(148, 163, 184, 0.35); | |
| background: rgba(255, 255, 255, 0.9); | |
| border-radius: 3px; | |
| padding: 0.4rem; | |
| cursor: pointer; | |
| text-align: left; | |
| } | |
| .plot-card:hover { | |
| border-color: rgba(59, 130, 246, 0.55); | |
| background: rgba(255, 255, 255, 1); | |
| } | |
| .plot-thumb { | |
| width: 100%; | |
| height: 160px; | |
| object-fit: cover; | |
| border-radius: 2px; | |
| border: 1px solid rgba(226, 232, 240, 1); | |
| background: #fff; | |
| } | |
| .plot-caption { | |
| margin-top: 0.35rem; | |
| font-size: 0.8rem; | |
| color: rgba(15, 23, 42, 0.75); | |
| line-height: 1.2; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .image-placeholder { | |
| width: 100%; | |
| text-align: center; | |
| color: #64748b; | |
| padding: 2rem 0; | |
| } | |
| .image-placeholder svg { | |
| width: 48px; | |
| height: 48px; | |
| margin: 0 auto 0.5rem; | |
| color: #94a3b8; | |
| } | |
| .image-placeholder p { | |
| margin: 0.25rem 0; | |
| font-size: 13px; | |
| } | |
| .hint { | |
| font-size: 11px ; | |
| color: #94a3b8 ; | |
| margin-top: 0.5rem ; | |
| } | |
| /* 大图预览遮罩 */ | |
| .img-preview-mask { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(15, 23, 42, 0.55); | |
| z-index: 9999; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| } | |
| .img-preview-panel { | |
| width: min(1200px, 100%); | |
| max-height: min(88vh, 980px); | |
| background: #0b1220; | |
| border: 1px solid rgba(148, 163, 184, 0.25); | |
| border-radius: 6px; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .img-preview-actions { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 0.8rem; | |
| padding: 10px 12px; | |
| background: rgba(2, 6, 23, 0.55); | |
| border-bottom: 1px solid rgba(148, 163, 184, 0.18); | |
| } | |
| .img-preview-title { | |
| color: rgba(226, 232, 240, 0.92); | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .img-preview-actions-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.6rem; | |
| flex: 0 0 auto; | |
| } | |
| .img-preview-icon-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 28px; | |
| height: 28px; | |
| padding: 0; | |
| color: rgba(226, 232, 240, 0.9); | |
| text-decoration: none; | |
| border-radius: 4px; | |
| border: 1px solid rgba(148, 163, 184, 0.25); | |
| background: transparent; | |
| cursor: pointer; | |
| } | |
| .img-preview-icon-btn svg { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .img-preview-icon-btn:hover { | |
| background: rgba(148, 163, 184, 0.12); | |
| } | |
| .img-preview-icon-btn:disabled { | |
| opacity: 0.6; | |
| cursor: default; | |
| } | |
| .img-preview-icon-btn .spinning { | |
| animation: img-preview-spin 1s linear infinite; | |
| } | |
| @keyframes img-preview-spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| .img-preview { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| background: #0b1220; | |
| } | |
| </style> | |