| <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)" |
| > |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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) |
| |
| 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 |
| } |
| |
| |
| const getSimulationTitle = (requirement) => { |
| if (!requirement) return '未命名模拟' |
| const title = requirement.slice(0, 20) |
| return requirement.length > 20 ? title + '...' : title |
| } |
| |
| |
| 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 |
| } |
| |
| |
| const goToProject = () => { |
| if (selectedProject.value?.project_id) { |
| router.push({ |
| name: 'Process', |
| params: { projectId: selectedProject.value.project_id } |
| }) |
| closeModal() |
| } |
| } |
| |
| |
| const goToSimulation = () => { |
| if (selectedProject.value?.simulation_id) { |
| router.push({ |
| name: 'Simulation', |
| params: { simulationId: selectedProject.value.simulation_id } |
| }) |
| closeModal() |
| } |
| } |
| |
| |
| 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 |
| } |
| } |
| |
| |
| 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 |
| } |
| |
| |
| |
| 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) |
| }) |
| |
| |
| 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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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> |
| |