- Added route watcher to reload history data when navigating back to the homepage. - Improved card positioning by adjusting vertical spacing for better visual alignment. - Initialized IntersectionObserver to manage card visibility more effectively. - Ensured data loading occurs after DOM rendering for smoother user experience.
803 lines
21 KiB
Vue
803 lines
21 KiB
Vue
<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, 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)
|
||
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) => {
|
||
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
|
||
}
|
||
}
|
||
|
||
// 初始化 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: 40px 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>
|