MiroFish / frontend /src /components /HistoryDatabase.vue
Codex Deploy
Deploy MiroFish to HF Space
ebdfd3b
<template>
<div
class="history-database"
:class="{ 'no-projects': projects.length === 0 && !loading }"
ref="historyContainer"
>
<!-- 背景装饰:技术网格线(只在有项目时显示) -->
<div v-if="projects.length > 0 || loading" class="tech-grid-bg">
<div class="grid-pattern"></div>
<div class="gradient-overlay"></div>
</div>
<!-- 标题区域 -->
<div class="section-header">
<div class="section-line"></div>
<span class="section-title">推演记录</span>
<div class="section-line"></div>
</div>
<!-- 卡片容器(只在有项目时显示) -->
<div v-if="projects.length > 0" class="cards-container" :class="{ expanded: isExpanded }" :style="containerStyle">
<div
v-for="(project, index) in projects"
:key="project.simulation_id"
class="project-card"
:class="{ expanded: isExpanded, hovering: hoveringCard === index }"
:style="getCardStyle(index)"
@mouseenter="hoveringCard = index"
@mouseleave="hoveringCard = null"
@click="navigateToProject(project)"
>
<!-- 卡片头部:simulation_id 和 功能可用状态 -->
<div class="card-header">
<span class="card-id">{{ formatSimulationId(project.simulation_id) }}</span>
<div class="card-status-icons">
<span
class="status-icon"
:class="{ available: project.project_id, unavailable: !project.project_id }"
title="图谱构建"
></span>
<span
class="status-icon available"
title="环境搭建"
></span>
<span
class="status-icon"
:class="{ available: project.report_id, unavailable: !project.report_id }"
title="分析报告"
></span>
</div>
</div>
<!-- 文件列表区域 -->
<div class="card-files-wrapper">
<!-- 角落装饰 - 取景框风格 -->
<div class="corner-mark top-left-only"></div>
<!-- 文件列表 -->
<div class="files-list" v-if="project.files && project.files.length > 0">
<div
v-for="(file, fileIndex) in project.files.slice(0, 3)"
:key="fileIndex"
class="file-item"
>
<span class="file-tag" :class="getFileType(file.filename)">{{ getFileTypeLabel(file.filename) }}</span>
<span class="file-name">{{ truncateFilename(file.filename, 20) }}</span>
</div>
<!-- 如果有更多文件,显示提示 -->
<div v-if="project.files.length > 3" class="files-more">
+{{ project.files.length - 3 }} 个文件
</div>
</div>
<!-- 无文件时的占位 -->
<div class="files-empty" v-else>
<span class="empty-file-icon"></span>
<span class="empty-file-text">暂无文件</span>
</div>
</div>
<!-- 卡片标题(使用模拟需求的前20字作为标题) -->
<h3 class="card-title">{{ getSimulationTitle(project.simulation_requirement) }}</h3>
<!-- 卡片描述(模拟需求完整展示) -->
<p class="card-desc">{{ truncateText(project.simulation_requirement, 55) }}</p>
<!-- 卡片底部 -->
<div class="card-footer">
<div class="card-datetime">
<span class="card-date">{{ formatDate(project.created_at) }}</span>
<span class="card-time">{{ formatTime(project.created_at) }}</span>
</div>
<span class="card-progress" :class="getProgressClass(project)">
<span class="status-dot"></span> {{ formatRounds(project) }}
</span>
</div>
<!-- 底部装饰线 (hover时展开) -->
<div class="card-bottom-line"></div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<span class="loading-spinner"></span>
<span class="loading-text">加载中...</span>
</div>
<!-- 历史回放详情弹窗 -->
<Teleport to="body">
<Transition name="modal">
<div v-if="selectedProject" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<!-- 弹窗头部 -->
<div class="modal-header">
<div class="modal-title-section">
<span class="modal-id">{{ formatSimulationId(selectedProject.simulation_id) }}</span>
<span class="modal-progress" :class="getProgressClass(selectedProject)">
<span class="status-dot"></span> {{ formatRounds(selectedProject) }}
</span>
<span class="modal-create-time">{{ formatDate(selectedProject.created_at) }} {{ formatTime(selectedProject.created_at) }}</span>
</div>
<button class="modal-close" @click="closeModal">×</button>
</div>
<!-- 弹窗内容 -->
<div class="modal-body">
<!-- 模拟需求 -->
<div class="modal-section">
<div class="modal-label">模拟需求</div>
<div class="modal-requirement">{{ selectedProject.simulation_requirement || '无' }}</div>
</div>
<!-- 文件列表 -->
<div class="modal-section">
<div class="modal-label">关联文件</div>
<div class="modal-files" v-if="selectedProject.files && selectedProject.files.length > 0">
<div v-for="(file, index) in selectedProject.files" :key="index" class="modal-file-item">
<span class="file-tag" :class="getFileType(file.filename)">{{ getFileTypeLabel(file.filename) }}</span>
<span class="modal-file-name">{{ file.filename }}</span>
</div>
</div>
<div class="modal-empty" v-else>暂无关联文件</div>
</div>
</div>
<!-- 推演回放分割线 -->
<div class="modal-divider">
<span class="divider-line"></span>
<span class="divider-text">推演回放</span>
<span class="divider-line"></span>
</div>
<!-- 导航按钮 -->
<div class="modal-actions">
<button
class="modal-btn btn-project"
@click="goToProject"
:disabled="!selectedProject.project_id"
>
<span class="btn-step">Step1</span>
<span class="btn-icon"></span>
<span class="btn-text">图谱构建</span>
</button>
<button
class="modal-btn btn-simulation"
@click="goToSimulation"
>
<span class="btn-step">Step2</span>
<span class="btn-icon"></span>
<span class="btn-text">环境搭建</span>
</button>
<button
class="modal-btn btn-report"
@click="goToReport"
:disabled="!selectedProject.report_id"
>
<span class="btn-step">Step4</span>
<span class="btn-icon"></span>
<span class="btn-text">分析报告</span>
</button>
</div>
<!-- 不可回放提示 -->
<div class="modal-playback-hint">
<span class="hint-text">Step3「开始模拟」与 Step5「深度互动」需在运行中启动,不支持历史回放</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getSimulationHistory } from '../api/simulation'
const router = useRouter()
const route = useRoute()
// 状态
const projects = ref([])
const loading = ref(true)
const isExpanded = ref(false)
const hoveringCard = ref(null)
const historyContainer = ref(null)
const selectedProject = ref(null) // 当前选中的项目(用于弹窗)
let observer = null
let isAnimating = false // 动画锁,防止闪烁
let expandDebounceTimer = null // 防抖定时器
let pendingState = null // 记录待执行的目标状态
// 卡片布局配置 - 调整为更宽的比例
const CARDS_PER_ROW = 4
const CARD_WIDTH = 280
const CARD_HEIGHT = 280
const CARD_GAP = 24
// 动态计算容器高度样式
const containerStyle = computed(() => {
if (!isExpanded.value) {
// 折叠态:固定高度
return { minHeight: '420px' }
}
// 展开态:根据卡片数量动态计算高度
const total = projects.value.length
if (total === 0) {
return { minHeight: '280px' }
}
const rows = Math.ceil(total / CARDS_PER_ROW)
// 计算实际需要的高度:行数 * 卡片高度 + (行数-1) * 间距 + 少量底部间距
const expandedHeight = rows * CARD_HEIGHT + (rows - 1) * CARD_GAP + 10
return { minHeight: `${expandedHeight}px` }
})
// 获取卡片样式
const getCardStyle = (index) => {
const total = projects.value.length
if (isExpanded.value) {
// 展开态:网格布局
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
const col = index % CARDS_PER_ROW
const row = Math.floor(index / CARDS_PER_ROW)
// 计算当前行的卡片数量,确保每行居中
const currentRowStart = row * CARDS_PER_ROW
const currentRowCards = Math.min(CARDS_PER_ROW, total - currentRowStart)
const rowWidth = currentRowCards * CARD_WIDTH + (currentRowCards - 1) * CARD_GAP
const startX = -(rowWidth / 2) + (CARD_WIDTH / 2)
const colInRow = index % CARDS_PER_ROW
const x = startX + colInRow * (CARD_WIDTH + CARD_GAP)
// 向下展开,增加与标题的间距
const y = 20 + row * (CARD_HEIGHT + CARD_GAP)
return {
transform: `translate(${x}px, ${y}px) rotate(0deg) scale(1)`,
zIndex: 100 + index,
opacity: 1,
transition: transition
}
} else {
// 折叠态:扇形堆叠
const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'
const centerIndex = (total - 1) / 2
const offset = index - centerIndex
const x = offset * 35
// 调整起始位置,靠近标题但保持适当间距
const y = 25 + Math.abs(offset) * 8
const r = offset * 3
const s = 0.95 - Math.abs(offset) * 0.05
return {
transform: `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`,
zIndex: 10 + index,
opacity: 1,
transition: transition
}
}
}
// 根据轮数进度获取样式类
const getProgressClass = (simulation) => {
const current = simulation.current_round || 0
const total = simulation.total_rounds || 0
if (total === 0 || current === 0) {
// 未开始
return 'not-started'
} else if (current >= total) {
// 已完成
return 'completed'
} else {
// 进行中
return 'in-progress'
}
}
// 格式化日期(只显示日期部分)
const formatDate = (dateStr) => {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toISOString().slice(0, 10)
} catch {
return dateStr?.slice(0, 10) || ''
}
}
// 格式化时间(显示时:分)
const formatTime = (dateStr) => {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
} catch {
return ''
}
}
// 截断文本
const truncateText = (text, maxLength) => {
if (!text) return ''
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
// 从模拟需求生成标题(取前20字)
const getSimulationTitle = (requirement) => {
if (!requirement) return '未命名模拟'
const title = requirement.slice(0, 20)
return requirement.length > 20 ? title + '...' : title
}
// 格式化 simulation_id 显示(截取前6位)
const formatSimulationId = (simulationId) => {
if (!simulationId) return 'SIM_UNKNOWN'
const prefix = simulationId.replace('sim_', '').slice(0, 6)
return `SIM_${prefix.toUpperCase()}`
}
// 格式化轮数显示(当前轮/总轮数)
const formatRounds = (simulation) => {
const current = simulation.current_round || 0
const total = simulation.total_rounds || 0
if (total === 0) return '未开始'
return `${current}/${total} 轮`
}
// 获取文件类型(用于样式)
const getFileType = (filename) => {
if (!filename) return 'other'
const ext = filename.split('.').pop()?.toLowerCase()
const typeMap = {
'pdf': 'pdf',
'doc': 'doc', 'docx': 'doc',
'xls': 'xls', 'xlsx': 'xls', 'csv': 'xls',
'ppt': 'ppt', 'pptx': 'ppt',
'txt': 'txt', 'md': 'txt', 'json': 'code',
'jpg': 'img', 'jpeg': 'img', 'png': 'img', 'gif': 'img',
'zip': 'zip', 'rar': 'zip', '7z': 'zip'
}
return typeMap[ext] || 'other'
}
// 获取文件类型标签文本
const getFileTypeLabel = (filename) => {
if (!filename) return 'FILE'
const ext = filename.split('.').pop()?.toUpperCase()
return ext || 'FILE'
}
// 截断文件名(保留扩展名)
const truncateFilename = (filename, maxLength) => {
if (!filename) return '未知文件'
if (filename.length <= maxLength) return filename
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const nameWithoutExt = filename.slice(0, filename.length - ext.length)
const truncatedName = nameWithoutExt.slice(0, maxLength - ext.length - 3) + '...'
return truncatedName + ext
}
// 打开项目详情弹窗
const navigateToProject = (simulation) => {
selectedProject.value = simulation
}
// 关闭弹窗
const closeModal = () => {
selectedProject.value = null
}
// 导航到图谱构建页面(Project)
const goToProject = () => {
if (selectedProject.value?.project_id) {
router.push({
name: 'Process',
params: { projectId: selectedProject.value.project_id }
})
closeModal()
}
}
// 导航到环境配置页面(Simulation)
const goToSimulation = () => {
if (selectedProject.value?.simulation_id) {
router.push({
name: 'Simulation',
params: { simulationId: selectedProject.value.simulation_id }
})
closeModal()
}
}
// 导航到分析报告页面(Report)
const goToReport = () => {
if (selectedProject.value?.report_id) {
router.push({
name: 'Report',
params: { reportId: selectedProject.value.report_id }
})
closeModal()
}
}
// 加载历史项目
const loadHistory = async () => {
try {
loading.value = true
const response = await getSimulationHistory(20)
if (response.success) {
projects.value = response.data || []
}
} catch (error) {
console.error('加载历史项目失败:', error)
projects.value = []
} finally {
loading.value = false
}
}
// 初始化 IntersectionObserver
const initObserver = () => {
if (observer) {
observer.disconnect()
}
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const shouldExpand = entry.isIntersecting
// 更新待执行的目标状态(无论是否在动画中都要记录最新的目标状态)
pendingState = shouldExpand
// 清除之前的防抖定时器(新的滚动意图会覆盖旧的)
if (expandDebounceTimer) {
clearTimeout(expandDebounceTimer)
expandDebounceTimer = null
}
// 如果正在动画中,只记录状态,等动画结束后处理
if (isAnimating) return
// 如果目标状态与当前状态相同,不需要处理
if (shouldExpand === isExpanded.value) {
pendingState = null
return
}
// 使用防抖延迟状态切换,防止快速闪烁
// 展开时延迟较短(50ms),收起时延迟较长(200ms)以增加稳定性
const delay = shouldExpand ? 50 : 200
expandDebounceTimer = setTimeout(() => {
// 检查是否正在动画
if (isAnimating) return
// 检查待执行状态是否仍需要执行(可能已被后续滚动覆盖)
if (pendingState === null || pendingState === isExpanded.value) return
// 设置动画锁
isAnimating = true
isExpanded.value = pendingState
pendingState = null
// 动画完成后解除锁定,并检查是否有待处理的状态变化
setTimeout(() => {
isAnimating = false
// 动画结束后,检查是否有新的待执行状态
if (pendingState !== null && pendingState !== isExpanded.value) {
// 延迟一小段时间再执行,避免太快切换
expandDebounceTimer = setTimeout(() => {
if (pendingState !== null && pendingState !== isExpanded.value) {
isAnimating = true
isExpanded.value = pendingState
pendingState = null
setTimeout(() => {
isAnimating = false
}, 750)
}
}, 100)
}
}, 750)
}, delay)
})
},
{
// 使用多个阈值,使检测更平滑
threshold: [0.4, 0.6, 0.8],
// 调整 rootMargin,视口底部向上收缩,需要滚动更多才触发展开
rootMargin: '0px 0px -150px 0px'
}
)
// 开始观察
if (historyContainer.value) {
observer.observe(historyContainer.value)
}
}
// 监听路由变化,当返回首页时重新加载数据
watch(() => route.path, (newPath) => {
if (newPath === '/') {
loadHistory()
}
})
onMounted(async () => {
// 确保 DOM 渲染完成后再加载数据
await nextTick()
await loadHistory()
// 等待 DOM 渲染后初始化观察器
setTimeout(() => {
initObserver()
}, 100)
})
// 如果使用 keep-alive,在组件激活时重新加载数据
onActivated(() => {
loadHistory()
})
onUnmounted(() => {
// 清理 Intersection Observer
if (observer) {
observer.disconnect()
observer = null
}
// 清理防抖定时器
if (expandDebounceTimer) {
clearTimeout(expandDebounceTimer)
expandDebounceTimer = null
}
})
</script>
<style scoped>
/* 容器 */
.history-database {
position: relative;
width: 100%;
min-height: 280px;
margin-top: 40px;
padding: 35px 0 40px;
overflow: visible;
}
/* 无项目时简化显示 */
.history-database.no-projects {
min-height: auto;
padding: 40px 0 20px;
}
/* 技术网格背景 */
.tech-grid-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
}
/* 使用CSS背景图案创建固定间距的正方形网格 */
.grid-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
background-size: 50px 50px;
/* 从左上角开始定位,高度变化时只在底部扩展,不影响已有网格位置 */
background-position: top left;
}
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(to right, rgba(255, 255, 255, 0.9) 0%, transparent 15%, transparent 85%, rgba(255, 255, 255, 0.9) 100%),
linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, transparent 20%, transparent 80%, rgba(255, 255, 255, 0.8) 100%);
pointer-events: none;
}
/* 标题区域 */
.section-header {
position: relative;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
margin-bottom: 24px;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
padding: 0 40px;
}
.section-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, #E5E7EB, transparent);
max-width: 300px;
}
.section-title {
font-size: 0.8rem;
font-weight: 500;
color: #9CA3AF;
letter-spacing: 3px;
text-transform: uppercase;
}
/* 卡片容器 */
.cards-container {
position: relative;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0 40px;
transition: min-height 700ms cubic-bezier(0.23, 1, 0.32, 1);
/* min-height 由 JS 动态计算,根据卡片数量自适应 */
}
/* 项目卡片 */
.project-card {
position: absolute;
width: 280px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 0;
padding: 14px;
cursor: pointer;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
transition: box-shadow 0.3s ease, border-color 0.3s ease, transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1);
}
.project-card:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.4);
z-index: 1000 !important;
}
.project-card.hovering {
z-index: 1000 !important;
}
/* 卡片头部 */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #F3F4F6;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.7rem;
}
.card-id {
color: #6B7280;
letter-spacing: 0.5px;
font-weight: 500;
}
/* 功能状态图标组 */
.card-status-icons {
display: flex;
align-items: center;
gap: 6px;
}
.status-icon {
font-size: 0.75rem;
transition: all 0.2s ease;
cursor: default;
}
.status-icon.available {
opacity: 1;
}
/* 不同功能的颜色 */
.status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */
.status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */
.status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */
.status-icon.unavailable {
color: #D1D5DB;
opacity: 0.5;
}
/* 轮数进度显示 */
.card-progress {
display: flex;
align-items: center;
gap: 6px;
letter-spacing: 0.5px;
font-weight: 600;
font-size: 0.65rem;
}
.status-dot {
font-size: 0.5rem;
}
/* 进度状态颜色 */
.card-progress.completed { color: #10B981; } /* 已完成 - 绿色 */
.card-progress.in-progress { color: #F59E0B; } /* 进行中 - 橙色 */
.card-progress.not-started { color: #9CA3AF; } /* 未开始 - 灰色 */
.card-status.pending { color: #9CA3AF; }
/* 文件列表区域 */
.card-files-wrapper {
position: relative;
width: 100%;
min-height: 48px;
max-height: 110px;
margin-bottom: 12px;
padding: 8px 10px;
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%);
border-radius: 4px;
border: 1px solid #e8eaed;
overflow: hidden;
}
.files-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* 更多文件提示 */
.files-more {
display: flex;
align-items: center;
justify-content: center;
padding: 3px 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: #6B7280;
background: rgba(255, 255, 255, 0.5);
border-radius: 3px;
letter-spacing: 0.3px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background: rgba(255, 255, 255, 0.7);
border-radius: 3px;
transition: all 0.2s ease;
}
.file-item:hover {
background: rgba(255, 255, 255, 1);
transform: translateX(2px);
border-color: #e5e7eb;
}
/* 简约文件标签样式 */
.file-tag {
display: inline-flex;
align-items: center;
justify-content: center;
height: 16px;
padding: 0 4px;
border-radius: 2px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.55rem;
font-weight: 600;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.2px;
flex-shrink: 0;
min-width: 28px;
}
/* 低饱和度配色方案 - Morandi色系 */
.file-tag.pdf { background: #f2e6e6; color: #a65a5a; }
.file-tag.doc { background: #e6eff5; color: #5a7ea6; }
.file-tag.xls { background: #e6f2e8; color: #5aa668; }
.file-tag.ppt { background: #f5efe6; color: #a6815a; }
.file-tag.txt { background: #f0f0f0; color: #757575; }
.file-tag.code { background: #eae6f2; color: #815aa6; }
.file-tag.img { background: #e6f2f2; color: #5aa6a6; }
.file-tag.zip { background: #f2f0e6; color: #a69b5a; }
.file-tag.other { background: #f3f4f6; color: #6b7280; }
.file-name {
font-family: 'Inter', sans-serif;
font-size: 0.7rem;
color: #4b5563;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.1px;
}
/* 无文件时的占位 */
.files-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 48px;
color: #9CA3AF;
}
.empty-file-icon {
font-size: 1rem;
opacity: 0.5;
}
.empty-file-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.5px;
}
/* 悬停时文件区域效果 */
.project-card:hover .card-files-wrapper {
border-color: #d1d5db;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
}
/* 角落装饰 */
.corner-mark.top-left-only {
position: absolute;
top: 6px;
left: 6px;
width: 8px;
height: 8px;
border-top: 1.5px solid rgba(0, 0, 0, 0.4);
border-left: 1.5px solid rgba(0, 0, 0, 0.4);
pointer-events: none;
z-index: 10;
}
/* 卡片标题 */
.card-title {
font-family: 'Inter', -apple-system, sans-serif;
font-size: 0.9rem;
font-weight: 700;
color: #111827;
margin: 0 0 6px 0;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
}
.project-card:hover .card-title {
color: #2563EB;
}
/* 卡片描述 */
.card-desc {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
color: #6B7280;
margin: 0 0 16px 0;
line-height: 1.5;
height: 34px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 卡片底部 */
.card-footer {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #F3F4F6;
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: #9CA3AF;
font-weight: 500;
}
/* 日期时间组合 */
.card-datetime {
display: flex;
align-items: center;
gap: 8px;
}
/* 底部轮数进度显示 */
.card-footer .card-progress {
display: flex;
align-items: center;
gap: 6px;
letter-spacing: 0.5px;
font-weight: 600;
font-size: 0.65rem;
}
.card-footer .status-dot {
font-size: 0.5rem;
}
/* 进度状态颜色 - 底部 */
.card-footer .card-progress.completed { color: #10B981; }
.card-footer .card-progress.in-progress { color: #F59E0B; }
.card-footer .card-progress.not-started { color: #9CA3AF; }
/* 底部装饰线 */
.card-bottom-line {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
width: 0;
background-color: #000;
transition: width 0.5s cubic-bezier(0.23, 1, 0.32, 1);
z-index: 20;
}
.project-card:hover .card-bottom-line {
width: 100%;
}
/* 空状态 */
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 48px;
color: #9CA3AF;
}
.empty-icon {
font-size: 2rem;
opacity: 0.5;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #E5E7EB;
border-top-color: #6B7280;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 响应式 */
@media (max-width: 1200px) {
.project-card {
width: 240px;
}
}
@media (max-width: 768px) {
.cards-container {
padding: 0 20px;
}
.project-card {
width: 200px;
}
}
/* ===== 历史回放详情弹窗样式 ===== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
}
.modal-content {
background: #FFFFFF;
width: 560px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 动画过渡 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-content {
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.modal-leave-active .modal-content {
transition: all 0.2s ease-in;
}
.modal-enter-from .modal-content {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
.modal-leave-to .modal-content {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
/* 弹窗头部 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 32px;
border-bottom: 1px solid #F3F4F6;
background: #FFFFFF;
}
.modal-title-section {
display: flex;
align-items: center;
gap: 16px;
}
.modal-id {
font-family: 'JetBrains Mono', monospace;
font-size: 1rem;
font-weight: 600;
color: #111827;
letter-spacing: 0.5px;
}
.modal-progress {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
background: #F9FAFB;
}
.modal-progress.completed { color: #10B981; background: rgba(16, 185, 129, 0.1); }
.modal-progress.in-progress { color: #F59E0B; background: rgba(245, 158, 11, 0.1); }
.modal-progress.not-started { color: #9CA3AF; background: #F3F4F6; }
.modal-create-time {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #9CA3AF;
letter-spacing: 0.3px;
}
.modal-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 1.5rem;
color: #9CA3AF;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
border-radius: 6px;
}
.modal-close:hover {
background: #F3F4F6;
color: #111827;
}
/* 弹窗内容 */
.modal-body {
padding: 24px 32px;
}
.modal-section {
margin-bottom: 24px;
}
.modal-section:last-child {
margin-bottom: 0;
}
.modal-label {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #6B7280;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
font-weight: 500;
}
.modal-requirement {
font-size: 0.95rem;
color: #374151;
line-height: 1.6;
padding: 16px;
background: #F9FAFB;
border: 1px solid #F3F4F6;
border-radius: 8px;
}
.modal-files {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 200px;
overflow-y: auto;
padding-right: 4px;
}
/* 自定义滚动条样式 */
.modal-files::-webkit-scrollbar {
width: 4px;
}
.modal-files::-webkit-scrollbar-track {
background: #F3F4F6;
border-radius: 2px;
}
.modal-files::-webkit-scrollbar-thumb {
background: #D1D5DB;
border-radius: 2px;
}
.modal-files::-webkit-scrollbar-thumb:hover {
background: #9CA3AF;
}
.modal-file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 6px;
transition: all 0.2s ease;
}
.modal-file-item:hover {
border-color: #D1D5DB;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.modal-file-name {
font-size: 0.85rem;
color: #4B5563;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-empty {
font-size: 0.85rem;
color: #9CA3AF;
padding: 16px;
background: #F9FAFB;
border: 1px dashed #E5E7EB;
border-radius: 6px;
text-align: center;
}
/* 推演回放分割线 */
.modal-divider {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 32px 0;
background: #FFFFFF;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, #E5E7EB, transparent);
}
.divider-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: #9CA3AF;
letter-spacing: 2px;
text-transform: uppercase;
white-space: nowrap;
}
/* 导航按钮 */
.modal-actions {
display: flex;
gap: 16px;
padding: 20px 32px;
background: #FFFFFF;
}
.modal-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.modal-btn:hover:not(:disabled) {
border-color: #000000;
transform: translateY(-2px);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.modal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #F9FAFB;
}
.btn-step {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
font-weight: 500;
color: #9CA3AF;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.btn-icon {
font-size: 1.4rem;
line-height: 1;
transition: color 0.2s ease;
}
.btn-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
color: #4B5563;
}
.modal-btn.btn-project .btn-icon { color: #3B82F6; }
.modal-btn.btn-simulation .btn-icon { color: #F59E0B; }
.modal-btn.btn-report .btn-icon { color: #10B981; }
.modal-btn:hover:not(:disabled) .btn-text {
color: #111827;
}
/* 不可回放提示 */
.modal-playback-hint {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32px 20px;
background: #FFFFFF;
}
.hint-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: #9CA3AF;
letter-spacing: 0.3px;
text-align: center;
line-height: 1.5;
}
</style>