MiroFish/frontend/src/components/HistoryDatabase.vue
666ghj f32f5713f8 style(HistoryDatabase): adjust grid background position for improved layout
- Changed background position from center to top left to enhance grid alignment and responsiveness during height changes.
2026-01-09 10:53:44 +08:00

749 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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="history-database"
ref="historyContainer"
>
<!-- 背景装饰技术网格线使用CSS背景固定间距正方形网格 -->
<div 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 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>
<span class="card-status" :class="getStatusClass(project.status)">
<span class="status-dot"></span> {{ getStatusText(project.status) }}
</span>
</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>
<!-- 无文件时的占位 -->
<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">
<span class="card-date">{{ formatDate(project.created_at) }}</span>
<span class="card-rounds">{{ formatRounds(project) }}</span>
</div>
<!-- 底部装饰线 (hover时展开) -->
<div class="card-bottom-line"></div>
</div>
</div>
<!-- 空状态 -->
<div v-if="projects.length === 0 && !loading" class="empty-state">
<span class="empty-icon"></span>
<span class="empty-text">暂无历史项目</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<span class="loading-spinner"></span>
<span class="loading-text">加载中...</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getSimulationHistory } from '../api/simulation'
const router = useRouter()
// 状态
const projects = ref([])
const loading = ref(true)
const isExpanded = ref(false)
const hoveringCard = ref(null)
const historyContainer = ref(null)
let observer = null
let isAnimating = false // 动画锁,防止闪烁
let expandDebounceTimer = 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) * 间距 + 额外padding
const expandedHeight = rows * CARD_HEIGHT + (rows - 1) * CARD_GAP + 40
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 = 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 = 40 + 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 getStatusClass = (status) => {
const statusMap = {
completed: 'completed',
running: 'processing',
ready: 'ready',
failed: 'failed',
preparing: 'processing',
created: 'pending'
}
return statusMap[status] || 'pending'
}
// 获取状态文本
const getStatusText = (status) => {
const textMap = {
completed: 'COMPLETED',
running: 'PROCESSING',
ready: 'READY',
failed: 'FAILED',
preparing: 'PREPARING',
created: 'CREATED'
}
return textMap[status] || 'PENDING'
}
// 格式化日期
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 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) => {
router.push({
name: 'SimulationRun',
params: { simulationId: simulation.simulation_id }
})
}
// 加载历史项目
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
}
}
onMounted(() => {
loadHistory()
// 使用 Intersection Observer 监听滚动,自动展开/收起卡片
// 优化:使用防抖和动画锁防止闪烁
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 如果正在动画中,忽略新的触发
if (isAnimating) return
const shouldExpand = entry.isIntersecting
// 如果状态没有变化,不做任何处理
if (shouldExpand === isExpanded.value) return
// 清除之前的防抖定时器
if (expandDebounceTimer) {
clearTimeout(expandDebounceTimer)
expandDebounceTimer = null
}
// 使用防抖延迟状态切换,防止快速闪烁
// 展开时延迟较短(50ms),收起时延迟较长(200ms)以增加稳定性
const delay = shouldExpand ? 50 : 200
expandDebounceTimer = setTimeout(() => {
// 再次检查是否正在动画
if (isAnimating) return
// 设置动画锁
isAnimating = true
isExpanded.value = shouldExpand
// 动画完成后解除锁定700ms 是动画时长)
setTimeout(() => {
isAnimating = false
}, 750)
}, delay)
})
},
{
// 使用多个阈值,使检测更平滑
threshold: [0.3, 0.5, 0.7],
// 调整 rootMargin使触发区域更稳定
rootMargin: '0px 0px -100px 0px'
}
)
// 等待 DOM 渲染后开始观察
setTimeout(() => {
if (historyContainer.value) {
observer.observe(historyContainer.value)
}
}, 100)
})
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: 80px;
padding: 60px 0 120px;
overflow: visible;
}
/* 技术网格背景 */
.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: 200px;
}
.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 {
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
font-size: 0.65rem;
}
.status-dot {
font-size: 0.5rem;
}
.card-status.completed { color: #10B981; }
.card-status.processing { color: #F59E0B; }
.card-status.ready { color: #3B82F6; }
.card-status.failed { color: #EF4444; }
.card-status.pending { color: #9CA3AF; }
/* 文件列表区域 */
.card-files-wrapper {
position: relative;
width: 100%;
min-height: 64px;
margin-bottom: 12px;
padding: 8px 10px;
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%);
border-radius: 4px;
border: 1px solid #e8eaed;
}
.files-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.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-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;
}
}
</style>