| <template> |
| <div class="simulation-panel"> |
| |
| <div class="control-bar"> |
| <div class="status-group"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div class="main-content-area" ref="scrollContainer"> |
| |
| <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> |
| |
| |
| <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"> |
| |
| <div v-if="action.action_type === 'CREATE_POST' && action.action_args?.content" class="content-text main-text"> |
| {{ action.action_args.content }} |
| </div> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| </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> |
| |
| |
| <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, |
| minutesPerRound: { |
| type: Number, |
| default: 30 |
| }, |
| projectData: Object, |
| graphData: Object, |
| systemLogs: Array |
| }) |
| |
| const emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status']) |
| |
| const router = useRouter() |
| |
| |
| const isGeneratingReport = ref(false) |
| const phase = ref(0) |
| const isStarting = ref(false) |
| const isStopping = ref(false) |
| const startError = ref(null) |
| const runStatus = ref({}) |
| const allActions = ref([]) |
| const actionIds = ref(new Set()) |
| const scrollContainer = ref(null) |
| |
| |
| |
| 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` |
| } |
| |
| |
| const twitterElapsedTime = computed(() => { |
| return formatElapsedTime(runStatus.value.twitter_current_round || 0) |
| }) |
| |
| |
| const redditElapsedTime = computed(() => { |
| return formatElapsedTime(runStatus.value.reddit_current_round || 0) |
| }) |
| |
| |
| 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 |
| } |
| |
| |
| const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped' |
| |
| |
| |
| 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) => { |
| |
| if (!data) return false |
| |
| |
| const twitterCompleted = data.twitter_completed === true |
| const redditCompleted = data.reddit_completed === true |
| |
| |
| |
| const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted |
| const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted |
| |
| |
| 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) { |
| |
| const serverActions = res.data.all_actions || [] |
| |
| |
| let newActionsAdded = 0 |
| serverActions.forEach(action => { |
| |
| 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) |
| } |
| } |
| |
| |
| 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 |
| } |
| } |
| |
| |
| 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 { |
| 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 { |
| 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 { |
| 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-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 { |
| flex: 1; |
| overflow-y: auto; |
| position: relative; |
| background: #FFF; |
| } |
| |
| |
| .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 { |
| 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; |
| 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; } |
| |
| |
| .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; |
| } |
| |
| |
| .timeline-item.twitter { |
| justify-content: flex-start; |
| padding-right: 50%; |
| } |
| .timeline-item.twitter .timeline-card { |
| margin-left: auto; |
| margin-right: 32px; |
| } |
| |
| |
| .timeline-item.reddit { |
| justify-content: flex-end; |
| padding-left: 50%; |
| } |
| .timeline-item.reddit .timeline-card { |
| margin-right: auto; |
| margin-left: 32px; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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; |
| } |
| |
| .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 { |
| 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; } |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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-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> |