Files
system-doctor/src/App.vue
Julian Freeman 936c58dcc8 add custom bsod
2025-11-26 09:51:30 -04:00

461 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="app-layout">
<!-- 左侧侧边栏 -->
<aside class="sidebar">
<div class="brand">
<span class="icon">🩺</span>
<h1>体检中心</h1>
</div>
<nav class="nav-menu">
<button
class="nav-item"
:class="{ active: currentTab === 'overview' }"
@click="currentTab = 'overview'"
>
<span class="nav-icon">📊</span> 静态概览
</button>
<button
class="nav-item"
:class="{ active: currentTab === 'bsod' }"
@click="currentTab = 'bsod'"
>
<span class="nav-icon"></span> 蓝屏分析
</button>
</nav>
<div class="sidebar-footer">
<span class="version">Pro v1.2</span>
</div>
</aside>
<!-- 右侧主内容区 -->
<main class="main-content">
<!-- TAB 1: 概览 -->
<div v-if="currentTab === 'overview'" class="tab-view overview-view">
<div class="view-header">
<h2>系统健康概览</h2>
<div class="actions-row">
<button @click="startScan" :disabled="loading" class="primary-btn" :class="{ 'scanning': loading }">
<span v-if="loading" class="icon-spin">🔄</span>
<span v-else>🔍 全面体检</span>
</button>
<div class="sub-actions">
<button class="icon-btn" @click="triggerImport" :disabled="loading">📂 导入</button>
<button class="icon-btn" @click="exportReport" :disabled="!isReportValid || loading">💾 导出</button>
</div>
</div>
</div>
<!-- 状态提示 -->
<div v-if="errorMsg" class="message-box error"> {{ errorMsg }}</div>
<div v-if="scanFinished" class="message-box success"> 扫描完成</div>
<input type="file" ref="fileInput" @change="handleFileImport" accept=".json" style="display: none" />
<!-- 卡片展示区 -->
<div v-if="report.hardware" class="dashboard fade-in">
<!-- 1. 硬件概览 (强制独占一行) -->
<div class="card summary slide-up">
<div class="card-header">
<h3>🖥 硬件概览与资源占用</h3>
</div>
<div class="grid-container-summary">
<div class="basic-info">
<div class="info-item"><span class="label">CPU</span><span class="value text-ellipsis" :title="report.hardware.cpu_name">{{ report.hardware.cpu_name }}</span></div>
<div class="info-item"><span class="label">System</span><span class="value text-ellipsis">{{ report.hardware.sys_vendor }} {{ report.hardware.sys_product }}</span></div>
<div class="info-item"><span class="label">Mobo</span><span class="value text-ellipsis">{{ report.hardware.mobo_vendor }} {{ report.hardware.mobo_product }}</span></div>
<div class="info-item"><span class="label">BIOS</span><span class="value text-ellipsis">{{ report.hardware.bios_version }}</span></div>
</div>
<div class="usage-bars">
<div class="usage-item">
<div class="progress-label">
<span class="text-ellipsis">C盘 ({{ report.hardware.c_drive_used_gb }}/{{ report.hardware.c_drive_total_gb }}GB)</span>
<strong>{{ calculatePercent(report.hardware.c_drive_used_gb, report.hardware.c_drive_total_gb) }}%</strong>
</div>
<div class="progress-bar-large"><div class="fill" :style="getProgressStyle(report.hardware.c_drive_used_gb, report.hardware.c_drive_total_gb)"></div></div>
</div>
<div class="usage-item">
<div class="progress-label">
<span class="text-ellipsis">内存 ({{ report.hardware.memory_used_gb }}/{{ report.hardware.memory_total_gb }}GB)</span>
<strong>{{ calculatePercent(report.hardware.memory_used_gb, report.hardware.memory_total_gb) }}%</strong>
</div>
<div class="progress-bar-large"><div class="fill" :style="getProgressStyle(report.hardware.memory_used_gb, report.hardware.memory_total_gb)"></div></div>
</div>
</div>
</div>
</div>
<!-- 2. 驱动状态 -->
<div v-if="report.drivers" class="card slide-up" :class="{ danger: report.drivers.length > 0, success: report.drivers.length === 0 }">
<div class="card-header">
<h3>🔌 驱动状态</h3>
<span class="badge" :class="report.drivers.length > 0 ? 'badge-red' : 'badge-green'">
{{ report.drivers.length > 0 ? '异常' : '正常' }}
</span>
</div>
<div v-if="report.drivers.length > 0" class="list-container">
<div v-for="(drv, idx) in report.drivers" :key="idx" class="list-item error-item">
<div class="item-header">
<strong class="text-ellipsis" :title="drv.device_name">{{ drv.device_name }}</strong>
<span class="code-tag">Code {{ drv.error_code }}</span>
</div>
<p class="description">{{ drv.description }}</p>
</div>
</div>
<div v-else class="good-news">设备管理器无异常</div>
</div>
<!-- 3. 电池健康 -->
<div v-if="report.battery" class="card slide-up" :class="{ danger: report.battery.health_percentage < 60 }">
<div class="card-header">
<h3>🔋 电池健康</h3>
<span class="badge badge-blue">{{ report.battery.health_percentage }}%</span>
</div>
<div class="content-box">
<div class="progress-bar"><div class="fill" :style="{ width: report.battery.health_percentage + '%', background: getBatteryColor(report.battery.health_percentage) }"></div></div>
<p class="description mt-2">{{ report.battery.explanation }}</p>
</div>
</div>
<!-- 4. 硬盘健康 -->
<div v-if="report.storage" class="card slide-up" :class="{ danger: hasStorageDanger }">
<div class="card-header">
<h3>💾 硬盘 S.M.A.R.T</h3>
</div>
<div class="list-container">
<div v-for="(disk, index) in report.storage" :key="index" class="list-item">
<div class="item-header">
<strong class="text-ellipsis" :title="disk.model">{{ disk.model }}</strong>
<span class="status-text" :class="disk.is_danger ? 'text-red' : 'text-green'">{{ disk.health_status }}</span>
</div>
<p class="description">{{ disk.human_explanation }}</p>
</div>
</div>
</div>
<!-- 5. 日志卡片 -->
<div v-if="report.events" class="card slide-up" :class="{ danger: report.events.length > 0 }">
<div class="card-header">
<h3> 关键日志</h3>
</div>
<div v-if="report.events.length > 0" class="list-container">
<div v-for="(evt, idx) in report.events" :key="idx" class="list-item warning-item">
<div class="item-header">
<span class="event-id">ID: {{ evt.event_id }}</span>
<span class="event-source" :title="evt.source">{{ evt.source }}</span>
<span class="event-time">{{ formatTime(evt.time_generated) }}</span>
</div>
<p class="description highlight">{{ evt.analysis_hint }}</p>
<p v-if="evt.message" class="description raw-message">{{ evt.message }}</p>
</div>
</div>
<div v-else class="good-news">无致命错误日志</div>
</div>
</div>
</div>
<!-- TAB 2: 蓝屏分析 -->
<div v-if="currentTab === 'bsod'" class="tab-view bsod-view">
<div class="view-header">
<h2>蓝屏死机分析 (Minidump)</h2>
<!-- 按钮组 -->
<div class="actions-row">
<button class="secondary-btn" @click="triggerBsodImport" :disabled="bsodAnalyzing">📂 导入文件</button>
<button class="secondary-btn" @click="loadMinidumps" :disabled="bsodLoading">🔄 刷新列表</button>
</div>
</div>
<!-- 隐藏的 BSOD 文件输入框 -->
<input type="file" ref="bsodFileInput" @change="handleBsodFileImport" accept=".dmp" style="display: none" />
<div class="bsod-layout">
<div class="bsod-list-panel">
<div v-if="bsodList.length === 0 && !bsodLoading" class="empty-state">
未发现 Minidump 文件<br>系统可能未启用转储或已被清理
</div>
<div v-else class="file-list">
<div
v-for="file in bsodList"
:key="file.path"
class="file-item"
:class="{ active: selectedBsod?.path === file.path }"
@click="analyzeBsod(file)"
>
<div class="file-icon">📄</div>
<div class="file-info">
<div class="file-name">{{ file.filename }}</div>
<div class="file-meta">{{ file.created_time }} · {{ file.size_kb }}KB</div>
</div>
</div>
</div>
</div>
<div class="bsod-detail-panel">
<div v-if="bsodAnalyzing" class="analyzing-state">
<div class="spinner"></div>
<p>正在解析 Dump 文件可能需要几秒钟...</p>
</div>
<div v-else-if="bsodResult" class="analysis-result fade-in">
<div class="result-header">
<h3>{{ bsodResult.crash_reason }}</h3>
<span class="code-pill">{{ bsodResult.bug_check_code }}</span>
</div>
<div class="human-analysis card-section">
<h4>🤖 智能分析</h4>
<p class="human-text">{{ bsodResult.human_analysis }}</p>
<div class="recommendation">
<strong>建议操作</strong> {{ bsodResult.recommendation }}
</div>
</div>
<div class="tech-details card-section">
<h4>🔬 技术细节</h4>
<div class="detail-grid">
<div class="d-item"><span class="d-label">崩溃地址</span>{{ bsodResult.crash_address }}</div>
<div class="d-item"><span class="d-label">相关线程</span>{{ bsodResult.crashing_thread || 'N/A' }}</div>
</div>
</div>
</div>
<div v-else class="empty-detail">
👈 请从左侧选择一个蓝屏文件开始分析<br>或点击上方导入文件分析外部文件
</div>
</div>
</div>
</div>
</main>
<!-- Toast 通知 -->
<transition name="toast-slide">
<div v-if="toast.show" class="toast-notification" :class="toast.type">
<div class="toast-content">
<span class="check-icon">{{ toast.type === 'error' ? '❌' : '✅' }}</span>
<div class="toast-text"><h4>{{ toast.title }}</h4><p>{{ toast.message }}</p></div>
</div>
<div class="toast-progress"></div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted, reactive, onMounted, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
// --- 状态管理 ---
const currentTab = ref('overview'); // 'overview' | 'bsod'
// 概览相关
const emptyReport = () => ({ hardware: null, storage: null, events: null, minidumps: null, drivers: null, battery: null });
const report = ref(emptyReport());
const loading = ref(false);
const errorMsg = ref('');
const scanFinished = ref(false);
const fileInput = ref(null);
let unlistenFns = [];
// BSOD 相关
const bsodList = ref([]);
const selectedBsod = ref(null);
const bsodResult = ref(null);
const bsodLoading = ref(false);
const bsodAnalyzing = ref(false);
const bsodFileInput = ref(null); // 新增 BSOD 文件输入引用
const toast = reactive({ show: false, title: '', message: '', type: 'success' });
let toastTimer = null;
const hasStorageDanger = computed(() => report.value?.storage?.some(d => d.is_danger) || false);
const isReportValid = computed(() => report.value && (report.value.hardware || report.value.storage));
// --- 辅助函数 (保留) ---
function calculatePercent(used, total) { return (!total || total === 0) ? 0 : Math.round((used / total) * 100); }
function getProgressStyle(used, total) {
const percent = calculatePercent(used, total);
let color = percent >= 90 ? '#ff4757' : (percent >= 75 ? '#ffa502' : '#2ed573');
return { width: `${percent}%`, background: color };
}
function getBatteryColor(p) { return p < 50 ? '#ff4757' : (p < 80 ? '#ffa502' : '#2ed573'); }
function formatTime(t) { return !t ? "Unknown Time" : t.replace('T', ' ').substring(0, 19); }
// --- 概览:导出/导入 ---
async function exportReport() {
if (!isReportValid.value) return;
const fileName = `SystemDoctor_Report_${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.json`;
const content = JSON.stringify(report.value, null, 2);
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'JSON Report', accept: { 'application/json': ['.json'] }, }], });
const writable = await handle.createWritable(); await writable.write(content); await writable.close();
triggerToast('导出成功', '文件已保存到指定位置', 'success'); return;
} catch (err) { if (err.name === 'AbortError') return; }
}
try {
const blob = new Blob([content], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url);
triggerToast('导出成功', '已保存到默认下载目录', 'success');
} catch (err) { triggerToast('导出失败', err.message, 'error'); }
}
function triggerImport() { fileInput.value.click(); }
function handleFileImport(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const json = JSON.parse(e.target.result); if (json && (json.hardware || json.storage)) { report.value = json; scanFinished.value = false; triggerToast('导入成功', '已加载历史报告'); } else { triggerToast('导入失败', '文件格式错误', 'error'); } } catch (err) { triggerToast('解析失败', err.message, 'error'); } }; reader.readAsText(file); event.target.value = ''; }
async function startScan() {
report.value = emptyReport(); errorMsg.value = ''; scanFinished.value = false; loading.value = true;
if (unlistenFns.length > 0) { for (const fn of unlistenFns) fn(); unlistenFns = []; }
try {
unlistenFns.push(
await listen('report-hardware', (e) => report.value.hardware = e.payload),
await listen('report-storage', (e) => report.value.storage = e.payload),
await listen('report-drivers', (e) => report.value.drivers = e.payload),
await listen('report-minidumps', (e) => report.value.minidumps = e.payload),
await listen('report-battery', (e) => report.value.battery = e.payload),
await listen('report-events', (e) => report.value.events = e.payload),
await listen('diagnosis-finished', () => { loading.value = false; scanFinished.value = true; triggerToast('扫描完成', '体检已结束', 'success'); })
);
await invoke('run_diagnosis');
} catch (e) { loading.value = false; errorMsg.value = e; }
}
// --- BSOD 功能 ---
async function loadMinidumps() { bsodLoading.value = true; try { bsodList.value = await invoke('list_minidumps'); } catch (e) { triggerToast('加载失败', e, 'error'); } finally { bsodLoading.value = false; } }
async function analyzeBsod(file) { if (bsodAnalyzing.value) return; selectedBsod.value = file; bsodResult.value = null; bsodAnalyzing.value = true; try { bsodResult.value = await invoke('analyze_minidump', { filepath: file.path }); } catch (e) { triggerToast('分析失败', e, 'error'); } finally { bsodAnalyzing.value = false; } }
// [新增] BSOD 导入功能
function triggerBsodImport() { bsodFileInput.value.click(); }
function handleBsodFileImport(event) {
const file = event.target.files[0];
if (!file) return;
// 更新 UI 状态,模拟选中了一个“外部文件”
selectedBsod.value = { path: 'external', filename: file.name, created_time: 'Imported', size_kb: Math.round(file.size / 1024) };
bsodResult.value = null;
bsodAnalyzing.value = true;
const reader = new FileReader();
reader.onload = async (e) => {
try {
// 将 ArrayBuffer 转换为 Uint8Array (Rust Vec<u8>)
const arrayBuffer = e.target.result;
const bytes = new Uint8Array(arrayBuffer);
const byteArray = Array.from(bytes); // 转换为普通数组以便序列化传输
const result = await invoke('analyze_minidump_bytes', { fileContent: byteArray });
bsodResult.value = result;
triggerToast('分析成功', '已完成外部文件解析', 'success');
} catch (err) {
triggerToast('分析失败', err, 'error');
} finally {
bsodAnalyzing.value = false;
}
};
reader.readAsArrayBuffer(file);
event.target.value = '';
}
watch(currentTab, (newVal) => { if (newVal === 'bsod' && bsodList.value.length === 0) loadMinidumps(); });
function triggerToast(title, message, type = 'success') { toast.title = title; toast.message = message; toast.type = type; toast.show = true; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.show = false; }, 3000); }
onUnmounted(() => { for (const fn of unlistenFns) fn(); if (toastTimer) clearTimeout(toastTimer); });
</script>
<style>
body { margin: 0; padding: 0; background-color: #f4f6f9; overflow: hidden; }
</style>
<style scoped>
.app-layout { display: flex; height: 100vh; width: 100vw; overflow: hidden; background-color: #f4f6f9; }
.sidebar { width: 240px; background: #2c3e50; color: white; display: flex; flex-direction: column; padding: 20px 0; box-shadow: 2px 0 10px rgba(0,0,0,0.1); z-index: 10; }
.brand { padding: 0 20px 30px; display: flex; align-items: center; gap: 12px; }
.brand .icon { font-size: 1.8rem; } .brand h1 { font-size: 1.2rem; margin: 0; font-weight: 700; color: white; }
.nav-menu { flex: 1; display: flex; flex-direction: column; gap: 5px; padding: 0 10px; }
.nav-item { background: transparent; border: none; color: #bdc3c7; padding: 12px 15px; font-size: 1rem; cursor: pointer; text-align: left; border-radius: 8px; transition: all 0.2s; display: flex; align-items: center; gap: 12px; }
.nav-item:hover { background: rgba(255,255,255,0.1); color: white; }
.nav-item.active { background: #2ecc71; color: white; font-weight: 600; box-shadow: 0 4px 10px rgba(46, 204, 113, 0.3); }
.nav-icon { font-size: 1.2rem; }
.sidebar-footer { padding: 0 20px; text-align: center; color: #7f8c8d; font-size: 0.8rem; }
.main-content { flex: 1; overflow-y: auto; padding: 30px 40px; position: relative; }
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
.view-header h2 { margin: 0; font-size: 1.8rem; color: #2c3e50; }
.actions-row { display: flex; align-items: center; gap: 15px; }
.sub-actions { display: flex; gap: 10px; }
.dashboard { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; }
.card { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.03); border: 1px solid #eaecf0; border-top: 3px solid #2ecc71; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #f0f2f5; }
.card-header h3 { margin: 0; font-size: 1.1rem; font-weight: 600; }
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; gap: 12px; }
.progress-label { display: flex; justify-content: space-between; font-size: 0.85rem; color: #636e72; gap: 10px; }
.text-ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.item-header strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 1; }
.status-text, .code-tag, .badge { flex-shrink: 0; white-space: nowrap; }
.event-id { font-weight: bold; color: #2c3e50; background: #e2e8f0; padding: 1px 5px; border-radius: 3px; font-size: 0.75rem; flex-shrink: 0; }
.event-source { font-size: 0.75rem; color: #7f8c8d; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0 5px; text-align: left; }
.event-time { font-size: 0.75rem; color: #95a5a6; flex-shrink: 0; white-space: nowrap; }
.badge { font-size: 0.75rem; padding: 3px 10px; border-radius: 12px; color: white; font-weight: 600; letter-spacing: 0.5px; white-space: nowrap; flex-shrink: 0; }
.grid-container-summary { display: grid; grid-template-columns: 1fr 1.5fr; gap: 25px; }
@media (max-width: 900px) { .grid-container-summary { grid-template-columns: 1fr; } }
.basic-info, .usage-bars { display: flex; flex-direction: column; gap: 12px; }
.usage-bars { padding-top: 5px; }
.usage-item { display: flex; flex-direction: column; gap: 6px; }
.info-item { display: flex; flex-direction: column; }
.label { font-size: 0.75rem; color: #95a5a6; margin-bottom: 2px; text-transform: uppercase; font-weight: 600; }
.value { font-size: 1rem; font-weight: 500; color: #2c3e50; }
.progress-bar-large { width: 100%; height: 16px; background: #f1f3f5; border-radius: 8px; overflow: hidden; }
.fill { height: 100%; transition: width 0.6s ease; }
.list-container { display: flex; flex-direction: column; gap: 10px; }
.list-item { background: #f8f9fa; padding: 10px 14px; border-radius: 6px; border: 1px solid #edf2f7; }
.list-item.error-item { background: #fff5f5; border-color: #fed7d7; }
.list-item.warning-item { background: #fffaf0; border-color: #fce588; }
.description { margin: 0; color: #636e72; font-size: 0.9rem; line-height: 1.4; }
.description.highlight { color: #d35400; font-weight: 500; }
.description.raw-message { margin-top: 6px; font-size: 0.8rem; color: #7f8c8d; font-family: Consolas, monospace; background: #fff; padding: 4px 8px; border-radius: 4px; border: 1px solid #eee; word-break: break-all; }
.text-green { color: #27ae60; } .text-red { color: #c0392b; }
.badge-red { background-color: #ff4757; } .badge-green { background-color: #2ed573; } .badge-blue { background-color: #3498db; } .badge-gray { background-color: #95a5a6; }
.code-tag { background: #ff4757; color: white; padding: 1px 5px; border-radius: 3px; font-size: 0.75rem; font-weight: bold; }
.good-news { color: #27ae60; font-weight: 600; background: #eafaf1; padding: 12px; border-radius: 6px; border: 1px solid #d4efdf; text-align: center; font-size: 0.9rem; }
.tip { margin-top: 12px; font-size: 0.85rem; color: #d35400; background: #fff3e0; padding: 10px; border-radius: 6px; border: 1px solid #ffe0b2; }
.progress-wrapper { margin-bottom: 12px; }
.progress-bar { width: 100%; height: 8px; background: #f1f3f5; border-radius: 4px; overflow: hidden; }
.content-box { padding: 5px 0; } .main-text { font-weight: 500; color: #2c3e50; margin-bottom: 8px; }
.card.danger { border-top-color: #ff4757; }
.card.summary { border-top-color: #3498db; grid-column: 1 / -1; }
.primary-btn { background: #2ecc71; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 8px; transition: 0.2s; }
.primary-btn:hover:not(:disabled) { background: #27ae60; } .primary-btn:disabled { background: #bdc3c7; cursor: not-allowed; }
.secondary-btn { background: white; border: 1px solid #dcdfe6; color: #606266; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
.secondary-btn:hover:not(:disabled) { border-color: #2ecc71; color: #2ecc71; }
.icon-btn { background: transparent; border: none; cursor: pointer; padding: 8px; color: #7f8c8d; font-weight: 600; }
.icon-btn:hover:not(:disabled) { color: #2ecc71; background: #eafaf1; border-radius: 6px; }
.toast-notification { position: fixed; top: 20px; right: 20px; background: white; border-left: 5px solid #2ecc71; padding: 15px 20px; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.15); z-index: 100; min-width: 250px; }
.toast-notification.error { border-left-color: #ff4757; }
.toast-content { display: flex; gap: 12px; } .toast-text h4 { margin: 0 0 4px; font-size: 1rem; } .toast-text p { margin: 0; color: #7f8c8d; font-size: 0.85rem; }
.toast-slide-enter-active, .toast-slide-leave-active { transition: all 0.3s; } .toast-slide-enter-from, .toast-slide-leave-to { transform: translateX(100%); opacity: 0; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.fade-in { animation: fadeIn 0.4s ease-out; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.slide-up { animation: slideUp 0.4s ease-out forwards; } @keyframes slideUp { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
.bsod-layout { display: grid; grid-template-columns: 300px 1fr; gap: 20px; height: calc(100vh - 120px); }
.bsod-list-panel { background: white; border-radius: 10px; border: 1px solid #eaecf0; overflow-y: auto; }
.bsod-detail-panel { background: white; border-radius: 10px; border: 1px solid #eaecf0; padding: 25px; overflow-y: auto; position: relative; }
.file-item { padding: 15px; border-bottom: 1px solid #f0f2f5; cursor: pointer; display: flex; gap: 12px; transition: background 0.2s; }
.file-item:hover { background: #f8f9fa; } .file-item.active { background: #eafaf1; border-left: 4px solid #2ecc71; }
.file-icon { font-size: 1.5rem; } .file-name { font-weight: 600; font-size: 0.9rem; color: #2c3e50; margin-bottom: 4px; } .file-meta { font-size: 0.8rem; color: #95a5a6; }
.empty-state { padding: 40px; text-align: center; color: #95a5a6; font-size: 0.9rem; line-height: 1.6; }
.analyzing-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #7f8c8d; }
.spinner { width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #2ecc71; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 15px; }
.empty-detail { display: flex; align-items: center; justify-content: center; height: 100%; color: #95a5a6; font-size: 1.1rem; }
.result-header { border-bottom: 1px solid #eee; padding-bottom: 15px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; }
.result-header h3 { margin: 0; color: #c0392b; font-size: 1.4rem; } .code-pill { background: #2c3e50; color: white; padding: 4px 10px; border-radius: 4px; font-family: monospace; font-weight: bold; }
.card-section { margin-bottom: 25px; } .card-section h4 { margin: 0 0 10px 0; font-size: 1rem; color: #7f8c8d; text-transform: uppercase; letter-spacing: 0.5px; border-left: 3px solid #2ecc71; padding-left: 10px; }
.human-text { font-size: 1.1rem; color: #2c3e50; line-height: 1.6; margin-bottom: 10px; }
.recommendation { background: #eafaf1; color: #27ae60; padding: 15px; border-radius: 8px; font-weight: 500; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .d-item { background: #f8f9fa; padding: 10px; border-radius: 6px; display: flex; flex-direction: column; } .d-label { font-size: 0.75rem; color: #95a5a6; margin-bottom: 4px; }
.message-box { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem; font-weight: 500; }
.message-box.success { background-color: #eafaf1; color: #27ae60; border: 1px solid #d4efdf; }
.message-box.error { background-color: #ffeaea; color: #c0392b; border: 1px solid #ffcccc; }
</style>