1562 lines
41 KiB
Vue
1562 lines
41 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">{{ $t('history.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.project_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">{{ project.name || (project.project_id || '').slice(0, 8) }}</span>
|
||
<div class="card-status-icons">
|
||
<span
|
||
class="status-icon"
|
||
:class="{ available: project.project_id, unavailable: !project.project_id }"
|
||
:title="$t('history.graphBuild')"
|
||
>◇</span>
|
||
<span
|
||
class="status-icon"
|
||
:class="{ available: project.graph_id, unavailable: !project.graph_id }"
|
||
:title="$t('history.envSetup')"
|
||
>◈</span>
|
||
<span
|
||
class="status-icon"
|
||
:class="{ available: project.last_report_id || project.last_simulation_id, unavailable: !project.last_report_id && !project.last_simulation_id }"
|
||
:title="$t('history.analysisReport')"
|
||
>◆</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">
|
||
{{ $t('history.moreFiles', { count: project.files.length - 3 }) }}
|
||
</div>
|
||
</div>
|
||
<!-- 无文件时的占位 -->
|
||
<div class="files-empty" v-else>
|
||
<span class="empty-file-icon">◇</span>
|
||
<span class="empty-file-text">{{ $t('history.noFiles') }}</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">
|
||
<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>
|
||
|
||
<!-- Delete button -->
|
||
<button class="card-delete-btn" @click.stop="confirmDelete(project)" :title="$t('history.deleteProject')">×</button>
|
||
|
||
<!-- 底部装饰线 (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">{{ $t('history.loadingText') }}</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 v-if="!editingName" class="modal-id" @click="startEditName" style="cursor:pointer;" :title="$t('history.editName')">
|
||
{{ selectedProject.name || (selectedProject.project_id || '').slice(0, 8) }}
|
||
<span style="font-size:0.7rem; color:#9CA3AF; margin-left:4px;">✎</span>
|
||
</span>
|
||
<input
|
||
v-else
|
||
ref="nameInput"
|
||
v-model="editedName"
|
||
class="modal-name-input"
|
||
:placeholder="$t('history.namePlaceholder')"
|
||
@blur="saveEditedName"
|
||
@keyup.enter="saveEditedName"
|
||
@keyup.escape="cancelEditName"
|
||
/>
|
||
<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">{{ $t('history.simRequirement') }}</div>
|
||
<div class="modal-requirement">{{ selectedProject.simulation_requirement || $t('common.none') }}</div>
|
||
</div>
|
||
|
||
<!-- 文件列表 -->
|
||
<div class="modal-section">
|
||
<div class="modal-label">{{ $t('history.relatedFiles') }}</div>
|
||
<div class="modal-files" v-if="hasRelatedFiles(selectedProject)">
|
||
<a
|
||
v-if="selectedProject.files && selectedProject.files.length > 0"
|
||
:href="`/api/graph/project/${selectedProject.project_id}/download/source`"
|
||
class="modal-file-item modal-file-link"
|
||
download
|
||
>
|
||
<span class="file-tag txt">SRC</span>
|
||
<span class="modal-file-name">{{ selectedProject.files[0].filename }}</span>
|
||
<span class="file-download-icon">↓</span>
|
||
</a>
|
||
<a
|
||
v-if="selectedProject.last_simulation_id"
|
||
:href="`/api/simulation/${selectedProject.last_simulation_id}/download/report`"
|
||
class="modal-file-item modal-file-link"
|
||
download
|
||
>
|
||
<span class="file-tag doc">RPT</span>
|
||
<span class="modal-file-name">{{ $t('history.finalReport') }}</span>
|
||
<span class="file-download-icon">↓</span>
|
||
</a>
|
||
<a
|
||
v-if="selectedProject.last_simulation_id"
|
||
:href="`/api/simulation/${selectedProject.last_simulation_id}/download/log`"
|
||
class="modal-file-item modal-file-link"
|
||
download
|
||
>
|
||
<span class="file-tag code">LOG</span>
|
||
<span class="modal-file-name">{{ $t('history.simLog') }}</span>
|
||
<span class="file-download-icon">↓</span>
|
||
</a>
|
||
</div>
|
||
<div class="modal-empty" v-else>{{ $t('history.noRelatedFiles') }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 推演回放分割线 -->
|
||
<div class="modal-divider">
|
||
<span class="divider-line"></span>
|
||
<span class="divider-text">{{ $t('history.replayTitle') }}</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">{{ $t('history.step1Button') }}</span>
|
||
</button>
|
||
<button
|
||
class="modal-btn btn-simulation"
|
||
@click="goToSimulation"
|
||
:disabled="!selectedProject.last_simulation_id"
|
||
>
|
||
<span class="btn-step">Step2</span>
|
||
<span class="btn-icon">◈</span>
|
||
<span class="btn-text">{{ $t('history.step2Button') }}</span>
|
||
</button>
|
||
<button
|
||
class="modal-btn btn-report"
|
||
@click="goToReport"
|
||
:disabled="!selectedProject.last_report_id && !selectedProject.last_simulation_id"
|
||
>
|
||
<span class="btn-step">Step4</span>
|
||
<span class="btn-icon">◆</span>
|
||
<span class="btn-text">{{ $t('history.step4Button') }}</span>
|
||
</button>
|
||
</div>
|
||
<!-- 不可回放提示 -->
|
||
<div class="modal-playback-hint">
|
||
<span class="hint-text">{{ $t('history.replayHint') }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
|
||
<!-- Delete confirmation modal -->
|
||
<Teleport to="body">
|
||
<Transition name="modal">
|
||
<div v-if="deleteConfirmProject" class="modal-overlay delete-confirm-overlay" @click.self="cancelDelete">
|
||
<div class="modal-content delete-confirm-modal">
|
||
<div class="modal-header">
|
||
<span class="modal-id">{{ $t('history.deleteProject') }}</span>
|
||
<button class="modal-close" @click="cancelDelete">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="delete-confirm-text">
|
||
{{ $t('history.deleteConfirm', { title: getSimulationTitle(deleteConfirmProject.simulation_requirement) }) }}
|
||
</p>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="modal-btn btn-delete-confirm" @click="doDelete">{{ $t('history.deleteConfirmYes') }}</button>
|
||
<button class="modal-btn btn-project" @click="cancelDelete">{{ $t('history.deleteConfirmNo') }}</button>
|
||
</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 { useI18n } from 'vue-i18n'
|
||
import { listProjects, deleteProject, updateProjectName } from '../api/graph'
|
||
import { getReportBySimulation } from '../api/report'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
|
||
// 状态
|
||
const projects = ref([])
|
||
const loading = ref(true)
|
||
const isExpanded = ref(false)
|
||
const hoveringCard = ref(null)
|
||
const historyContainer = ref(null)
|
||
const selectedProject = ref(null) // 当前选中的项目(用于弹窗)
|
||
const deleteConfirmProject = ref(null) // project pending delete confirmation
|
||
const editingName = ref(false)
|
||
const editedName = ref('')
|
||
const nameInput = 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 t('history.untitledSimulation')
|
||
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 t('history.notStarted')
|
||
return t('history.roundsProgress', { 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 t('history.unknownFile')
|
||
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 confirmDelete = (project) => {
|
||
deleteConfirmProject.value = project
|
||
}
|
||
|
||
const cancelDelete = () => {
|
||
deleteConfirmProject.value = null
|
||
}
|
||
|
||
const doDelete = async () => {
|
||
const project = deleteConfirmProject.value
|
||
deleteConfirmProject.value = null
|
||
try {
|
||
await deleteProject(project.project_id)
|
||
projects.value = projects.value.filter(p => p.project_id !== project.project_id)
|
||
} catch (e) {
|
||
console.error('Delete project failed', e)
|
||
}
|
||
}
|
||
|
||
const hasRelatedFiles = (project) => {
|
||
if (!project) return false
|
||
return (project.files && project.files.length > 0) || !!project.last_simulation_id
|
||
}
|
||
|
||
const startEditName = () => {
|
||
editedName.value = selectedProject.value?.name || ''
|
||
editingName.value = true
|
||
nextTick(() => nameInput.value?.focus())
|
||
}
|
||
|
||
const saveEditedName = async () => {
|
||
const name = editedName.value.trim()
|
||
editingName.value = false
|
||
if (!name || !selectedProject.value) return
|
||
if (name === selectedProject.value.name) return
|
||
try {
|
||
await updateProjectName(selectedProject.value.project_id, name)
|
||
selectedProject.value = { ...selectedProject.value, name }
|
||
const idx = projects.value.findIndex(p => p.project_id === selectedProject.value.project_id)
|
||
if (idx !== -1) projects.value[idx] = { ...projects.value[idx], name }
|
||
} catch (e) {
|
||
console.error('Failed to update project name', e)
|
||
}
|
||
}
|
||
|
||
const cancelEditName = () => {
|
||
editingName.value = false
|
||
}
|
||
|
||
// 导航到图谱构建页面(Project)
|
||
const goToProject = () => {
|
||
if (selectedProject.value?.project_id) {
|
||
router.push({
|
||
name: 'Process',
|
||
params: { projectId: selectedProject.value.project_id }
|
||
})
|
||
closeModal()
|
||
}
|
||
}
|
||
|
||
// 导航到环境配置页面(Simulation)
|
||
const goToSimulation = () => {
|
||
if (selectedProject.value?.last_simulation_id) {
|
||
router.push({
|
||
name: 'Simulation',
|
||
params: { simulationId: selectedProject.value.last_simulation_id }
|
||
})
|
||
closeModal()
|
||
}
|
||
}
|
||
|
||
// 导航到分析报告页面(Report)
|
||
const goToReport = async () => {
|
||
if (!selectedProject.value) return
|
||
|
||
// Use known report_id if available
|
||
let reportId = selectedProject.value.last_report_id
|
||
|
||
// Otherwise look it up by simulation_id
|
||
if (!reportId && selectedProject.value.last_simulation_id) {
|
||
try {
|
||
const res = await getReportBySimulation(selectedProject.value.last_simulation_id)
|
||
reportId = res?.data?.report_id
|
||
} catch {
|
||
// no report found
|
||
}
|
||
}
|
||
|
||
if (reportId) {
|
||
closeModal()
|
||
router.push({ name: 'Interaction', params: { reportId } })
|
||
}
|
||
}
|
||
|
||
// 加载历史项目
|
||
const loadHistory = async () => {
|
||
try {
|
||
loading.value = true
|
||
const response = await listProjects(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: 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;
|
||
}
|
||
|
||
/* 使用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: 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;
|
||
}
|
||
|
||
/* 低饱和度配色方案 - 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-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;
|
||
}
|
||
|
||
/* Delete button on project card */
|
||
.card-delete-btn {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 1px solid #D1D5DB;
|
||
background: #fff;
|
||
color: #9CA3AF;
|
||
font-size: 0.95rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
border-radius: 3px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
opacity: 0.55;
|
||
transition: opacity 0.15s, color 0.15s, background 0.15s, border-color 0.15s;
|
||
z-index: 20;
|
||
}
|
||
|
||
.card-delete-btn:hover {
|
||
opacity: 1;
|
||
color: #EF4444;
|
||
border-color: #EF4444;
|
||
background: rgba(239, 68, 68, 0.06);
|
||
}
|
||
|
||
/* Delete confirmation modal */
|
||
.delete-confirm-modal {
|
||
max-width: 380px;
|
||
}
|
||
|
||
.delete-confirm-text {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 0.85rem;
|
||
color: #D1D5DB;
|
||
line-height: 1.6;
|
||
margin: 0;
|
||
}
|
||
|
||
.btn-delete-confirm {
|
||
background: rgba(239, 68, 68, 0.15);
|
||
border-color: rgba(239, 68, 68, 0.4);
|
||
color: #EF4444;
|
||
}
|
||
|
||
.btn-delete-confirm:hover:not(:disabled) {
|
||
background: rgba(239, 68, 68, 0.25);
|
||
border-color: rgba(239, 68, 68, 0.7);
|
||
}
|
||
|
||
.modal-name-input {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
border: 1px solid #D1D5DB;
|
||
border-radius: 4px;
|
||
padding: 2px 8px;
|
||
outline: none;
|
||
width: 260px;
|
||
}
|
||
|
||
.modal-name-input:focus {
|
||
border-color: #3B82F6;
|
||
}
|
||
|
||
.modal-file-link {
|
||
text-decoration: none;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.modal-file-link:hover {
|
||
border-color: #3B82F6;
|
||
background: #EFF6FF;
|
||
}
|
||
|
||
.file-download-icon {
|
||
margin-left: auto;
|
||
font-size: 0.85rem;
|
||
color: #9CA3AF;
|
||
}
|
||
</style>
|