mirror of
https://github.com/666ghj/MiroFish.git
synced 2026-04-28 06:31:25 +00:00
1267 lines
No EOL
38 KiB
Vue
1267 lines
No EOL
38 KiB
Vue
<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">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">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 ? $t('step3.generatingReportBtn') : $t('step3.startGenerateReportBtn') }}
|
||
<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 { useI18n } from 'vue-i18n'
|
||
import {
|
||
startSimulation,
|
||
stopSimulation,
|
||
getRunStatus,
|
||
getRunStatusDetail
|
||
} from '../api/simulation'
|
||
import { generateReport } from '../api/report'
|
||
|
||
const { t } = useI18n()
|
||
|
||
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(t('log.errorMissingSimId'))
|
||
return
|
||
}
|
||
|
||
// 先重置所有状态,确保不会受到上一次模拟的影响
|
||
resetAllState()
|
||
|
||
isStarting.value = true
|
||
startError.value = null
|
||
addLog(t('log.startingDualSim'))
|
||
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(t('log.setMaxRounds', { rounds: props.maxRounds }))
|
||
}
|
||
|
||
addLog(t('log.graphMemoryUpdateEnabled'))
|
||
|
||
const res = await startSimulation(params)
|
||
|
||
if (res.success && res.data) {
|
||
if (res.data.force_restarted) {
|
||
addLog(t('log.oldSimCleared'))
|
||
}
|
||
addLog(t('log.engineStarted'))
|
||
addLog(` ├─ PID: ${res.data.process_pid || '-'}`)
|
||
|
||
phase.value = 1
|
||
runStatus.value = res.data
|
||
|
||
startStatusPolling()
|
||
startDetailPolling()
|
||
} else {
|
||
startError.value = res.error || '启动失败'
|
||
addLog(t('log.startFailed', { error: res.error || t('common.unknownError') }))
|
||
emit('update-status', 'error')
|
||
}
|
||
} catch (err) {
|
||
startError.value = err.message
|
||
addLog(t('log.startException', { error: err.message }))
|
||
emit('update-status', 'error')
|
||
} finally {
|
||
isStarting.value = false
|
||
}
|
||
}
|
||
|
||
// 停止模拟
|
||
const handleStopSimulation = async () => {
|
||
if (!props.simulationId) return
|
||
|
||
isStopping.value = true
|
||
addLog(t('log.stoppingSim'))
|
||
|
||
try {
|
||
const res = await stopSimulation({ simulation_id: props.simulationId })
|
||
|
||
if (res.success) {
|
||
addLog(t('log.simStoppedSuccess'))
|
||
phase.value = 2
|
||
stopPolling()
|
||
emit('update-status', 'completed')
|
||
} else {
|
||
addLog(t('log.stopFailed', { error: res.error || t('common.unknownError') }))
|
||
}
|
||
} catch (err) {
|
||
addLog(t('log.stopException', { error: 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(t('log.allPlatformsCompleted'))
|
||
}
|
||
addLog(t('log.simCompleted'))
|
||
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(t('log.errorMissingSimId'))
|
||
return
|
||
}
|
||
|
||
if (isGeneratingReport.value) {
|
||
addLog(t('log.reportRequestSent'))
|
||
return
|
||
}
|
||
|
||
isGeneratingReport.value = true
|
||
addLog(t('log.startingReportGen'))
|
||
|
||
try {
|
||
const res = await generateReport({
|
||
simulation_id: props.simulationId,
|
||
force_regenerate: true
|
||
})
|
||
|
||
if (res.success && res.data) {
|
||
const reportId = res.data.report_id
|
||
addLog(t('log.reportGenTaskStarted', { reportId }))
|
||
|
||
// 跳转到报告页面
|
||
router.push({ name: 'Report', params: { reportId } })
|
||
} else {
|
||
addLog(t('log.reportGenFailed', { error: res.error || t('common.unknownError') }))
|
||
isGeneratingReport.value = false
|
||
}
|
||
} catch (err) {
|
||
addLog(t('log.reportGenException', { error: 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(t('log.step3Init'))
|
||
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> |