MiroFish / frontend /src /components /Step3Simulation.vue
Codex Deploy
Deploy MiroFish to HF Space
ebdfd3b
<template>
<div class="simulation-panel">
<!-- Top Control Bar -->
<div class="control-bar">
<div class="status-group">
<!-- Twitter 平台进度 -->
<div class="platform-status twitter" :class="{ active: runStatus.twitter_running, completed: runStatus.twitter_completed }">
<div class="platform-header">
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
<span class="platform-name">Info Plaza</span>
<span v-if="runStatus.twitter_completed" class="status-badge">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
</div>
<div class="platform-stats">
<span class="stat">
<span class="stat-label">ROUND</span>
<span class="stat-value mono">{{ runStatus.twitter_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>
</span>
<span class="stat">
<span class="stat-label">Elapsed Time</span>
<span class="stat-value mono">{{ twitterElapsedTime }}</span>
</span>
<span class="stat">
<span class="stat-label">ACTS</span>
<span class="stat-value mono">{{ runStatus.twitter_actions_count || 0 }}</span>
</span>
</div>
<!-- 可用动作提示 -->
<div class="actions-tooltip">
<div class="tooltip-title">Available Actions</div>
<div class="tooltip-actions">
<span class="tooltip-action">POST</span>
<span class="tooltip-action">LIKE</span>
<span class="tooltip-action">REPOST</span>
<span class="tooltip-action">QUOTE</span>
<span class="tooltip-action">FOLLOW</span>
<span class="tooltip-action">IDLE</span>
</div>
</div>
</div>
<!-- Reddit 平台进度 -->
<div class="platform-status reddit" :class="{ active: runStatus.reddit_running, completed: runStatus.reddit_completed }">
<div class="platform-header">
<svg class="platform-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
</svg>
<span class="platform-name">Topic Community</span>
<span v-if="runStatus.reddit_completed" class="status-badge">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
</div>
<div class="platform-stats">
<span class="stat">
<span class="stat-label">ROUND</span>
<span class="stat-value mono">{{ runStatus.reddit_current_round || 0 }}<span class="stat-total">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>
</span>
<span class="stat">
<span class="stat-label">Elapsed Time</span>
<span class="stat-value mono">{{ redditElapsedTime }}</span>
</span>
<span class="stat">
<span class="stat-label">ACTS</span>
<span class="stat-value mono">{{ runStatus.reddit_actions_count || 0 }}</span>
</span>
</div>
<!-- 可用动作提示 -->
<div class="actions-tooltip">
<div class="tooltip-title">Available Actions</div>
<div class="tooltip-actions">
<span class="tooltip-action">POST</span>
<span class="tooltip-action">COMMENT</span>
<span class="tooltip-action">LIKE</span>
<span class="tooltip-action">DISLIKE</span>
<span class="tooltip-action">SEARCH</span>
<span class="tooltip-action">TREND</span>
<span class="tooltip-action">FOLLOW</span>
<span class="tooltip-action">MUTE</span>
<span class="tooltip-action">REFRESH</span>
<span class="tooltip-action">IDLE</span>
</div>
</div>
</div>
</div>
<div class="action-controls">
<button
class="action-btn primary"
:disabled="phase !== 2 || isGeneratingReport"
@click="handleNextStep"
>
<span v-if="isGeneratingReport" class="loading-spinner-small"></span>
{{ isGeneratingReport ? '启动中...' : '开始生成结果报告' }}
<span v-if="!isGeneratingReport" class="arrow-icon"></span>
</button>
</div>
</div>
<!-- Main Content: Dual Timeline -->
<div class="main-content-area" ref="scrollContainer">
<!-- Timeline Header -->
<div class="timeline-header" v-if="allActions.length > 0">
<div class="timeline-stats">
<span class="total-count">TOTAL EVENTS: <span class="mono">{{ allActions.length }}</span></span>
<span class="platform-breakdown">
<span class="breakdown-item twitter">
<svg class="mini-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
<span class="mono">{{ twitterActionsCount }}</span>
</span>
<span class="breakdown-divider">/</span>
<span class="breakdown-item reddit">
<svg class="mini-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
<span class="mono">{{ redditActionsCount }}</span>
</span>
</span>
</div>
</div>
<!-- Timeline Feed -->
<div class="timeline-feed">
<div class="timeline-axis"></div>
<TransitionGroup name="timeline-item">
<div
v-for="action in chronologicalActions"
:key="action._uniqueId || action.id || `${action.timestamp}-${action.agent_id}`"
class="timeline-item"
:class="action.platform"
>
<div class="timeline-marker">
<div class="marker-dot"></div>
</div>
<div class="timeline-card">
<div class="card-header">
<div class="agent-info">
<div class="avatar-placeholder">{{ (action.agent_name || 'A')[0] }}</div>
<span class="agent-name">{{ action.agent_name }}</span>
</div>
<div class="header-meta">
<div class="platform-indicator">
<svg v-if="action.platform === 'twitter'" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
<svg v-else viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
</div>
<div class="action-badge" :class="getActionTypeClass(action.action_type)">
{{ getActionTypeLabel(action.action_type) }}
</div>
</div>
</div>
<div class="card-body">
<!-- CREATE_POST: 发布帖子 -->
<div v-if="action.action_type === 'CREATE_POST' && action.action_args?.content" class="content-text main-text">
{{ action.action_args.content }}
</div>
<!-- QUOTE_POST: 引用帖子 -->
<template v-if="action.action_type === 'QUOTE_POST'">
<div v-if="action.action_args?.quote_content" class="content-text">
{{ action.action_args.quote_content }}
</div>
<div v-if="action.action_args?.original_content" class="quoted-block">
<div class="quote-header">
<svg class="icon-small" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
<span class="quote-label">@{{ action.action_args.original_author_name || 'User' }}</span>
</div>
<div class="quote-text">
{{ truncateContent(action.action_args.original_content, 150) }}
</div>
</div>
</template>
<!-- REPOST: 转发帖子 -->
<template v-if="action.action_type === 'REPOST'">
<div class="repost-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>
<span class="repost-label">Reposted from @{{ action.action_args?.original_author_name || 'User' }}</span>
</div>
<div v-if="action.action_args?.original_content" class="repost-content">
{{ truncateContent(action.action_args.original_content, 200) }}
</div>
</template>
<!-- LIKE_POST: 点赞帖子 -->
<template v-if="action.action_type === 'LIKE_POST'">
<div class="like-info">
<svg class="icon-small filled" viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path></svg>
<span class="like-label">Liked @{{ action.action_args?.post_author_name || 'User' }}'s post</span>
</div>
<div v-if="action.action_args?.post_content" class="liked-content">
"{{ truncateContent(action.action_args.post_content, 120) }}"
</div>
</template>
<!-- CREATE_COMMENT: 发表评论 -->
<template v-if="action.action_type === 'CREATE_COMMENT'">
<div v-if="action.action_args?.content" class="content-text">
{{ action.action_args.content }}
</div>
<div v-if="action.action_args?.post_id" class="comment-context">
<svg class="icon-small" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
<span>Reply to post #{{ action.action_args.post_id }}</span>
</div>
</template>
<!-- SEARCH_POSTS: 搜索帖子 -->
<template v-if="action.action_type === 'SEARCH_POSTS'">
<div class="search-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<span class="search-label">Search Query:</span>
<span class="search-query">"{{ action.action_args?.query || '' }}"</span>
</div>
</template>
<!-- FOLLOW: 关注用户 -->
<template v-if="action.action_type === 'FOLLOW'">
<div class="follow-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><line x1="20" y1="8" x2="20" y2="14"></line><line x1="23" y1="11" x2="17" y2="11"></line></svg>
<span class="follow-label">Followed @{{ action.action_args?.target_user || action.action_args?.user_id || 'User' }}</span>
</div>
</template>
<!-- UPVOTE / DOWNVOTE -->
<template v-if="action.action_type === 'UPVOTE_POST' || action.action_type === 'DOWNVOTE_POST'">
<div class="vote-info">
<svg v-if="action.action_type === 'UPVOTE_POST'" class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>
<svg v-else class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>
<span class="vote-label">{{ action.action_type === 'UPVOTE_POST' ? 'Upvoted' : 'Downvoted' }} Post</span>
</div>
<div v-if="action.action_args?.post_content" class="voted-content">
"{{ truncateContent(action.action_args.post_content, 120) }}"
</div>
</template>
<!-- DO_NOTHING: 无操作(静默) -->
<template v-if="action.action_type === 'DO_NOTHING'">
<div class="idle-info">
<svg class="icon-small" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
<span class="idle-label">Action Skipped</span>
</div>
</template>
<!-- 通用回退:未知类型或有 content 但未被上述处理 -->
<div v-if="!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content" class="content-text">
{{ action.action_args.content }}
</div>
</div>
<div class="card-footer">
<span class="time-tag">R{{ action.round_num }}{{ formatActionTime(action.timestamp) }}</span>
<!-- Platform tag removed as it is in header now -->
</div>
</div>
</div>
</TransitionGroup>
<div v-if="allActions.length === 0" class="waiting-state">
<div class="pulse-ring"></div>
<span>Waiting for agent actions...</span>
</div>
</div>
</div>
<!-- Bottom Info / Logs -->
<div class="system-logs">
<div class="log-header">
<span class="log-title">SIMULATION MONITOR</span>
<span class="log-id">{{ simulationId || 'NO_SIMULATION' }}</span>
</div>
<div class="log-content" ref="logContent">
<div class="log-line" v-for="(log, idx) in systemLogs" :key="idx">
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.msg }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import {
startSimulation,
stopSimulation,
getRunStatus,
getRunStatusDetail
} from '../api/simulation'
import { generateReport } from '../api/report'
const props = defineProps({
simulationId: String,
maxRounds: Number, // 从Step2传入的最大轮数
minutesPerRound: {
type: Number,
default: 30 // 默认每轮30分钟
},
projectData: Object,
graphData: Object,
systemLogs: Array
})
const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])
const router = useRouter()
// State
const isGeneratingReport = ref(false)
const phase = ref(0) // 0: 未开始, 1: 运行中, 2: 已完成
const isStarting = ref(false)
const isStopping = ref(false)
const startError = ref(null)
const runStatus = ref({})
const allActions = ref([]) // 所有动作(增量累积)
const actionIds = ref(new Set()) // 用于去重的动作ID集合
const scrollContainer = ref(null)
// Computed
// 按时间顺序显示动作(最新的在最后面,即底部)
const chronologicalActions = computed(() => {
return allActions.value
})
// 各平台动作计数
const twitterActionsCount = computed(() => {
return allActions.value.filter(a => a.platform === 'twitter').length
})
const redditActionsCount = computed(() => {
return allActions.value.filter(a => a.platform === 'reddit').length
})
// 格式化模拟流逝时间(根据轮次和每轮分钟数计算)
const formatElapsedTime = (currentRound) => {
if (!currentRound || currentRound <= 0) return '0h 0m'
const totalMinutes = currentRound * props.minutesPerRound
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return `${hours}h ${minutes}m`
}
// Twitter平台的模拟流逝时间
const twitterElapsedTime = computed(() => {
return formatElapsedTime(runStatus.value.twitter_current_round || 0)
})
// Reddit平台的模拟流逝时间
const redditElapsedTime = computed(() => {
return formatElapsedTime(runStatus.value.reddit_current_round || 0)
})
// Methods
const addLog = (msg) => {
emit('add-log', msg)
}
// 重置所有状态(用于重新启动模拟)
const resetAllState = () => {
phase.value = 0
runStatus.value = {}
allActions.value = []
actionIds.value = new Set()
prevTwitterRound.value = 0
prevRedditRound.value = 0
startError.value = null
isStarting.value = false
isStopping.value = false
stopPolling() // 停止之前可能存在的轮询
}
// 启动模拟
const doStartSimulation = async () => {
if (!props.simulationId) {
addLog('错误:缺少 simulationId')
return
}
// 先重置所有状态,确保不会受到上一次模拟的影响
resetAllState()
isStarting.value = true
startError.value = null
addLog('正在启动双平台并行模拟...')
emit('update-status', 'processing')
try {
const params = {
simulation_id: props.simulationId,
platform: 'parallel',
force: true, // 强制重新开始
enable_graph_memory_update: true // 开启动态图谱更新
}
if (props.maxRounds) {
params.max_rounds = props.maxRounds
addLog(`设置最大模拟轮数: ${props.maxRounds}`)
}
addLog('已开启动态图谱更新模式')
const res = await startSimulation(params)
if (res.success && res.data) {
if (res.data.force_restarted) {
addLog('✓ 已清理旧的模拟日志,重新开始模拟')
}
addLog('✓ 模拟引擎启动成功')
addLog(` ├─ PID: ${res.data.process_pid || '-'}`)
phase.value = 1
runStatus.value = res.data
startStatusPolling()
startDetailPolling()
} else {
startError.value = res.error || '启动失败'
addLog(`✗ 启动失败: ${res.error || '未知错误'}`)
emit('update-status', 'error')
}
} catch (err) {
startError.value = err.message
addLog(`✗ 启动异常: ${err.message}`)
emit('update-status', 'error')
} finally {
isStarting.value = false
}
}
// 停止模拟
const handleStopSimulation = async () => {
if (!props.simulationId) return
isStopping.value = true
addLog('正在停止模拟...')
try {
const res = await stopSimulation({ simulation_id: props.simulationId })
if (res.success) {
addLog('✓ 模拟已停止')
phase.value = 2
stopPolling()
emit('update-status', 'completed')
} else {
addLog(`停止失败: ${res.error || '未知错误'}`)
}
} catch (err) {
addLog(`停止异常: ${err.message}`)
} finally {
isStopping.value = false
}
}
// 轮询状态
let statusTimer = null
let detailTimer = null
const startStatusPolling = () => {
statusTimer = setInterval(fetchRunStatus, 2000)
}
const startDetailPolling = () => {
detailTimer = setInterval(fetchRunStatusDetail, 3000)
}
const stopPolling = () => {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
if (detailTimer) {
clearInterval(detailTimer)
detailTimer = null
}
}
// 追踪各平台的上一次轮次,用于检测变化并输出日志
const prevTwitterRound = ref(0)
const prevRedditRound = ref(0)
const fetchRunStatus = async () => {
if (!props.simulationId) return
try {
const res = await getRunStatus(props.simulationId)
if (res.success && res.data) {
const data = res.data
runStatus.value = data
// 分别检测各平台的轮次变化并输出日志
if (data.twitter_current_round > prevTwitterRound.value) {
addLog(`[Plaza] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`)
prevTwitterRound.value = data.twitter_current_round
}
if (data.reddit_current_round > prevRedditRound.value) {
addLog(`[Community] R${data.reddit_current_round}/${data.total_rounds} | T:${data.reddit_simulated_hours || 0}h | A:${data.reddit_actions_count}`)
prevRedditRound.value = data.reddit_current_round
}
// 检测模拟是否已完成(通过 runner_status 或平台完成状态判断)
const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped'
// 额外检查:如果后端还没来得及更新 runner_status,但平台已经报告完成
// 通过检测 twitter_completed 和 reddit_completed 状态判断
const platformsCompleted = checkPlatformsCompleted(data)
if (isCompleted || platformsCompleted) {
if (platformsCompleted && !isCompleted) {
addLog('✓ 检测到所有平台模拟已结束')
}
addLog('✓ 模拟已完成')
phase.value = 2
stopPolling()
emit('update-status', 'completed')
}
}
} catch (err) {
console.warn('获取运行状态失败:', err)
}
}
// 检查所有启用的平台是否已完成
const checkPlatformsCompleted = (data) => {
// 如果没有任何平台数据,返回 false
if (!data) return false
// 检查各平台的完成状态
const twitterCompleted = data.twitter_completed === true
const redditCompleted = data.reddit_completed === true
// 如果至少有一个平台完成了,检查是否所有启用的平台都完成了
// 通过 actions_count 判断平台是否被启用(如果 count > 0 或 running 曾为 true)
const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted
const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted
// 如果没有任何平台被启用,返回 false
if (!twitterEnabled && !redditEnabled) return false
// 检查所有启用的平台是否都已完成
if (twitterEnabled && !twitterCompleted) return false
if (redditEnabled && !redditCompleted) return false
return true
}
const fetchRunStatusDetail = async () => {
if (!props.simulationId) return
try {
const res = await getRunStatusDetail(props.simulationId)
if (res.success && res.data) {
// 使用 all_actions 获取完整的动作列表
const serverActions = res.data.all_actions || []
// 增量添加新动作(去重)
let newActionsAdded = 0
serverActions.forEach(action => {
// 生成唯一ID
const actionId = action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}`
if (!actionIds.value.has(actionId)) {
actionIds.value.add(actionId)
allActions.value.push({
...action,
_uniqueId: actionId
})
newActionsAdded++
}
})
// 不自动滚动,让用户自由查看时间轴
// 新动作会在底部追加
}
} catch (err) {
console.warn('获取详细状态失败:', err)
}
}
// Helpers
const getActionTypeLabel = (type) => {
const labels = {
'CREATE_POST': 'POST',
'REPOST': 'REPOST',
'LIKE_POST': 'LIKE',
'CREATE_COMMENT': 'COMMENT',
'LIKE_COMMENT': 'LIKE',
'DO_NOTHING': 'IDLE',
'FOLLOW': 'FOLLOW',
'SEARCH_POSTS': 'SEARCH',
'QUOTE_POST': 'QUOTE',
'UPVOTE_POST': 'UPVOTE',
'DOWNVOTE_POST': 'DOWNVOTE'
}
return labels[type] || type || 'UNKNOWN'
}
const getActionTypeClass = (type) => {
const classes = {
'CREATE_POST': 'badge-post',
'REPOST': 'badge-action',
'LIKE_POST': 'badge-action',
'CREATE_COMMENT': 'badge-comment',
'LIKE_COMMENT': 'badge-action',
'QUOTE_POST': 'badge-post',
'FOLLOW': 'badge-meta',
'SEARCH_POSTS': 'badge-meta',
'UPVOTE_POST': 'badge-action',
'DOWNVOTE_POST': 'badge-action',
'DO_NOTHING': 'badge-idle'
}
return classes[type] || 'badge-default'
}
const truncateContent = (content, maxLength = 100) => {
if (!content) return ''
if (content.length > maxLength) return content.substring(0, maxLength) + '...'
return content
}
const formatActionTime = (timestamp) => {
if (!timestamp) return ''
try {
return new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ''
}
}
const handleNextStep = async () => {
if (!props.simulationId) {
addLog('错误:缺少 simulationId')
return
}
if (isGeneratingReport.value) {
addLog('报告生成请求已发送,请稍候...')
return
}
isGeneratingReport.value = true
addLog('正在启动报告生成...')
try {
const res = await generateReport({
simulation_id: props.simulationId,
force_regenerate: true
})
if (res.success && res.data) {
const reportId = res.data.report_id
addLog(`✓ 报告生成任务已启动: ${reportId}`)
// 跳转到报告页面
router.push({ name: 'Report', params: { reportId } })
} else {
addLog(`✗ 启动报告生成失败: ${res.error || '未知错误'}`)
isGeneratingReport.value = false
}
} catch (err) {
addLog(`✗ 启动报告生成异常: ${err.message}`)
isGeneratingReport.value = false
}
}
// Scroll log to bottom
const logContent = ref(null)
watch(() => props.systemLogs?.length, () => {
nextTick(() => {
if (logContent.value) {
logContent.value.scrollTop = logContent.value.scrollHeight
}
})
})
onMounted(() => {
addLog('Step3 模拟运行初始化')
if (props.simulationId) {
doStartSimulation()
}
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.simulation-panel {
height: 100%;
display: flex;
flex-direction: column;
background: #FFFFFF;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
overflow: hidden;
}
/* --- Control Bar --- */
.control-bar {
background: #FFF;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #EAEAEA;
z-index: 10;
height: 64px;
}
.status-group {
display: flex;
gap: 12px;
}
/* Platform Status Cards */
.platform-status {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 12px;
border-radius: 4px;
background: #FAFAFA;
border: 1px solid #EAEAEA;
opacity: 0.7;
transition: all 0.3s;
min-width: 140px;
position: relative;
cursor: pointer;
}
.platform-status.active {
opacity: 1;
border-color: #333;
background: #FFF;
}
.platform-status.completed {
opacity: 1;
border-color: #1A936F;
background: #F2FAF6;
}
/* Actions Tooltip */
.actions-tooltip {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
padding: 10px 14px;
background: #000;
color: #FFF;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 100;
min-width: 180px;
pointer-events: none;
}
.actions-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #000;
}
.platform-status:hover .actions-tooltip {
opacity: 1;
visibility: visible;
}
.tooltip-title {
font-size: 10px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 8px;
}
.tooltip-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tooltip-action {
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
color: #FFF;
letter-spacing: 0.03em;
}
.platform-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.platform-name {
font-size: 11px;
font-weight: 700;
color: #000;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.platform-status.twitter .platform-icon { color: #000; }
.platform-status.reddit .platform-icon { color: #000; }
.platform-stats {
display: flex;
gap: 10px;
}
.stat {
display: flex;
align-items: baseline;
gap: 3px;
}
.stat-label {
font-size: 8px;
color: #999;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 11px;
font-weight: 600;
color: #333;
}
.stat-total, .stat-unit {
font-size: 9px;
color: #999;
font-weight: 400;
}
.status-badge {
margin-left: auto;
color: #1A936F;
display: flex;
align-items: center;
}
/* Action Button */
.action-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-btn.primary {
background: #000;
color: #FFF;
}
.action-btn.primary:hover:not(:disabled) {
background: #333;
}
.action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* --- Main Content Area --- */
.main-content-area {
flex: 1;
overflow-y: auto;
position: relative;
background: #FFF;
}
/* Timeline Header */
.timeline-header {
position: sticky;
top: 0;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
padding: 12px 24px;
border-bottom: 1px solid #EAEAEA;
z-index: 5;
display: flex;
justify-content: center;
}
.timeline-stats {
display: flex;
align-items: center;
gap: 16px;
font-size: 11px;
color: #666;
background: #F5F5F5;
padding: 4px 12px;
border-radius: 20px;
}
.total-count {
font-weight: 600;
color: #333;
}
.platform-breakdown {
display: flex;
align-items: center;
gap: 8px;
}
.breakdown-item {
display: flex;
align-items: center;
gap: 4px;
}
.breakdown-divider { color: #DDD; }
.breakdown-item.twitter { color: #000; }
.breakdown-item.reddit { color: #000; }
/* --- Timeline Feed --- */
.timeline-feed {
padding: 24px 0;
position: relative;
min-height: 100%;
max-width: 900px;
margin: 0 auto;
}
.timeline-axis {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: #EAEAEA; /* Cleaner line */
transform: translateX(-50%);
}
.timeline-item {
display: flex;
justify-content: center;
margin-bottom: 32px;
position: relative;
width: 100%;
}
.timeline-marker {
position: absolute;
left: 50%;
top: 24px;
width: 10px;
height: 10px;
background: #FFF;
border: 1px solid #CCC;
border-radius: 50%;
transform: translateX(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
}
.marker-dot {
width: 4px;
height: 4px;
background: #CCC;
border-radius: 50%;
}
.timeline-item.twitter .marker-dot { background: #000; }
.timeline-item.reddit .marker-dot { background: #000; }
.timeline-item.twitter .timeline-marker { border-color: #000; }
.timeline-item.reddit .timeline-marker { border-color: #000; }
/* Card Layout */
.timeline-card {
width: calc(100% - 48px);
background: #FFF;
border-radius: 2px;
padding: 16px 20px;
border: 1px solid #EAEAEA;
box-shadow: 0 2px 10px rgba(0,0,0,0.02);
position: relative;
transition: all 0.2s;
}
.timeline-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
border-color: #DDD;
}
/* Left side (Twitter) */
.timeline-item.twitter {
justify-content: flex-start;
padding-right: 50%;
}
.timeline-item.twitter .timeline-card {
margin-left: auto;
margin-right: 32px; /* Gap from axis */
}
/* Right side (Reddit) */
.timeline-item.reddit {
justify-content: flex-end;
padding-left: 50%;
}
.timeline-item.reddit .timeline-card {
margin-right: auto;
margin-left: 32px; /* Gap from axis */
}
/* Card Content Styles */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #F5F5F5;
}
.agent-info {
display: flex;
align-items: center;
gap: 10px;
}
.avatar-placeholder {
width: 24px;
height: 24px;
background: #000;
color: #FFF;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.agent-name {
font-size: 13px;
font-weight: 600;
color: #000;
}
.header-meta {
display: flex;
align-items: center;
gap: 8px;
}
.platform-indicator {
color: #999;
display: flex;
align-items: center;
}
.action-badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 2px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
/* Monochromatic Badges */
.badge-post { background: #F0F0F0; color: #333; border-color: #E0E0E0; }
.badge-comment { background: #F0F0F0; color: #666; border-color: #E0E0E0; }
.badge-action { background: #FFF; color: #666; border: 1px solid #E0E0E0; }
.badge-meta { background: #FAFAFA; color: #999; border: 1px dashed #DDD; }
.badge-idle { opacity: 0.5; }
.content-text {
font-size: 13px;
line-height: 1.6;
color: #333;
margin-bottom: 10px;
}
.content-text.main-text {
font-size: 14px;
color: #000;
}
/* Info Blocks (Quote, Repost, etc) */
.quoted-block, .repost-content {
background: #F9F9F9;
border: 1px solid #EEE;
padding: 10px 12px;
border-radius: 2px;
margin-top: 8px;
font-size: 12px;
color: #555;
}
.quote-header, .repost-info, .like-info, .search-info, .follow-info, .vote-info, .idle-info, .comment-context {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 11px;
color: #666;
}
.icon-small {
color: #999;
}
.icon-small.filled {
color: #999; /* Keep icons neutral unless highlighted */
}
.search-query {
font-family: 'JetBrains Mono', monospace;
background: #F0F0F0;
padding: 0 4px;
border-radius: 2px;
}
.card-footer {
margin-top: 12px;
display: flex;
justify-content: flex-end;
font-size: 10px;
color: #BBB;
font-family: 'JetBrains Mono', monospace;
}
/* Waiting State */
.waiting-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #CCC;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.pulse-ring {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid #EAEAEA;
animation: ripple 2s infinite;
}
@keyframes ripple {
0% { transform: scale(0.8); opacity: 1; border-color: #CCC; }
100% { transform: scale(2.5); opacity: 0; border-color: #EAEAEA; }
}
/* Animation */
.timeline-item-enter-active,
.timeline-item-leave-active {
transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.timeline-item-enter-from {
opacity: 0;
transform: translateY(20px);
}
.timeline-item-leave-to {
opacity: 0;
}
/* Logs */
.system-logs {
background: #000;
color: #DDD;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
border-top: 1px solid #222;
flex-shrink: 0;
}
.log-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #333;
padding-bottom: 8px;
margin-bottom: 8px;
font-size: 10px;
color: #666;
}
.log-content {
display: flex;
flex-direction: column;
gap: 4px;
height: 100px;
overflow-y: auto;
padding-right: 4px;
}
.log-content::-webkit-scrollbar { width: 4px; }
.log-content::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
.log-line {
font-size: 11px;
display: flex;
gap: 12px;
line-height: 1.5;
}
.log-time { color: #555; min-width: 75px; }
.log-msg { color: #BBB; word-break: break-all; }
.mono { font-family: 'JetBrains Mono', monospace; }
/* Loading spinner for button */
.loading-spinner-small {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #FFF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 6px;
}
</style>