- Changed background position from center to top left to enhance grid alignment and responsiveness during height changes.
749 lines
19 KiB
Vue
749 lines
19 KiB
Vue
<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>
|