Add real-time simulation configuration endpoint and update frontend components
- Introduced a new API endpoint for retrieving real-time simulation configuration, allowing users to view progress and metadata during simulation generation. - Updated frontend API service to include the new real-time configuration method. - Enhanced Step2EnvSetup.vue to support real-time polling for configuration updates, improving user experience during simulation setup. - Revised display logic to show detailed configuration summaries and orchestration content, enriching the simulation setup process.
This commit is contained in:
parent
ceb1399144
commit
8b5d082fb1
3 changed files with 390 additions and 20 deletions
|
|
@ -936,6 +936,126 @@ def get_simulation_profiles_realtime(simulation_id: str):
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@simulation_bp.route('/<simulation_id>/config/realtime', methods=['GET'])
|
||||||
|
def get_simulation_config_realtime(simulation_id: str):
|
||||||
|
"""
|
||||||
|
实时获取模拟配置(用于在生成过程中实时查看进度)
|
||||||
|
|
||||||
|
与 /config 接口的区别:
|
||||||
|
- 直接读取文件,不经过 SimulationManager
|
||||||
|
- 适用于生成过程中的实时查看
|
||||||
|
- 返回额外的元数据(如文件修改时间、是否正在生成等)
|
||||||
|
- 即使配置还没生成完也能返回部分信息
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"simulation_id": "sim_xxxx",
|
||||||
|
"file_exists": true,
|
||||||
|
"file_modified_at": "2025-12-04T18:20:00",
|
||||||
|
"is_generating": true, // 是否正在生成
|
||||||
|
"generation_stage": "generating_config", // 当前生成阶段
|
||||||
|
"config": {...} // 配置内容(如果存在)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取模拟目录
|
||||||
|
sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)
|
||||||
|
|
||||||
|
if not os.path.exists(sim_dir):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": f"模拟不存在: {simulation_id}"
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# 配置文件路径
|
||||||
|
config_file = os.path.join(sim_dir, "simulation_config.json")
|
||||||
|
|
||||||
|
# 检查文件是否存在
|
||||||
|
file_exists = os.path.exists(config_file)
|
||||||
|
config = None
|
||||||
|
file_modified_at = None
|
||||||
|
|
||||||
|
if file_exists:
|
||||||
|
# 获取文件修改时间
|
||||||
|
file_stat = os.stat(config_file)
|
||||||
|
file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
|
logger.warning(f"读取 config 文件失败(可能正在写入中): {e}")
|
||||||
|
config = None
|
||||||
|
|
||||||
|
# 检查是否正在生成(通过 state.json 判断)
|
||||||
|
is_generating = False
|
||||||
|
generation_stage = None
|
||||||
|
config_generated = False
|
||||||
|
|
||||||
|
state_file = os.path.join(sim_dir, "state.json")
|
||||||
|
if os.path.exists(state_file):
|
||||||
|
try:
|
||||||
|
with open(state_file, 'r', encoding='utf-8') as f:
|
||||||
|
state_data = json.load(f)
|
||||||
|
status = state_data.get("status", "")
|
||||||
|
is_generating = status == "preparing"
|
||||||
|
config_generated = state_data.get("config_generated", False)
|
||||||
|
|
||||||
|
# 判断当前阶段
|
||||||
|
if is_generating:
|
||||||
|
if state_data.get("profiles_generated", False):
|
||||||
|
generation_stage = "generating_config"
|
||||||
|
else:
|
||||||
|
generation_stage = "generating_profiles"
|
||||||
|
elif status == "ready":
|
||||||
|
generation_stage = "completed"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 构建返回数据
|
||||||
|
response_data = {
|
||||||
|
"simulation_id": simulation_id,
|
||||||
|
"file_exists": file_exists,
|
||||||
|
"file_modified_at": file_modified_at,
|
||||||
|
"is_generating": is_generating,
|
||||||
|
"generation_stage": generation_stage,
|
||||||
|
"config_generated": config_generated,
|
||||||
|
"config": config
|
||||||
|
}
|
||||||
|
|
||||||
|
# 如果配置存在,提取一些关键统计信息
|
||||||
|
if config:
|
||||||
|
response_data["summary"] = {
|
||||||
|
"total_agents": len(config.get("agent_configs", [])),
|
||||||
|
"simulation_hours": config.get("time_config", {}).get("total_simulation_hours"),
|
||||||
|
"initial_posts_count": len(config.get("event_config", {}).get("initial_posts", [])),
|
||||||
|
"hot_topics_count": len(config.get("event_config", {}).get("hot_topics", [])),
|
||||||
|
"has_twitter_config": "twitter_config" in config,
|
||||||
|
"has_reddit_config": "reddit_config" in config,
|
||||||
|
"generated_at": config.get("generated_at"),
|
||||||
|
"llm_model": config.get("llm_model")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": response_data
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"实时获取Config失败: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"traceback": traceback.format_exc()
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@simulation_bp.route('/<simulation_id>/config', methods=['GET'])
|
@simulation_bp.route('/<simulation_id>/config', methods=['GET'])
|
||||||
def get_simulation_config(simulation_id: str):
|
def get_simulation_config(simulation_id: str):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,15 @@ export const getSimulationConfig = (simulationId) => {
|
||||||
return service.get(`/api/simulation/${simulationId}/config`)
|
return service.get(`/api/simulation/${simulationId}/config`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实时获取生成中的模拟配置
|
||||||
|
* @param {string} simulationId
|
||||||
|
* @returns {Promise} 返回配置信息,包含元数据和配置内容
|
||||||
|
*/
|
||||||
|
export const getSimulationConfigRealtime = (simulationId) => {
|
||||||
|
return service.get(`/api/simulation/${simulationId}/config/realtime`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出所有模拟
|
* 列出所有模拟
|
||||||
* @param {string} projectId - 可选,按项目ID过滤
|
* @param {string} projectId - 可选,按项目ID过滤
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 03: 生成模拟配置 -->
|
<!-- Step 03: 生成双平台模拟配置 -->
|
||||||
<div class="step-card" :class="{ 'active': phase === 2, 'completed': phase > 2 }">
|
<div class="step-card" :class="{ 'active': phase === 2, 'completed': phase > 2 }">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="step-info">
|
<div class="step-info">
|
||||||
|
|
@ -137,38 +137,98 @@
|
||||||
<div v-if="simulationConfig" class="config-preview">
|
<div v-if="simulationConfig" class="config-preview">
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<span class="config-label">模拟时长</span>
|
<span class="config-label">模拟时长</span>
|
||||||
<span class="config-value">{{ simulationConfig.time_config?.simulation_hours || '-' }} 小时</span>
|
<span class="config-value">{{ simulationConfig.time_config?.total_simulation_hours || '-' }} 小时</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<span class="config-label">模拟轮次</span>
|
<span class="config-label">总轮次</span>
|
||||||
<span class="config-value">{{ simulationConfig.time_config?.max_rounds || '-' }} 轮</span>
|
<span class="config-value">{{ (simulationConfig.time_config?.total_simulation_hours * 60 / simulationConfig.time_config?.minutes_per_round) || '-' }} 轮</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<span class="config-label">平台</span>
|
<span class="config-label">平台配置</span>
|
||||||
<span class="config-value">
|
<span class="config-value">
|
||||||
<span v-if="simulationConfig.platform_configs?.twitter" class="platform-tag">Twitter</span>
|
<span v-if="simulationConfig.twitter_config" class="platform-tag">Twitter</span>
|
||||||
<span v-if="simulationConfig.platform_configs?.reddit" class="platform-tag">Reddit</span>
|
<span v-if="simulationConfig.reddit_config" class="platform-tag">Reddit</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LLM Reasoning -->
|
<!-- LLM Reasoning -->
|
||||||
<div v-if="simulationConfig.generation_reasoning" class="reasoning-section">
|
<div v-if="simulationConfig.generation_reasoning" class="reasoning-section">
|
||||||
<span class="reasoning-label">LLM 配置推理</span>
|
<span class="reasoning-label">LLM 配置推理</span>
|
||||||
<p class="reasoning-text">{{ simulationConfig.generation_reasoning }}</p>
|
<p class="reasoning-text">{{ simulationConfig.generation_reasoning.split('|')[0] }} ...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 04: 准备完成 -->
|
<!-- Step 04: 初始激活编排 -->
|
||||||
<div class="step-card" :class="{ 'active': phase === 3 }">
|
<div class="step-card" :class="{ 'active': phase === 3, 'completed': phase > 3 }">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="step-info">
|
<div class="step-info">
|
||||||
<span class="step-num">04</span>
|
<span class="step-num">04</span>
|
||||||
|
<span class="step-title">初始激活编排</span>
|
||||||
|
</div>
|
||||||
|
<div class="step-status">
|
||||||
|
<span v-if="phase > 3" class="badge success">已完成</span>
|
||||||
|
<span v-else-if="phase === 3" class="badge processing">编排中</span>
|
||||||
|
<span v-else class="badge pending">等待</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<p class="api-note">Event Orchestration</p>
|
||||||
|
<p class="description">
|
||||||
|
基于叙事方向,自动生成初始激活事件与热点话题,引导模拟世界的初始状态
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="simulationConfig?.event_config" class="orchestration-content">
|
||||||
|
<!-- 叙事方向 -->
|
||||||
|
<div class="narrative-box">
|
||||||
|
<span class="box-label">叙事引导方向</span>
|
||||||
|
<p class="narrative-text">{{ simulationConfig.event_config.narrative_direction }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热点话题 -->
|
||||||
|
<div class="topics-section">
|
||||||
|
<span class="box-label">初始热点话题</span>
|
||||||
|
<div class="hot-topics-grid">
|
||||||
|
<span v-for="topic in simulationConfig.event_config.hot_topics.slice(0, 8)" :key="topic" class="hot-topic-tag">
|
||||||
|
# {{ topic }}
|
||||||
|
</span>
|
||||||
|
<span v-if="simulationConfig.event_config.hot_topics.length > 8" class="hot-topic-more">
|
||||||
|
+{{ simulationConfig.event_config.hot_topics.length - 8 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 初始帖子流 -->
|
||||||
|
<div class="initial-posts-section">
|
||||||
|
<span class="box-label">初始激活序列 ({{ simulationConfig.event_config.initial_posts.length }})</span>
|
||||||
|
<div class="posts-timeline">
|
||||||
|
<div v-for="(post, idx) in simulationConfig.event_config.initial_posts" :key="idx" class="timeline-item">
|
||||||
|
<div class="timeline-marker"></div>
|
||||||
|
<div class="timeline-content">
|
||||||
|
<div class="post-header">
|
||||||
|
<span class="post-role">{{ post.poster_type }}</span>
|
||||||
|
<span class="post-id">Agent {{ post.poster_agent_id }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="post-text">{{ post.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 05: 准备完成 -->
|
||||||
|
<div class="step-card" :class="{ 'active': phase === 4 }">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="step-info">
|
||||||
|
<span class="step-num">05</span>
|
||||||
<span class="step-title">准备完成</span>
|
<span class="step-title">准备完成</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-status">
|
<div class="step-status">
|
||||||
<span v-if="phase >= 3" class="badge processing">进行中</span>
|
<span v-if="phase >= 4" class="badge processing">进行中</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -183,7 +243,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="action-btn primary"
|
class="action-btn primary"
|
||||||
:disabled="phase < 3"
|
:disabled="phase < 4"
|
||||||
@click="$emit('next-step')"
|
@click="$emit('next-step')"
|
||||||
>
|
>
|
||||||
开始模拟 ➝
|
开始模拟 ➝
|
||||||
|
|
@ -300,7 +360,8 @@ import {
|
||||||
prepareSimulation,
|
prepareSimulation,
|
||||||
getPrepareStatus,
|
getPrepareStatus,
|
||||||
getSimulationProfilesRealtime,
|
getSimulationProfilesRealtime,
|
||||||
getSimulationConfig
|
getSimulationConfig,
|
||||||
|
getSimulationConfigRealtime
|
||||||
} from '../api/simulation'
|
} from '../api/simulation'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -331,6 +392,11 @@ watch(currentStage, (newStage) => {
|
||||||
phase.value = 1
|
phase.value = 1
|
||||||
} else if (newStage === '生成模拟配置' || newStage === 'generating_config') {
|
} else if (newStage === '生成模拟配置' || newStage === 'generating_config') {
|
||||||
phase.value = 2
|
phase.value = 2
|
||||||
|
// 进入配置生成阶段,开始轮询配置
|
||||||
|
if (!configTimer) {
|
||||||
|
addLog('开始生成双平台模拟配置...')
|
||||||
|
startConfigPolling()
|
||||||
|
}
|
||||||
} else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {
|
} else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {
|
||||||
phase.value = 2 // 仍属于配置阶段
|
phase.value = 2 // 仍属于配置阶段
|
||||||
}
|
}
|
||||||
|
|
@ -339,6 +405,7 @@ watch(currentStage, (newStage) => {
|
||||||
// Polling timer
|
// Polling timer
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
let profilesTimer = null
|
let profilesTimer = null
|
||||||
|
let configTimer = null
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const displayProfiles = computed(() => {
|
const displayProfiles = computed(() => {
|
||||||
|
|
@ -504,6 +571,48 @@ const fetchProfilesRealtime = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 配置轮询
|
||||||
|
const startConfigPolling = () => {
|
||||||
|
configTimer = setInterval(fetchConfigRealtime, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopConfigPolling = () => {
|
||||||
|
if (configTimer) {
|
||||||
|
clearInterval(configTimer)
|
||||||
|
configTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConfigRealtime = async () => {
|
||||||
|
if (!props.simulationId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getSimulationConfigRealtime(props.simulationId)
|
||||||
|
|
||||||
|
if (res.success && res.data) {
|
||||||
|
const data = res.data
|
||||||
|
|
||||||
|
// 如果配置已生成
|
||||||
|
if (data.config_generated && data.config) {
|
||||||
|
simulationConfig.value = data.config
|
||||||
|
addLog('模拟配置生成完成')
|
||||||
|
|
||||||
|
// 显示配置摘要
|
||||||
|
if (data.summary) {
|
||||||
|
addLog(`配置摘要: ${data.summary.total_agents}个Agent, ${data.summary.simulation_hours}小时, ${data.summary.initial_posts_count}条初始帖子`)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopConfigPolling()
|
||||||
|
phase.value = 4
|
||||||
|
addLog('环境搭建完成,可以开始模拟')
|
||||||
|
emit('update-status', 'completed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('获取 Config 失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadPreparedData = async () => {
|
const loadPreparedData = async () => {
|
||||||
phase.value = 2
|
phase.value = 2
|
||||||
addLog('正在加载配置数据...')
|
addLog('正在加载配置数据...')
|
||||||
|
|
@ -511,15 +620,27 @@ const loadPreparedData = async () => {
|
||||||
// 最后获取一次 Profiles
|
// 最后获取一次 Profiles
|
||||||
await fetchProfilesRealtime()
|
await fetchProfilesRealtime()
|
||||||
|
|
||||||
// 获取配置
|
// 获取配置(使用实时接口)
|
||||||
try {
|
try {
|
||||||
const res = await getSimulationConfig(props.simulationId)
|
const res = await getSimulationConfigRealtime(props.simulationId)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
simulationConfig.value = res.data
|
if (res.data.config_generated && res.data.config) {
|
||||||
|
simulationConfig.value = res.data.config
|
||||||
addLog('模拟配置加载成功')
|
addLog('模拟配置加载成功')
|
||||||
|
|
||||||
|
// 显示配置摘要
|
||||||
|
if (res.data.summary) {
|
||||||
|
addLog(`配置摘要: ${res.data.summary.total_agents}个Agent, ${res.data.summary.simulation_hours}小时`)
|
||||||
|
}
|
||||||
|
|
||||||
addLog('环境搭建完成,可以开始模拟')
|
addLog('环境搭建完成,可以开始模拟')
|
||||||
phase.value = 3
|
phase.value = 4
|
||||||
emit('update-status', 'completed')
|
emit('update-status', 'completed')
|
||||||
|
} else {
|
||||||
|
// 配置尚未生成,开始轮询
|
||||||
|
addLog('配置生成中,等待完成...')
|
||||||
|
startConfigPolling()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addLog(`加载配置失败: ${err.message}`)
|
addLog(`加载配置失败: ${err.message}`)
|
||||||
|
|
@ -548,6 +669,7 @@ onMounted(() => {
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
stopProfilesPolling()
|
stopProfilesPolling()
|
||||||
|
stopConfigPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -1254,5 +1376,124 @@ onUnmounted(() => {
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
/* Orchestration Content */
|
||||||
|
.orchestration-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative-box {
|
||||||
|
background: #F9F9F9;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #FF5722;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topics-section {
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-topics-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-topic-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #FF5722;
|
||||||
|
background: #FFF3E0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-topic-more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.initial-posts-section {
|
||||||
|
border-top: 1px solid #EAEAEA;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 2px solid #F0F0F0;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marker {
|
||||||
|
position: absolute;
|
||||||
|
left: -5px;
|
||||||
|
top: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #CCC;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: #F9F9F9;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-role {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-id {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue