MiroFish/frontend/src/components/HistoryDatabase.vue
666ghj 5e865c0234 refactor(HistoryDatabase): improve project display logic and loading states
- Added conditional rendering for the tech grid background and cards container based on project availability.
- Simplified the empty state display logic when no projects are present.
- Introduced a new CSS class to adjust layout when there are no projects, enhancing visual consistency.
2026-01-09 11:12:10 +08:00

780 lines
20 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"
: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>
<span class="card-progress" :class="getProgressClass(project)">
<span class="status-dot"></span> {{ formatRounds(project) }}
</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-time">{{ formatTime(project.created_at) }}</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>
</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 // 防抖定时器
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 = 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 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) => {
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) => {
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'
}
)
// 等待 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 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: 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-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: 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>