feat(history): 添加首页历史项目展示组件

- 新增 HistoryDatabase.vue 组件,实现扇形堆叠到网格展开的动画效果
- 后端 simulation.py 添加历史模拟数据 API 支持
- 修复 SimulationManager 过滤隐藏文件问题
- 前端 simulation.js 添加获取历史模拟数据的 API 方法
- Home.vue 集成历史项目展示组件
- 实现正方形网格背景装饰效果
This commit is contained in:
666ghj 2025-12-31 17:54:39 +08:00
parent 8b80603325
commit e6da45ee63
5 changed files with 791 additions and 0 deletions

View file

@ -809,6 +809,109 @@ def list_simulations():
}), 500
@simulation_bp.route('/history', methods=['GET'])
def get_simulation_history():
"""
获取历史模拟列表带项目详情
用于首页历史项目展示返回包含项目名称描述等丰富信息的模拟列表
Query参数
limit: 返回数量限制默认20
返回
{
"success": true,
"data": [
{
"simulation_id": "sim_xxxx",
"project_id": "proj_xxxx",
"project_name": "武大舆情分析",
"simulation_requirement": "如果武汉大学发布...",
"status": "completed",
"entities_count": 68,
"profiles_count": 68,
"entity_types": ["Student", "Professor", ...],
"created_at": "2024-12-10",
"updated_at": "2024-12-10",
"total_rounds": 120,
"current_round": 120,
"version": "v1.0.2"
},
...
],
"count": 7
}
"""
try:
limit = request.args.get('limit', 20, type=int)
manager = SimulationManager()
simulations = manager.list_simulations()[:limit]
# 增强模拟数据,添加项目详情
enriched_simulations = []
for sim in simulations:
sim_dict = sim.to_dict()
# 获取关联的项目信息
project = ProjectManager.get_project(sim.project_id)
if project:
sim_dict["project_name"] = project.name
sim_dict["simulation_requirement"] = project.simulation_requirement
else:
sim_dict["project_name"] = "未知项目"
sim_dict["simulation_requirement"] = ""
# 获取模拟配置信息
config = manager.get_simulation_config(sim.simulation_id)
if config:
time_config = config.get("time_config", {})
sim_dict["total_simulation_hours"] = time_config.get("total_simulation_hours", 0)
sim_dict["total_rounds"] = int(
time_config.get("total_simulation_hours", 0) * 60 /
max(time_config.get("minutes_per_round", 60), 1)
)
else:
sim_dict["total_simulation_hours"] = 0
sim_dict["total_rounds"] = 0
# 获取运行状态
run_state = SimulationRunner.get_run_state(sim.simulation_id)
if run_state:
sim_dict["current_round"] = run_state.current_round
sim_dict["runner_status"] = run_state.runner_status.value
else:
sim_dict["current_round"] = 0
sim_dict["runner_status"] = "idle"
# 添加版本号
sim_dict["version"] = "v1.0.2"
# 格式化日期
try:
created_date = sim_dict.get("created_at", "")[:10]
sim_dict["created_date"] = created_date
except:
sim_dict["created_date"] = ""
enriched_simulations.append(sim_dict)
return jsonify({
"success": True,
"data": enriched_simulations,
"count": len(enriched_simulations)
})
except Exception as e:
logger.error(f"获取历史模拟失败: {str(e)}")
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
@simulation_bp.route('/<simulation_id>/profiles', methods=['GET'])
def get_simulation_profiles(simulation_id: str):
"""

View file

@ -465,6 +465,11 @@ class SimulationManager:
if os.path.exists(self.SIMULATION_DATA_DIR):
for sim_id in os.listdir(self.SIMULATION_DATA_DIR):
# 跳过隐藏文件(如 .DS_Store和非目录文件
sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id)
if sim_id.startswith('.') or not os.path.isdir(sim_path):
continue
state = self._load_simulation_state(sim_id)
if state:
if project_id is None or state.project_id == project_id:

View file

@ -176,3 +176,12 @@ export const interviewAgents = (data) => {
return requestWithRetry(() => service.post('/api/simulation/interview/batch', data), 3, 1000)
}
/**
* 获取历史模拟列表带项目详情
* 用于首页历史项目展示
* @param {number} limit - 返回数量限制
*/
export const getSimulationHistory = (limit = 20) => {
return service.get('/api/simulation/history', { params: { limit } })
}

View file

@ -0,0 +1,670 @@
<template>
<div
class="history-database"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 背景装饰技术网格线使用CSS背景固定间距正方形网格 -->
<div class="tech-grid-bg">
<div class="grid-pattern"></div>
<div class="gradient-overlay"></div>
</div>
<!-- CTA 按钮 -->
<div
class="cta-button"
:style="{ transform: `translateY(${ctaOffset}px)` }"
@click="toggleExpand"
>
<div class="cta-inner">
<span class="cta-icon"></span>
<span class="cta-text">HISTORY DATABASE ({{ projects.length }})</span>
<span class="cta-arrow" :class="{ expanded: isExpanded }"></span>
</div>
</div>
<!-- 卡片容器 -->
<div class="cards-container" :class="{ expanded: isExpanded }">
<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)"
>
<!-- 卡片头部ID和状态 -->
<div class="card-header">
<span class="card-id">ID_{{ String(index + 1).padStart(3, '0') }}</span>
<span class="card-status" :class="getStatusClass(project.status)">
<span class="status-dot"></span> {{ getStatusText(project.status) }}
</span>
</div>
<!-- 卡片图片区域带角落装饰 -->
<div class="card-image-wrapper">
<!-- 角落装饰 - 取景框风格 -->
<div class="corner-mark top-left-only"></div>
<!-- 图片 -->
<img
class="card-image"
:src="getRandomImageUrl(project.simulation_id, index)"
:alt="project.project_name"
loading="lazy"
@error="handleImageError($event, index)"
/>
</div>
<!-- 卡片标题 -->
<h3 class="card-title">{{ project.project_name || 'Unnamed Project' }}</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-version">{{ project.version || 'v1.0.2' }}</span>
</div>
<!-- 底部装饰线 (hover时展开) -->
<div class="card-bottom-line"></div>
</div>
</div>
<!-- 空状态 -->
<div v-if="projects.length === 0 && !loading" class="empty-state">
<span class="empty-icon"></span>
<span class="empty-text">暂无历史项目</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<span class="loading-spinner"></span>
<span class="loading-text">加载中...</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getSimulationHistory } from '../api/simulation'
const router = useRouter()
//
const projects = ref([])
const loading = ref(true)
const isExpanded = ref(false)
const hoveringCard = ref(null)
const ctaOffset = ref(0)
const imageErrors = ref({}) //
// -
const CARDS_PER_ROW = 4
const CARD_WIDTH = 280
const CARD_HEIGHT = 280
const CARD_GAP = 24
const EXPANDED_ROW_HEIGHT = 230 // 230px (Requirements)
const EXPANDED_COL_WIDTH = 280 // (Requirements spacing 280px)
// 访
const IMAGE_SERVICES = {
// Lorem Picsum - 访
picsum: (seed, width, height) =>
`https://picsum.photos/seed/${seed}/${width}/${height}`,
}
// URL - (280x64)
const getRandomImageUrl = (simulationId, index) => {
if (imageErrors.value[index]) {
return null
}
const seed = simulationId || `project-${index}`
// 280644.4:1
return IMAGE_SERVICES.picsum(seed, 280, 64)
}
//
const handleImageError = (event, index) => {
imageErrors.value[index] = true
event.target.style.display = 'none'
}
// CTA
const calculateCtaOffset = () => {
if (!isExpanded.value) {
ctaOffset.value = 0
return
}
const rowCount = Math.ceil(projects.value.length / CARDS_PER_ROW)
// CTA
ctaOffset.value = -(rowCount * EXPANDED_ROW_HEIGHT + 100)
}
//
const getCardStyle = (index) => {
const total = projects.value.length
if (isExpanded.value) {
//
// Easing: cubic-bezier(0.23, 1, 0.32, 1), Duration: 700ms
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)
//
// 280px (Based on CARD_WIDTH being 280px. Assuming standard grid gap is included or minimal)
// Using CARD_WIDTH + CARD_GAP for spacing calculation to be safe, but requirements said "spacing 280px".
// If spacing means column width, then grid width is ColWidth * count.
// Let's stick to the previous logic but ensure center alignment.
const rowWidth = currentRowCards * CARD_WIDTH + (currentRowCards - 1) * CARD_GAP
const containerWidth = CARDS_PER_ROW * CARD_WIDTH + (CARDS_PER_ROW - 1) * CARD_GAP // Full width of a complete row
// Calculate offset to center the current row relative to the full container width
// Actually, the requirements say "translateX: based on colIndex, centered per row"
// So for a row with 3 items, they should be centered.
// The visual center is 0.
// Leftmost item x = - (rowWidth / 2) + (CARD_WIDTH / 2)
// Next item x += CARD_WIDTH + CARD_GAP
const startX = -(rowWidth / 2) + (CARD_WIDTH / 2)
const offsetX = (col % CARDS_PER_ROW) * (CARD_WIDTH + CARD_GAP) // offset within the row
// Wait, the calculation needs to be based on the column index WITHIN the current row (0 to currentRowCards-1)
// Since col = index % 4, it resets for each row.
const colInRow = index % CARDS_PER_ROW
const x = startX + colInRow * (CARD_WIDTH + CARD_GAP)
// translateY: . 230px.
// Row 0 is at the bottom? "Upward stacking logic (first row pushed up)".
// Assuming "first row pushed up" means visually higher.
// In CSS translate Y, negative is up.
// So row 0 should have the most negative Y? Or row 0 is at the bottom and subsequent rows stack up?
// "4" -> Row 1 is above Row 0.
// So Row 0 is at y=0 (or base), Row 1 is at y = -230px, etc.
const y = -(row * EXPANDED_ROW_HEIGHT)
return {
transform: `translate(${x}px, ${y}px) rotate(0deg) scale(1)`,
zIndex: 100 + index, // Requirements: 100 + gridIndex
opacity: 1,
transition: transition
}
} else {
//
// Easing: cubic-bezier(0.23, 1, 0.32, 1), Duration: 700ms
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 // Center index (float)
const offset = index - centerIndex // Offset from center
// translateX: offset * 35px
const x = offset * 35
// translateY: 130px + Math.abs(offset) * 8px
const y = 130 + Math.abs(offset) * 8
// rotate: offset * 3deg
const r = offset * 3
// scale: 0.95 - Math.abs(offset) * 0.05
const s = 0.95 - Math.abs(offset) * 0.05
return {
transform: `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`,
zIndex: 10 + index, // Requirements: 10 + index
opacity: 1, // Collapsed cards are usually fully opaque in the stack
transition: transition
}
}
}
//
const getStatusClass = (status) => {
const statusMap = {
completed: 'completed',
running: 'processing',
ready: 'ready',
failed: 'failed',
preparing: 'processing',
created: 'pending'
}
return statusMap[status] || 'pending'
}
//
const getStatusText = (status) => {
const textMap = {
completed: 'COMPLETED',
running: 'PROCESSING',
ready: 'READY',
failed: 'FAILED',
preparing: 'PREPARING',
created: 'CREATED'
}
return textMap[status] || 'PENDING'
}
//
const formatDate = (dateStr) => {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toISOString().slice(0, 10)
} catch {
return dateStr?.slice(0, 10) || ''
}
}
//
const truncateText = (text, maxLength) => {
if (!text) return ''
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
}
//
const handleMouseEnter = () => {
isExpanded.value = true
calculateCtaOffset()
}
const handleMouseLeave = () => {
isExpanded.value = false
ctaOffset.value = 0
}
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
calculateCtaOffset()
}
//
const navigateToProject = (project) => {
if (project.status === 'completed' || project.status === 'running' || project.status === 'ready') {
router.push({
name: 'SimulationRun',
params: { simulationId: project.simulation_id }
})
} else {
router.push({
name: 'Process',
params: { projectId: project.project_id }
})
}
}
//
const loadHistory = async () => {
try {
loading.value = true
const response = await getSimulationHistory(20)
if (response.success) {
projects.value = response.data || []
}
} catch (error) {
console.error('加载历史项目失败:', error)
projects.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
loadHistory()
})
</script>
<style scoped>
/* 容器 */
.history-database {
position: relative;
width: 100%;
min-height: 280px;
margin-top: 80px;
padding: 60px 0 120px;
overflow: visible;
}
/* 技术网格背景 */
.tech-grid-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
}
/* 使用CSS背景图案创建固定间距的正方形网格 */
.grid-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* 60px x 60px 的正方形网格 */
background-image:
linear-gradient(to right, rgba(0, 0, 0, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.06) 1px, transparent 1px);
background-size: 60px 60px;
background-position: center center;
}
.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;
}
/* CTA 按钮 */
.cta-button {
position: relative;
z-index: 100;
display: flex;
justify-content: center;
margin-bottom: 48px;
transition: transform 700ms cubic-bezier(0.23, 1, 0.32, 1); /* Match card duration */
cursor: pointer;
}
.cta-inner {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 32px;
background: #FFFFFF;
border: 1px solid #E0E0E0;
border-radius: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); /* 加深阴影 */
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.78rem;
font-weight: 600; /* 加粗 */
color: #1a1a1a;
letter-spacing: 1.2px;
transition: all 0.3s ease;
}
.cta-inner:hover {
background: #FAFAFA;
border-color: #CCCCCC;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.cta-icon {
color: #666; /* 加深颜色 */
font-size: 1rem;
}
.cta-arrow {
color: #666; /* 加深颜色 */
transition: transform 0.3s ease;
}
.cta-arrow.expanded {
transform: rotate(90deg);
}
/* 卡片容器 */
.cards-container {
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
min-height: 340px;
padding: 0 40px;
transition: min-height 700ms cubic-bezier(0.23, 1, 0.32, 1); /* Match card duration */
}
.cards-container.expanded {
min-height: 520px;
}
/* 项目卡片 - 完全参照参考图 */
.project-card {
position: absolute;
width: 280px; /* 调整宽度 */
background: #FFFFFF;
border: 1px solid #E5E7EB; /* border-gray-200 */
border-radius: 0; /* 直角或极小圆角 */
padding: 14px; /* 稍微减小内边距,让内容更紧凑 */
cursor: pointer;
/* Transitions are handled inline for transform/opacity, CSS for others */
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* shadow-sm */
/* Remove transition property from here as it's overridden by inline styles for transform/opacity */
/* Add specific transitions for border and shadow */
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);
}
/* 悬停效果 - 黑色粗边框,阴影加深 */
/* Micro-interaction: Hover: border-black/40 shadow-lg */
.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); /* shadow-lg */
border-color: rgba(0, 0, 0, 0.4); /* border-black/40 */
z-index: 1000 !important;
}
.project-card.hovering {
z-index: 1000 !important;
}
/* 卡片头部 */
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #F3F4F6; /* 增加分割线 */
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.7rem;
}
.card-id {
color: #6B7280; /* 加深灰色 */
letter-spacing: 0.5px;
font-weight: 500;
}
.card-status {
display: flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
font-size: 0.65rem;
}
.status-dot {
font-size: 0.5rem;
}
.card-status.completed {
color: #10B981; /* 更鲜艳的绿 */
}
.card-status.processing {
color: #F59E0B;
}
.card-status.ready {
color: #3B82F6;
}
.card-status.failed {
color: #EF4444;
}
.card-status.pending {
color: #9CA3AF;
}
/* 卡片图片区域 */
.card-image-wrapper {
position: relative;
width: 100%;
height: 64px; /* 极度压扁,复刻参考图的宽银幕感 */
margin-bottom: 12px;
overflow: hidden;
background: #f0f0f0;
}
.card-image {
width: 100%;
height: 100%;
object-fit: cover;
/* Micro-interaction: Default: opacity-80 grayscale */
filter: grayscale(100%);
opacity: 0.8;
transition: all 500ms ease; /* Duration 500ms */
}
/* 悬停时图片变彩色 */
/* Micro-interaction: Hover: opacity-100 grayscale-0 */
.project-card:hover .card-image {
filter: grayscale(0%);
opacity: 1;
}
/* 角落装饰 - 只保留左上角,颜色加深 */
.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; /* For absolute positioning of the line */
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;
}
/* 底部装饰线 */
/* Micro-interaction: Height 2px, bg-black, Default w-0, Hover w-full */
.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>

View file

@ -199,6 +199,9 @@
</div>
</div>
</section>
<!-- 历史项目数据库 -->
<HistoryDatabase />
</div>
</div>
</template>
@ -206,6 +209,7 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import HistoryDatabase from '../components/HistoryDatabase.vue'
const router = useRouter()