2885 lines
86 KiB
Vue
2885 lines
86 KiB
Vue
<template>
|
||
<div class="command-center">
|
||
<div v-if="showScanEffect" class="scan-overlay"></div>
|
||
<!-- Top Status Bar -->
|
||
<div class="status-bar glass-panel">
|
||
<div class="status-item">
|
||
<n-text class="status-label">极速响应率</n-text>
|
||
<n-text class="status-value highlight-green">98.5%</n-text>
|
||
</div>
|
||
<div class="status-item" style="width: 280px; padding-right: 24px;">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||
<n-text class="status-label">竞争时差 (执行进度 vs 竞品)</n-text>
|
||
<div class="window-countdown" v-if="selectedSignal && currentStrategicInfo.is_strategic">
|
||
<span class="countdown-icon">⏳</span>
|
||
<span class="countdown-val">{{ leadWindow }}</span>
|
||
</div>
|
||
<n-button size="tiny" circle ghost @click="showCompetitorModal = true" v-else>
|
||
<template #icon>⚙️</template>
|
||
</n-button>
|
||
</div>
|
||
<div class="progress-comparison">
|
||
<div class="progress-row">
|
||
<span class="label">我方</span>
|
||
<n-progress type="line" :percentage="getOurProgress(selectedSignal)" color="#63e2b7" rail-color="rgba(255,255,255,0.1)" height="6" processing />
|
||
</div>
|
||
<div class="progress-row">
|
||
<span class="label">竞品</span>
|
||
<n-progress type="line" :percentage="getCompetitorProgress(selectedSignal)" color="#f5222d" rail-color="rgba(255,255,255,0.1)" height="6" />
|
||
</div>
|
||
<div class="lead-time-text" v-if="selectedSignal">
|
||
{{ getLeadTimeText(selectedSignal) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="status-item compute-load-item">
|
||
<n-text class="status-label">AI 算力负载</n-text>
|
||
<div class="compute-load-display">
|
||
<n-text class="status-value" :class="{ 'reserved': isReservingCompute }">{{ computeLoadPercent }}%</n-text>
|
||
<div v-if="isReservingCompute" class="reserved-badge">
|
||
<span class="reserved-dot"></span>
|
||
<span class="reserved-text">预占 {{ reservedComputePercent }}%</span>
|
||
</div>
|
||
</div>
|
||
<div class="compute-bar">
|
||
<div class="compute-bar-fill current" :style="{ width: computeLoadPercent + '%' }"></div>
|
||
<div v-if="isReservingCompute" class="compute-bar-fill reserved" :style="{ width: reservedComputePercent + '%' }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI 分析控制 -->
|
||
<div class="status-item" style="min-width: 200px;">
|
||
<n-text class="status-label">AI 信号分析</n-text>
|
||
<div v-if="analyzing" style="margin-top: 4px;">
|
||
<n-progress type="line" :percentage="analysisProgress" :indicator-placement="'inside'" processing />
|
||
<n-text depth="3" style="font-size: 11px;">{{ analysisStatus }}</n-text>
|
||
</div>
|
||
<n-space v-else :size="8">
|
||
<n-button size="small" :type="ollamaReady ? 'success' : 'warning'" @click="startAnalysis">
|
||
<template #icon>{{ ollamaReady ? '🤖' : '⚠️' }}</template>
|
||
{{ ollamaReady ? '启动分析' : '启动分析 (未检测到 Ollama)' }}
|
||
</n-button>
|
||
<n-button size="small" @click="checkOllamaStatus" quaternary circle>
|
||
<template #icon>🔄</template>
|
||
</n-button>
|
||
</n-space>
|
||
</div>
|
||
|
||
<div class="status-item">
|
||
<n-button size="small" type="primary" @click="showModal = true" ghost>⚡ 模拟信号注入</n-button>
|
||
</div>
|
||
</div>
|
||
|
||
<n-layout has-sider style="height: calc(100vh - 100px); background: transparent;">
|
||
<!-- Left: Intelligence Radar -->
|
||
<n-layout-sider bordered width="350px" content-style="padding: 16px;" class="glass-sider">
|
||
<n-space vertical>
|
||
<div class="panel-header">
|
||
<n-icon size="20" color="#63e2b7">
|
||
<RadarIcon />
|
||
</n-icon>
|
||
<n-text strong style="font-size: 16px; margin-left: 8px;">情报雷达站</n-text>
|
||
<n-spin v-if="loading" size="small" style="margin-left: auto;" />
|
||
</div>
|
||
|
||
<!-- Priority Legend -->
|
||
<div class="priority-legend">
|
||
<n-text depth="3" style="font-size: 11px;">优先级:</n-text>
|
||
<n-tag size="tiny" type="error" round>紧急</n-tag>
|
||
<n-tag size="tiny" type="warning" round>高优</n-tag>
|
||
<n-tag size="tiny" type="info" round>中等</n-tag>
|
||
<n-tag size="tiny" type="default" round>低优</n-tag>
|
||
</div>
|
||
|
||
<n-scrollbar style="max-height: calc(100vh - 200px)">
|
||
<n-list hoverable clickable class="signal-list">
|
||
<n-list-item
|
||
v-for="signal in aggregatedSignals"
|
||
:key="signal.id"
|
||
@click="selectSignal(signal.latestSignal)"
|
||
:class="{
|
||
'selected-item': selectedSignal?.id === signal.id,
|
||
'new-signal-flash': isNewSignal(signal.latestSignal)
|
||
}"
|
||
>
|
||
<template #header>
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<n-tag :type="getPriorityType(signal.latestSignal.priority)" size="small" round>
|
||
{{ getPriorityLabel(signal.latestSignal.priority) }}
|
||
</n-tag>
|
||
<n-tag :type="getImpactColor(signal.impact_score)" size="small" round>{{ signal.source }}</n-tag>
|
||
<n-tag v-if="signal.count > 1" type="info" size="tiny" round>{{ signal.count }}条</n-tag>
|
||
</div>
|
||
<n-text depth="3" style="font-size: 12px;">{{ formatDate(signal.created_at) }}</n-text>
|
||
</div>
|
||
</template>
|
||
<n-text class="signal-content" :depth="1">{{ signal.content }}</n-text>
|
||
</n-list-item>
|
||
</n-list>
|
||
</n-scrollbar>
|
||
</n-space>
|
||
</n-layout-sider>
|
||
|
||
<!-- Center: AI Strategy Room -->
|
||
<n-layout-content content-style="padding: 16px;" class="glass-content">
|
||
<div v-if="!selectedSignal" class="empty-state">
|
||
<div class="radar-scan"></div>
|
||
<n-text depth="3" style="margin-top: 20px;">等待信号接入...</n-text>
|
||
</div>
|
||
|
||
<div v-else class="strategy-room">
|
||
<!-- Competitive Moat Section -->
|
||
<n-card class="glass-card moat-card" :bordered="false" size="small" style="margin-bottom: 24px;">
|
||
<div class="moat-container">
|
||
<div class="moat-metric">
|
||
<n-statistic label="⚡ 响应速度优势" :value="getSpeedAdvantage(selectedSignal)">
|
||
<template #suffix>
|
||
<span class="metric-unit">小时</span>
|
||
</template>
|
||
<template #prefix>
|
||
<span class="metric-icon">🚀</span>
|
||
</template>
|
||
</n-statistic>
|
||
<div class="moat-subtitle">
|
||
比行业平均水平快 <span class="highlight-green">{{ getSpeedAdvantage(selectedSignal) }}</span> 小时
|
||
</div>
|
||
</div>
|
||
|
||
<div class="moat-timeline">
|
||
<n-steps size="small" :current="getTimelineStep(selectedSignal)" status="process">
|
||
<n-step
|
||
title="外部信号捕获"
|
||
:description="formatTime(selectedSignal.created_at)"
|
||
/>
|
||
<n-step
|
||
title="AI 战略研判"
|
||
:description="formatTime(getAiTime(selectedSignal))"
|
||
/>
|
||
<n-step
|
||
title="首行代码提交"
|
||
:description="getCodeTime(selectedSignal)"
|
||
:status="getNode3Status(selectedSignal)"
|
||
>
|
||
<template #icon>
|
||
<n-icon v-if="getNode3Status(selectedSignal) === 'process'" class="pulsing-icon">
|
||
<div class="code-icon">⚡</div>
|
||
</n-icon>
|
||
</template>
|
||
</n-step>
|
||
</n-steps>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
|
||
<n-grid :cols="24" :x-gap="24" style="height: calc(100% - 120px);">
|
||
<n-grid-item :span="14" style="height: 100%;">
|
||
<n-card class="glass-card ai-analysis-card" :bordered="false" size="huge">
|
||
<template #header>
|
||
<div class="card-title">
|
||
<span class="emoji">🧠</span> 战略沙盘推演
|
||
<n-tag :type="getPriorityType(selectedSignal?.priority)" size="small" round style="margin-left: 10px;">
|
||
{{ getPriorityLabel(selectedSignal?.priority) }}
|
||
</n-tag>
|
||
<n-tag v-if="currentStrategicInfo.is_strategic" type="error" size="small" round style="margin-left: 8px; box-shadow: 0 0 10px rgba(208, 48, 80, 0.5);">
|
||
STRATEGIC ALERT
|
||
</n-tag>
|
||
<n-tag v-if="currentStrategicInfo.confidence !== undefined" :type="getConfidenceType(currentStrategicInfo.confidence)" size="small" round style="margin-left: 8px;">
|
||
置信度: {{ formatConfidence(currentStrategicInfo.confidence) }}
|
||
</n-tag>
|
||
<n-tag v-if="currentStrategicInfo.voting?.enabled" type="success" size="small" round style="margin-left: 8px;">
|
||
🗳️ {{ currentStrategicInfo.voting.strategic_votes }}/{{ currentStrategicInfo.voting.total_votes }} 模型投票
|
||
<span v-if="currentStrategicInfo.voting.unanimous" style="margin-left: 4px;">✓</span>
|
||
</n-tag>
|
||
<n-tag v-if="currentStrategicInfo.rag_context_used > 0" type="info" size="small" round style="margin-left: 8px;">
|
||
📚 引用 {{ currentStrategicInfo.rag_context_used }} 条相关知识
|
||
</n-tag>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="sandbox-container">
|
||
<!-- Step 1: Signal -->
|
||
<div class="sandbox-step">
|
||
<div class="step-icon source-icon">📡</div>
|
||
<div class="step-content">
|
||
<div class="step-title">信号捕获</div>
|
||
<div class="step-desc">{{ formatSignalContent(selectedSignal.content) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="step-arrow">⬇</div>
|
||
|
||
<!-- Step 2: Impact Visual -->
|
||
<div class="sandbox-step">
|
||
<div class="step-icon impact-icon">💥</div>
|
||
<div class="step-content">
|
||
<div class="step-title">影响评估 (AI Score: {{ (selectedSignal.impact_score * 100).toFixed(0) }}%)</div>
|
||
<div class="impact-visual">
|
||
<div class="impact-bar loss">
|
||
<div class="bar-label">无视损失</div>
|
||
<div class="bar-fill" :style="{ width: ((1 - selectedSignal.impact_score) * 100) + '%' }">
|
||
{{ getLossLabel(selectedSignal.impact_score) }}
|
||
</div>
|
||
</div>
|
||
<div class="impact-bar gain">
|
||
<div class="bar-label">跟进收益</div>
|
||
<div class="bar-fill" :style="{ width: (selectedSignal.impact_score * 100) + '%' }">
|
||
{{ getGainLabel(selectedSignal.impact_score) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="step-arrow">⬇</div>
|
||
|
||
<!-- Step 3: RAG Alignment (AI-Powered) -->
|
||
<div class="sandbox-step">
|
||
<div class="step-icon rag-icon">📚</div>
|
||
<div class="step-content">
|
||
<div class="step-title">知识库检索(AI 智能提取)</div>
|
||
<div class="rag-sources">
|
||
<!-- Database RAG context -->
|
||
<template v-if="currentStrategicInfo.rag_context_used > 0">
|
||
<div class="dynamic-rag-section">
|
||
<div class="rag-section-title">从认知节点检索到 {{ currentStrategicInfo.rag_context_used }} 条相关知识</div>
|
||
<n-text depth="3" style="font-size: 12px; margin-bottom: 8px;">AI 基于向量相似度检索相关知识,辅助战略判断</n-text>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- AI-Generated Tech Stacks -->
|
||
<template v-if="getDetectedTechStacks().length > 0">
|
||
<div class="tech-stacks-section">
|
||
<div class="rag-section-title">检测到的技术栈</div>
|
||
<div class="tech-stacks-grid">
|
||
<n-tag
|
||
v-for="stack in getDetectedTechStacks()"
|
||
:key="stack.name"
|
||
size="small"
|
||
:type="getTechStackType(stack.category)"
|
||
:bordered="false"
|
||
class="tech-stack-tag"
|
||
>
|
||
<template #icon>
|
||
<span class="tech-icon">{{ getTechStackIcon(stack.category) }}</span>
|
||
</template>
|
||
{{ stack.name }}
|
||
<n-badge
|
||
:value="getRelevanceLabel(stack.relevance)"
|
||
:type="getRelevanceType(stack.relevance)"
|
||
size="small"
|
||
style="margin-left: 6px;"
|
||
/>
|
||
</n-tag>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- AI-Generated Knowledge Nodes -->
|
||
<template v-if="getRelatedKnowledgeNodes().length > 0">
|
||
<div class="knowledge-nodes-section">
|
||
<div class="rag-section-title">相关知识节点</div>
|
||
<div class="knowledge-nodes-list">
|
||
<div
|
||
v-for="node in getRelatedKnowledgeNodes()"
|
||
:key="node.title"
|
||
class="knowledge-node-item clickable-tag"
|
||
@click="showFactSnippetModal(node.title)"
|
||
>
|
||
<div class="node-header">
|
||
<span class="node-icon">{{ getKnowledgeNodeIcon(node.category) }}</span>
|
||
<span class="node-title">{{ node.title }}</span>
|
||
<n-tag size="tiny" :type="getKnowledgeNodeType(node.category)" bordered>
|
||
{{ getKnowledgeNodeCategoryLabel(node.category) }}
|
||
</n-tag>
|
||
</div>
|
||
<div class="node-summary">{{ node.summary }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Fallback: No AI Data Available -->
|
||
<template v-if="getDetectedTechStacks().length === 0 && getRelatedKnowledgeNodes().length === 0">
|
||
<div class="ai-annotation no-data">
|
||
📭 AI 未检测到相关技术栈或知识节点,可能需要更多上下文信息
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="step-arrow">⬇</div>
|
||
|
||
<!-- Step 4: Recommendation -->
|
||
<div class="sandbox-step final-step">
|
||
<div class="step-icon action-icon">🎯</div>
|
||
<div class="step-content">
|
||
<div class="step-title">最终建议</div>
|
||
<div class="typewriter-text highlight-text">
|
||
{{ displayedAnalysis.recommendation || '正在生成战略建议...' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 5: ROI Scorecard -->
|
||
<div class="roi-scorecard">
|
||
<div class="roi-header">
|
||
<span class="roi-title">💰 资源与收益评估 (ROI Scorecard)</span>
|
||
</div>
|
||
<div class="roi-metrics">
|
||
<div class="roi-metric-item">
|
||
<div class="roi-label">开发人力预估</div>
|
||
<div class="roi-value primary">{{ getTotalEstimatedHours }}h</div>
|
||
<div class="roi-sub">约 {{ getTotalEstimatedDays }} 工作日</div>
|
||
</div>
|
||
<div class="roi-divider"></div>
|
||
<div class="roi-metric-item">
|
||
<div class="roi-label">算力开销预估</div>
|
||
<div class="roi-value warning">{{ getEstimatedComputeCost }}</div>
|
||
<div class="roi-sub">GPU 租赁费用</div>
|
||
</div>
|
||
<div class="roi-divider"></div>
|
||
<div class="roi-metric-item highlight">
|
||
<div class="roi-label">领先竞品窗口</div>
|
||
<div class="roi-value success">{{ getLeadWindowDays }} 天</div>
|
||
<div class="roi-sub">战略先发优势</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Voting Details (when multi-model voting is used) -->
|
||
<div v-if="currentStrategicInfo.voting?.enabled" class="voting-details">
|
||
<div class="voting-header">
|
||
<span class="voting-title">🗳️ 多模型投票详情</span>
|
||
<n-tag :type="currentStrategicInfo.voting.unanimous ? 'success' : 'info'" size="small">
|
||
{{ currentStrategicInfo.voting.unanimous ? '一致同意' : '多数决策' }}
|
||
</n-tag>
|
||
</div>
|
||
<div class="voting-models">
|
||
<div v-for="model in currentStrategicInfo.voting.models_used || []" :key="model" class="voting-model-item">
|
||
<div class="model-name">{{ model }}</div>
|
||
<div class="model-vote">
|
||
<n-tag size="small" :type="getModelVoteType(model, currentStrategicInfo)">
|
||
{{ getModelVoteLabel(model, currentStrategicInfo) }}
|
||
</n-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="voting-summary">
|
||
<n-text depth="3">结果: {{ currentStrategicInfo.voting.strategic_votes }}/{{ currentStrategicInfo.voting.total_votes }} 模型判定为战略级</n-text>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Radar Chart (Keep it but smaller/integrated) -->
|
||
<div v-if="currentStrategicInfo.competitiveness_radar" style="height: 200px; margin-top: 10px;">
|
||
<v-chart class="chart" :option="getRadarOption(currentStrategicInfo.competitiveness_radar)" autoresize />
|
||
</div>
|
||
|
||
<!-- Command Gate -->
|
||
<div class="command-gate" v-if="currentStrategicInfo.is_strategic">
|
||
<n-button
|
||
type="error"
|
||
size="large"
|
||
block
|
||
class="deploy-btn authorize-btn"
|
||
:disabled="taskDeployed"
|
||
@click="deployStrategy"
|
||
>
|
||
<template #icon>
|
||
<span class="authorize-icon">🛡️</span>
|
||
</template>
|
||
{{ taskDeployed ? '战略指令已下发 (AUTHORIZED)' : '授权下发战略指令 (AUTHORIZE)' }}
|
||
</n-button>
|
||
</div>
|
||
|
||
<!-- Feedback Section -->
|
||
<div v-if="selectedSignal?.strategic_analysis" class="feedback-section">
|
||
<div class="feedback-header">
|
||
<span class="feedback-title">📝 AI 判断反馈</span>
|
||
<n-tag v-if="signalFeedback" :type="feedbackMatches ? 'success' : 'warning'" size="small">
|
||
{{ feedbackSubmitted ? '已反馈' : '等待反馈' }}
|
||
</n-tag>
|
||
</div>
|
||
<div class="feedback-content">
|
||
<n-text v-if="!feedbackSubmitted" depth="3" style="font-size: 12px; margin-bottom: 8px;">
|
||
帮助 AI 学习:这个信号的战略判断是否准确?
|
||
</n-text>
|
||
<div v-if="!feedbackSubmitted" class="feedback-actions">
|
||
<n-button
|
||
size="small"
|
||
type="success"
|
||
:disabled="feedbackSubmitting"
|
||
@click="submitFeedback(true)"
|
||
>
|
||
✓ 判断正确
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
type="error"
|
||
:disabled="feedbackSubmitting"
|
||
@click="submitFeedback(false)"
|
||
>
|
||
✗ 判断错误
|
||
</n-button>
|
||
</div>
|
||
<div v-else class="feedback-result">
|
||
<n-text :type="feedbackMatches ? 'success' : 'warning'">
|
||
{{ feedbackMatches ? '✓ 您的反馈与 AI 判断一致' : '✗ 您的反馈与 AI 判断不同' }}
|
||
</n-text>
|
||
<n-button text size="small" @click="resetFeedback" style="margin-left: 8px;">
|
||
重新反馈
|
||
</n-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
</n-grid-item>
|
||
|
||
<!-- Right: Mission Board -->
|
||
<n-grid-item :span="10" style="height: 100%;">
|
||
<n-card class="glass-card mission-board" :class="{ 'executing': taskExecuting }" title="⚔️ 作战任务看板 (P0)" :bordered="false" size="medium">
|
||
<template #header-extra>
|
||
<n-tag v-if="taskDeployed" type="success" size="small" round>SYNCED</n-tag>
|
||
<n-tag v-else-if="taskExecuting" type="warning" size="small" round class="executing-tag">
|
||
<span class="executing-dot"></span>EXECUTING
|
||
</n-tag>
|
||
<n-tag v-else type="info" size="small" round>STANDBY</n-tag>
|
||
</template>
|
||
|
||
<div class="mission-container">
|
||
<div class="mission-content" :class="{ 'blurred': !taskDeployed && !taskExecuting }">
|
||
<n-list class="task-list">
|
||
<template v-if="currentGeneratedTasks.length > 0">
|
||
<n-list-item v-for="(task, index) in currentGeneratedTasks" :key="index" class="task-item" :class="{ 'pulse-active': taskExecuting }">
|
||
<div class="task-header">
|
||
<n-tag size="small" :type="getTaskTypeColor(task.type)" :bordered="false">{{ task.type.toUpperCase() }}</n-tag>
|
||
<n-text class="task-hours">{{ task.estimated_hours }}h</n-text>
|
||
</div>
|
||
<div class="task-body">
|
||
<n-text class="task-name">{{ task.task_name }}</n-text>
|
||
<n-progress
|
||
type="line"
|
||
:percentage="getTaskProgress(task)"
|
||
:color="getTaskTypeColor(task.type, true)"
|
||
rail-color="rgba(255,255,255,0.1)"
|
||
height="4"
|
||
:show-indicator="false"
|
||
style="margin-top: 8px;"
|
||
:class="{ 'progress-pulse': taskExecuting }"
|
||
/>
|
||
</div>
|
||
</n-list-item>
|
||
</template>
|
||
<template v-else>
|
||
<div class="empty-tasks">
|
||
<n-text depth="3">无关联作战任务</n-text>
|
||
</div>
|
||
</template>
|
||
</n-list>
|
||
</div>
|
||
|
||
<div v-if="!taskDeployed && !taskExecuting" class="mission-locked-overlay">
|
||
<div class="lock-icon">⏸</div>
|
||
<n-text class="lock-text">AI 自动预案待命中<br/>等待授权同步至 Plane</n-text>
|
||
</div>
|
||
</div>
|
||
</n-card>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
</div>
|
||
</n-layout-content>
|
||
</n-layout>
|
||
|
||
<!-- Simulation Modal -->
|
||
<n-modal v-model:show="showModal">
|
||
<n-card style="width: 600px" title="⚡ 模拟外部信号注入" :bordered="false" size="huge" role="dialog" aria-modal="true" class="glass-modal">
|
||
<n-form ref="formRef" :model="formValue">
|
||
<n-form-item label="来源" path="source">
|
||
<n-input v-model:value="formValue.source" placeholder="例如: TechCrunch, Twitter" />
|
||
</n-form-item>
|
||
<n-form-item label="内容" path="content">
|
||
<n-input v-model:value="formValue.content" type="textarea" placeholder="输入突发新闻..." :rows="4" />
|
||
</n-form-item>
|
||
</n-form>
|
||
<template #footer>
|
||
<n-button type="error" block @click="submitSignal" :loading="submitting">
|
||
🚀 发送并触发战略评估
|
||
</n-button>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
<!-- Competitor Config Modal -->
|
||
<n-modal v-model:show="showCompetitorModal">
|
||
<n-card style="width: 400px" title="⚔️ 设定主要竞品(某漫、某画等)" :bordered="false" size="large" role="dialog" aria-modal="true" class="glass-modal">
|
||
<n-space vertical>
|
||
<n-input v-for="(comp, index) in competitors" :key="index" v-model:value="competitors[index]" placeholder="输入竞品名称..." />
|
||
</n-space>
|
||
<template #footer>
|
||
<n-button type="primary" block @click="saveCompetitors">
|
||
保存配置
|
||
</n-button>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Fact Snippet Modal -->
|
||
<n-modal v-model:show="showFactSnippet">
|
||
<n-card style="width: 500px" :bordered="false" size="medium" role="dialog" aria-modal="true" class="glass-modal fact-snippet-modal">
|
||
<template #header>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span class="fact-icon">📄</span>
|
||
<span class="fact-title">{{ currentFactSnippet.title }}</span>
|
||
</div>
|
||
</template>
|
||
<div class="fact-content">
|
||
<div class="fact-source">{{ currentFactSnippet.source }}</div>
|
||
<div class="fact-divider"></div>
|
||
<div class="fact-text">{{ currentFactSnippet.content }}</div>
|
||
</div>
|
||
</n-card>
|
||
</n-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, reactive, watch, nextTick, computed } from 'vue'
|
||
import {
|
||
NLayout, NLayoutSider, NLayoutContent, NSpace, NCard, NButton, NList, NListItem, NTag, NText,
|
||
NGrid, NGridItem, NDivider, NResult, NCheckbox, NThing, NScrollbar, NSpin, NModal, NForm, NFormItem,
|
||
NInput, useMessage, NEmpty, NProgress, NIcon, NSteps, NStep, NStatistic
|
||
} from 'naive-ui'
|
||
import axios from 'axios'
|
||
import VChart from 'vue-echarts'
|
||
import { use } from 'echarts/core'
|
||
import { CanvasRenderer } from 'echarts/renderers'
|
||
import { RadarChart } from 'echarts/charts'
|
||
import { TitleComponent, TooltipComponent, LegendComponent, RadarComponent } from 'echarts/components'
|
||
|
||
use([CanvasRenderer, RadarChart, TitleComponent, TooltipComponent, LegendComponent, RadarComponent])
|
||
|
||
// Icons (Mock component for now, or use unicode)
|
||
const RadarIcon = () => '📡'
|
||
|
||
const message = useMessage()
|
||
const signals = ref<any[]>([])
|
||
const selectedSignal = ref<any>(null)
|
||
const loading = ref(false)
|
||
const showModal = ref(false)
|
||
const submitting = ref(false)
|
||
let pollingInterval: any = null
|
||
let countdownInterval: any = null
|
||
let deployTimeout: any = null
|
||
|
||
// AI 分析状态
|
||
const analyzing = ref(false)
|
||
const analysisProgress = ref(0)
|
||
const analysisStatus = ref('')
|
||
const ollamaReady = ref(false)
|
||
|
||
// Typewriter Effect State
|
||
const displayedAnalysis = reactive({
|
||
situation_analysis: '',
|
||
recommendation: '',
|
||
expected_benefits: ''
|
||
})
|
||
const typing = ref(false)
|
||
|
||
const formValue = ref({
|
||
source: 'Civitai',
|
||
content: 'Civitai 发布了针对漫画一致性的新 LoRA 模型,可显著改善角色脸崩问题',
|
||
impact_score: 0.9
|
||
})
|
||
|
||
const taskDeployed = ref(false)
|
||
const taskExecuting = ref(false)
|
||
const showScanEffect = ref(false)
|
||
|
||
// Feedback state
|
||
const feedbackSubmitted = ref(false)
|
||
const feedbackSubmitting = ref(false)
|
||
const signalFeedback = ref<any>(null)
|
||
const feedbackMatches = ref(false)
|
||
|
||
const showCompetitorModal = ref(false)
|
||
const competitors = ref(['某漫', '某画'])
|
||
|
||
// Fact Snippet Modal
|
||
const showFactSnippet = ref(false)
|
||
const currentFactSnippet = ref({
|
||
title: '',
|
||
source: '',
|
||
content: ''
|
||
})
|
||
|
||
const leadWindow = ref('48:00:00')
|
||
|
||
// Compute Load State
|
||
const isReservingCompute = ref(false)
|
||
const reservedComputePercent = ref(0)
|
||
|
||
// Computed properties for expensive operations
|
||
const currentStrategicInfo = computed(() => {
|
||
return getStrategicInfo(selectedSignal.value)
|
||
})
|
||
|
||
const currentGeneratedTasks = computed(() => {
|
||
return getGeneratedTasks(selectedSignal.value)
|
||
})
|
||
|
||
// Aggregate signals by source to avoid duplicates
|
||
interface AggregatedSignal {
|
||
id: string
|
||
source: string
|
||
impact_score: number
|
||
created_at: string
|
||
content: string
|
||
count: number
|
||
signals: any[]
|
||
latestSignal: any
|
||
}
|
||
|
||
const aggregatedSignals = computed(() => {
|
||
const groups = new Map<string, any[]>()
|
||
|
||
// Group signals by source
|
||
signals.value.forEach(signal => {
|
||
const key = signal.source || 'Unknown'
|
||
if (!groups.has(key)) {
|
||
groups.set(key, [])
|
||
}
|
||
groups.get(key)!.push(signal)
|
||
})
|
||
|
||
// Create aggregated signal objects
|
||
const result: AggregatedSignal[] = []
|
||
groups.forEach((groupSignals, source) => {
|
||
// Sort by created_at descending
|
||
groupSignals.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||
|
||
const latest = groupSignals[0]
|
||
const count = groupSignals.length
|
||
|
||
let aggregatedContent = latest.content
|
||
let displayTitle = source
|
||
|
||
// If multiple signals from same source, create aggregated title
|
||
if (count > 1) {
|
||
// Check if it's Pixelle-Video or similar known projects
|
||
if (source.includes('Pixelle-Video') || source.includes('GitHub') || groupSignals.some(s => s.content.includes('Pixelle-Video'))) {
|
||
displayTitle = source
|
||
aggregatedContent = `检测到 ${source} 矩阵式更新,涉及 ${count} 个关键节点`
|
||
} else {
|
||
// Generic aggregation
|
||
displayTitle = source
|
||
aggregatedContent = `检测到 ${source} 系列更新,共 ${count} 条相关信号`
|
||
}
|
||
}
|
||
|
||
result.push({
|
||
id: latest.id, // Use latest signal's ID for selection
|
||
source: displayTitle,
|
||
impact_score: Math.max(...groupSignals.map(s => s.impact_score || 0)), // Use highest impact
|
||
created_at: latest.created_at,
|
||
content: aggregatedContent,
|
||
count,
|
||
signals: groupSignals,
|
||
latestSignal: latest
|
||
})
|
||
})
|
||
|
||
// Sort by impact_score and created_at
|
||
result.sort((a, b) => {
|
||
if (a.impact_score !== b.impact_score) {
|
||
return b.impact_score - a.impact_score
|
||
}
|
||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||
})
|
||
|
||
return result
|
||
})
|
||
|
||
// ROI Metrics
|
||
const getTotalEstimatedHours = computed(() => {
|
||
if (!currentGeneratedTasks.value || currentGeneratedTasks.value.length === 0) {
|
||
return 0
|
||
}
|
||
return currentGeneratedTasks.value.reduce((sum: number, task: any) => {
|
||
return sum + (task.estimated_hours || 0)
|
||
}, 0)
|
||
})
|
||
|
||
const getTotalEstimatedDays = computed(() => {
|
||
const hours = getTotalEstimatedHours.value
|
||
if (hours === 0) return 0
|
||
// Assume 8 hours per workday
|
||
return Math.ceil(hours / 8)
|
||
})
|
||
|
||
const getEstimatedComputeCost = computed(() => {
|
||
const hours = getTotalEstimatedHours.value
|
||
if (hours === 0) return '$0'
|
||
// Estimate: $2 per hour of GPU usage
|
||
const cost = hours * 2
|
||
return `$${cost.toFixed(0)}`
|
||
})
|
||
|
||
const getLeadWindowDays = computed(() => {
|
||
if (!selectedSignal.value) return 0
|
||
const leadHours = parseFloat(getSpeedAdvantage(selectedSignal.value))
|
||
if (leadHours <= 0) return 0
|
||
return Math.ceil(leadHours / 24)
|
||
})
|
||
|
||
// Compute Load Estimation
|
||
const computeLoadPercent = computed(() => {
|
||
if (!selectedSignal.value) return 42
|
||
// Base load varies by signal impact score
|
||
const baseLoad = 42
|
||
const impactBonus = (selectedSignal.value.impact_score || 0) * 30
|
||
// Add task-based load
|
||
const taskLoad = currentGeneratedTasks.value.length * 3
|
||
return Math.min(95, Math.round(baseLoad + impactBonus + taskLoad))
|
||
})
|
||
|
||
// Update Lead Window Countdown - AI Drama Business
|
||
const updateLeadWindow = () => {
|
||
if (!selectedSignal.value) {
|
||
leadWindow.value = '--:--:--'
|
||
return
|
||
}
|
||
|
||
// AI Drama: 1 week (168h) competitor baseline - 24h internal testing = 144h (6 days) lead advantage
|
||
const LEAD_ADVANTAGE_MS = (168 - 24) * 60 * 60 * 1000 // 144 hours in milliseconds
|
||
const INTERNAL_TESTING_MS = 24 * 60 * 60 * 1000 // 24 hours for internal testing
|
||
|
||
const created = new Date(selectedSignal.value.created_at).getTime()
|
||
const now = Date.now()
|
||
const elapsed = now - created
|
||
|
||
// If still within internal testing window, show full lead advantage
|
||
if (elapsed < INTERNAL_TESTING_MS) {
|
||
// We're still in the 24h internal testing phase
|
||
const h = Math.floor(LEAD_ADVANTAGE_MS / (1000 * 60 * 60))
|
||
const m = Math.floor((LEAD_ADVANTAGE_MS % (1000 * 60 * 60)) / (1000 * 60))
|
||
const s = Math.floor((LEAD_ADVANTAGE_MS % (1000 * 60)) / 1000)
|
||
leadWindow.value = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||
return
|
||
}
|
||
|
||
// Calculate remaining lead advantage after internal testing phase
|
||
const remainingLead = LEAD_ADVANTAGE_MS - (elapsed - INTERNAL_TESTING_MS)
|
||
|
||
if (remainingLead <= 0) {
|
||
leadWindow.value = '00:00:00'
|
||
return
|
||
}
|
||
|
||
const h = Math.floor(remainingLead / (1000 * 60 * 60))
|
||
const m = Math.floor((remainingLead % (1000 * 60 * 60)) / (1000 * 60))
|
||
const s = Math.floor((remainingLead % (1000 * 60)) / 1000)
|
||
|
||
leadWindow.value = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
const saveCompetitors = () => {
|
||
localStorage.setItem('competitors', JSON.stringify(competitors.value))
|
||
showCompetitorModal.value = false
|
||
message.success('竞品配置已更新')
|
||
}
|
||
|
||
// Fact Snippets Data - AI Drama Business Intelligence
|
||
const factSnippets: Record<string, { title: string; source: string; content: string }> = {
|
||
'Civitai-LoRA': {
|
||
title: 'Civitai 漫画一致性 LoRA 模型技术报告',
|
||
source: 'civitai.com/models/anime-consistency-lora-v2',
|
||
content: '针对 AI 漫剧生产线的角色一致性解决方案:\n\n• 基于 LoRA 架构的角色保持技术\n• 解决「角色脸崩」核心痛点\n• 支持 768x1024 高清输出\n• 推理速度提升 40%\n• 与现有 Stable Diffusion 管线完全兼容\n• 预计可降低返工率 65%'
|
||
},
|
||
'Sora-2D': {
|
||
title: 'Sora 2D 动漫风格运动优化接口文档',
|
||
source: 'openai.com/research/sora-2d-animation',
|
||
content: 'OpenAI Sora 针对 2D 动漫场景的运动增强:\n\n• 新增 2D 动漫风格运动适配器\n• 支持逐帧动作捕捉与优化\n• 动态镜头语言自动生成\n• 可将单集制作成本从 500 元降至 50 元\n• API 接口:/v1/2d-anime-optimize\n• 需要配合动漫专用训练数据集'
|
||
},
|
||
'AI-Drama-Analysis': {
|
||
title: 'AI 漫剧生产线竞争力分析 v3.0',
|
||
source: 'Internal AI Drama Product Team / 2024-Q4',
|
||
content: '「AI 漫剧产品专家」战略研判:\n\n• 竞品(某漫、某画)平均适配周期:7 天\n• 我们的内测目标:24 小时完成验证\n• 领先窗口:6 天战略先发优势\n• 成本优化:单集 500元 → 50元(90% 降幅)\n• 质量:解决「角色脸崩」用户投诉核心痛点\n• 建议:立即启动 Civitai LoRA 集成验证'
|
||
},
|
||
'Pixelle-Video': {
|
||
title: 'Pixelle-Video v1.0 技术文档',
|
||
source: 'github.com/user/repo/README.md',
|
||
content: '基于 WebUI 架构,支持一键模型热加载。核心特性包括:\n\n• Gradio WebUI 界面,支持实时预览\n• 模型热加载机制,无需重启服务\n• 动作迁移 (Motion Transfer) 模块\n• 支持自定义工作流 pipeline\n• 完整的 REST API 接口'
|
||
},
|
||
'Whitepaper': {
|
||
title: 'Technical Whitepaper v2.0',
|
||
source: 'Internal Documentation / Technical Specs',
|
||
content: '本文档描述了下一代 AI 系统架构设计原则:\n\n• 微服务化组件设计\n• 事件驱动架构 (Event-Driven Architecture)\n• 实时数据流处理管道\n• 可扩展的模型集成层\n• 企业级安全与合规要求'
|
||
},
|
||
'Competitor': {
|
||
title: '竞品(某漫、某画)技术栈分析',
|
||
source: 'Market Intelligence Report / 2024-Q4',
|
||
content: '主要竞争对手动态分析:\n\n• 某漫:使用 SD 1.5 基座,适配周期 7-10 天\n• 某画:手工调优模型,单集成本 800 元\n• 市场痛点:角色脸崩、动作僵硬、成本高昂\n• 我们的优势:24h 内测完成、成本降至 50 元/集\n• 战略窗口:快速抢占市场空白期'
|
||
}
|
||
}
|
||
|
||
const showFactSnippetModal = (type: string) => {
|
||
const snippet = factSnippets[type] || factSnippets['Whitepaper']
|
||
currentFactSnippet.value = {
|
||
title: snippet.title,
|
||
source: snippet.source,
|
||
content: snippet.content
|
||
}
|
||
showFactSnippet.value = true
|
||
}
|
||
|
||
// --- Logic ---
|
||
|
||
const getOurProgress = (signal: any) => {
|
||
if (!signal) return 0
|
||
|
||
// AI Drama Business: We have 24-hour internal testing capability
|
||
// Strong signal (>= 0.8): Instant jump to 80% (AI Advantage + 24h testing target)
|
||
if (signal.impact_score >= 0.8) {
|
||
const created = new Date(signal.created_at).getTime()
|
||
const now = Date.now()
|
||
const hoursSince = (now - created) / (1000 * 60 * 60)
|
||
|
||
// If within 24h window, show progress ramping up
|
||
if (hoursSince < 24) {
|
||
return Math.min(80, 10 + (hoursSince / 24) * 70).toFixed(1)
|
||
}
|
||
return 80.0
|
||
}
|
||
// Weak signal: 10% (AI Captured)
|
||
return 10.0
|
||
}
|
||
|
||
const getCompetitorProgress = (signal: any) => {
|
||
if (!signal) return 0.0
|
||
// Weak signal (< 0.6): Competitor doesn't know (0%)
|
||
if (signal.impact_score < 0.6) return 0.0
|
||
|
||
// AI Drama Business: Competitors (某漫、某画) take 1 week (168 hours) to adapt
|
||
const COMPETITOR_ADAPT_TIME_HOURS = 168 // 7 days
|
||
const created = new Date(signal.created_at).getTime()
|
||
const now = Date.now()
|
||
const hoursSince = (now - created) / (1000 * 60 * 60)
|
||
|
||
// First 24h: Competitors still unaware (0-5%)
|
||
if (hoursSince < 24) return Math.min(5, hoursSince * 0.2).toFixed(1)
|
||
|
||
// After 24h, competitors start adapting (slow progression over 1 week)
|
||
const progress = ((hoursSince - 24) / (COMPETITOR_ADAPT_TIME_HOURS - 24)) * 100
|
||
return Math.min(90, progress).toFixed(1)
|
||
}
|
||
|
||
const getLeadTimeText = (signal: any) => {
|
||
if (!signal) return ''
|
||
|
||
// Weak Signal
|
||
if (signal.impact_score < 0.6) {
|
||
return '领先竞品:无限 (竞品未发现)'
|
||
}
|
||
|
||
// Strong Signal: Use consistent logic with getSpeedAdvantage
|
||
const val = getSpeedAdvantage(signal)
|
||
return `领先竞品:${val} 小时`
|
||
}
|
||
|
||
// Impact label helpers
|
||
const getLossLabel = (score: number) => {
|
||
if (score >= 0.9) return 'Critical'
|
||
if (score >= 0.7) return 'High'
|
||
if (score >= 0.4) return 'Medium'
|
||
return 'Low'
|
||
}
|
||
|
||
const getGainLabel = (score: number) => {
|
||
if (score >= 0.9) return 'Maximum'
|
||
if (score >= 0.7) return 'High'
|
||
if (score >= 0.4) return 'Moderate'
|
||
return 'Minimal'
|
||
}
|
||
|
||
// AI-Powered RAG Section Helpers
|
||
const getDetectedTechStacks = () => {
|
||
const techStacks = currentStrategicInfo.value.detected_tech_stacks || []
|
||
return Array.isArray(techStacks) ? techStacks : []
|
||
}
|
||
|
||
const getRelatedKnowledgeNodes = () => {
|
||
const knowledgeNodes = currentStrategicInfo.value.related_knowledge_nodes || []
|
||
return Array.isArray(knowledgeNodes) ? knowledgeNodes : []
|
||
}
|
||
|
||
const getTechStackType = (category: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
framework: 'success',
|
||
model: 'error',
|
||
tool: 'info',
|
||
api: 'warning'
|
||
}
|
||
return typeMap[category] || 'default'
|
||
}
|
||
|
||
const getTechStackIcon = (category: string) => {
|
||
const iconMap: Record<string, string> = {
|
||
framework: '🏗️',
|
||
model: '🤖',
|
||
tool: '🔧',
|
||
api: '🔌'
|
||
}
|
||
return iconMap[category] || '📦'
|
||
}
|
||
|
||
const getRelevanceLabel = (relevance: string) => {
|
||
const labelMap: Record<string, string> = {
|
||
high: '高',
|
||
medium: '中',
|
||
low: '低'
|
||
}
|
||
return labelMap[relevance] || relevance
|
||
}
|
||
|
||
const getRelevanceType = (relevance: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
high: 'error',
|
||
medium: 'warning',
|
||
low: 'default'
|
||
}
|
||
return typeMap[relevance] || 'default'
|
||
}
|
||
|
||
const getKnowledgeNodeIcon = (category: string) => {
|
||
const iconMap: Record<string, string> = {
|
||
tech_stack: '⚙️',
|
||
competitor: '⚔️',
|
||
market: '📊'
|
||
}
|
||
return iconMap[category] || '🧠'
|
||
}
|
||
|
||
const getKnowledgeNodeType = (category: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
tech_stack: 'success',
|
||
competitor: 'error',
|
||
market: 'info'
|
||
}
|
||
return typeMap[category] || 'default'
|
||
}
|
||
|
||
const getKnowledgeNodeCategoryLabel = (category: string) => {
|
||
const labelMap: Record<string, string> = {
|
||
tech_stack: '技术',
|
||
competitor: '竞品',
|
||
market: '市场'
|
||
}
|
||
return labelMap[category] || category
|
||
}
|
||
|
||
const formatSignalContent = (content: string) => {
|
||
if (!content) return ''
|
||
|
||
// AI Drama Business: Prioritize AI Drama signals
|
||
if (content.includes('Civitai') || content.includes('LoRA')) {
|
||
return '🎭 关键情报:Civitai 发布漫画一致性 LoRA,可解决「角色脸崩」痛点'
|
||
}
|
||
if (content.includes('Sora') || (content.includes('2D') && content.includes('动漫'))) {
|
||
return '🎬 战略机遇:Sora 开放 2D 动漫运动优化 API,成本将降至 1/10'
|
||
}
|
||
// 2. Intelligent Renaming for Known Projects (check before desensitizing)
|
||
if (content.includes('Pixelle-Video')) {
|
||
return '监测到关键开源动态:Pixelle-Video 全自动短视频引擎更新'
|
||
}
|
||
|
||
// 1. Desensitize Paths
|
||
let cleaned = content.replace(/Path:\s*\/Users\/[\w\/\.-]+/gi, '')
|
||
|
||
// 3. Truncate if too long
|
||
if (cleaned.length > 80) return cleaned.substring(0, 80) + '...'
|
||
return cleaned
|
||
}
|
||
|
||
const getNode3Status = (signal: any) => {
|
||
const tasks = getGeneratedTasks(signal)
|
||
const hasCompleted = tasks.some((t: any) => t.status === 'Done')
|
||
if (hasCompleted) return 'finish'
|
||
if (tasks.length > 0) return 'process' // Active/Listening
|
||
return 'wait'
|
||
}
|
||
|
||
const deployStrategy = () => {
|
||
// Clear any existing timeout
|
||
if (deployTimeout) {
|
||
clearTimeout(deployTimeout)
|
||
}
|
||
|
||
// Start executing state immediately with sync animation
|
||
taskExecuting.value = true
|
||
|
||
// After 0.5s sync animation, show scan effect and complete deployment
|
||
deployTimeout = setTimeout(() => {
|
||
showScanEffect.value = true
|
||
|
||
// Another 1s for scan effect, then mark as deployed
|
||
setTimeout(() => {
|
||
taskDeployed.value = true
|
||
taskExecuting.value = false
|
||
showScanEffect.value = false
|
||
message.success('战略任务已全量下发!')
|
||
}, 1000)
|
||
|
||
deployTimeout = null
|
||
}, 500)
|
||
}
|
||
|
||
const getImpactColor = (score: number) => {
|
||
if (score >= 0.9) return 'error'
|
||
if (score >= 0.7) return 'warning'
|
||
return 'success' // Changed default to success green for low impact
|
||
}
|
||
|
||
const getTaskTypeColor = (type: string, isHex = false) => {
|
||
const map: any = {
|
||
backend: { type: 'info', hex: '#2080f0' },
|
||
frontend: { type: 'success', hex: '#18a058' },
|
||
database: { type: 'warning', hex: '#f0a020' }
|
||
}
|
||
const t = map[type.toLowerCase()] || { type: 'default', hex: '#999' }
|
||
return isHex ? t.hex : t.type
|
||
}
|
||
|
||
const getTaskProgress = (task: any) => {
|
||
// Real logic: Check if status is Done
|
||
if (task.status === 'Done') return 100
|
||
if (task.status === 'Failed') return 0
|
||
|
||
// Mock progress for pending tasks based on name hash
|
||
let hash = 0
|
||
for (let i = 0; i < task.task_name.length; i++) {
|
||
hash = task.task_name.charCodeAt(i) + ((hash << 5) - hash)
|
||
}
|
||
return Math.abs(hash % 60) + 10 // 10-70% for pending
|
||
}
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
if (!dateStr) return ''
|
||
return new Date(dateStr).toLocaleTimeString()
|
||
}
|
||
|
||
const getStrategicInfo = (signal: any) => {
|
||
if (!signal?.strategic_analysis) return { is_strategic: false }
|
||
try {
|
||
const root = JSON.parse(signal.strategic_analysis)
|
||
const isStrategic = signal.impact_score >= 0.9
|
||
|
||
// Handle new structure with nested decision_proposal
|
||
const decisionProposal = root.decision_proposal || {}
|
||
const competitivenessRadar = root.competitiveness_radar
|
||
|
||
return {
|
||
is_strategic: isStrategic,
|
||
confidence: root.confidence, // May be undefined for old signals
|
||
decision_proposal: decisionProposal,
|
||
competitiveness_radar: competitivenessRadar,
|
||
rag_context_used: root.rag_context_used || 0,
|
||
rag_context_sources: root.rag_context_sources || [],
|
||
voting: root.voting || null, // Multi-model voting info
|
||
tasks: root.tasks || [],
|
||
// AI-powered tech stack and knowledge node detection
|
||
detected_tech_stacks: root.detected_tech_stacks || [],
|
||
related_knowledge_nodes: root.related_knowledge_nodes || []
|
||
}
|
||
} catch (e) {
|
||
return { is_strategic: false }
|
||
}
|
||
}
|
||
|
||
// Confidence helpers
|
||
const getConfidenceType = (confidence: number) => {
|
||
if (confidence >= 0.8) return 'success'
|
||
if (confidence >= 0.6) return 'warning'
|
||
return 'default'
|
||
}
|
||
|
||
const formatConfidence = (confidence: number) => {
|
||
return `${Math.round(confidence * 100)}%`
|
||
}
|
||
|
||
// Priority helpers
|
||
const getPriorityType = (priority: string) => {
|
||
switch (priority) {
|
||
case 'critical': return 'error'
|
||
case 'high': return 'warning'
|
||
case 'medium': return 'info'
|
||
case 'low': return 'default'
|
||
default: return 'default'
|
||
}
|
||
}
|
||
|
||
const getPriorityLabel = (priority: string) => {
|
||
switch (priority) {
|
||
case 'critical': return '🔴 紧急'
|
||
case 'high': return '🟠 高优'
|
||
case 'medium': return '🟡 中等'
|
||
case 'low': return '🟢 低优'
|
||
default: return '🟢 低优'
|
||
}
|
||
}
|
||
|
||
// Voting helpers
|
||
const getModelVoteType = (modelName: string, strategicInfo: any) => {
|
||
// For now, since we don't have individual model results in the response,
|
||
// we'll return a default type. This could be enhanced if we send individual results.
|
||
return strategicInfo.is_strategic ? 'success' : 'default'
|
||
}
|
||
|
||
const getModelVoteLabel = (modelName: string, strategicInfo: any) => {
|
||
// Similar to above, placeholder logic
|
||
return strategicInfo.is_strategic ? '战略级' : '非战略'
|
||
}
|
||
|
||
// Feedback functions
|
||
const submitFeedback = async (agreed: boolean) => {
|
||
if (!selectedSignal.value) return
|
||
|
||
feedbackSubmitting.value = true
|
||
try {
|
||
// Determine what the user actually thinks
|
||
// If agreed=true, user thinks AI was correct
|
||
// If agreed=false, user thinks AI was wrong (opposite)
|
||
const aiPrediction = currentStrategicInfo.value.is_strategic
|
||
const userFeedback = agreed ? aiPrediction : !aiPrediction
|
||
|
||
const response = await axios.post(`http://127.0.0.1:8000/signals/${selectedSignal.value.id}/feedback`, {
|
||
signal_id: selectedSignal.value.id,
|
||
was_strategic: userFeedback,
|
||
reason: agreed ? 'AI判断准确' : 'AI判断不准确'
|
||
})
|
||
|
||
signalFeedback.value = response.data
|
||
feedbackSubmitted.value = true
|
||
feedbackMatches.value = response.data.is_correct
|
||
|
||
message.success(feedbackMatches.value ? '感谢反馈!AI 判断正确' : '感谢反馈!我们会从错误中学习')
|
||
} catch (error) {
|
||
message.error('提交反馈失败')
|
||
} finally {
|
||
feedbackSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
const resetFeedback = () => {
|
||
feedbackSubmitted.value = false
|
||
signalFeedback.value = null
|
||
feedbackMatches.value = false
|
||
}
|
||
|
||
const getGeneratedTasks = (signal: any) => {
|
||
// Prefer tasks from the strategic analysis if available
|
||
if (signal?.strategic_analysis) {
|
||
try {
|
||
const root = JSON.parse(signal.strategic_analysis)
|
||
if (root.tasks && Array.isArray(root.tasks)) {
|
||
return root.tasks
|
||
}
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// Fallback to separate tasks_generated field if exists
|
||
if (!signal?.tasks_generated) return []
|
||
try {
|
||
return JSON.parse(signal.tasks_generated)
|
||
} catch (e) {
|
||
return []
|
||
}
|
||
}
|
||
|
||
// --- Timeline & Moat Logic ---
|
||
|
||
const formatTime = (timeStr: string | Date) => {
|
||
if (!timeStr) return '--:--'
|
||
const d = new Date(timeStr)
|
||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||
}
|
||
|
||
const getAiTime = (signal: any) => {
|
||
if (!signal) return ''
|
||
// Simulate AI time: created_at + 2 minutes
|
||
const d = new Date(signal.created_at)
|
||
d.setMinutes(d.getMinutes() + 2)
|
||
return d.toISOString()
|
||
}
|
||
|
||
const getCodeTime = (signal: any) => {
|
||
if (!signal) return '等待中...'
|
||
const tasks = getGeneratedTasks(signal)
|
||
|
||
// Try to find real completion time
|
||
const completedTask = tasks.find((t: any) => t.status === 'Done' && t.completed_at)
|
||
if (completedTask) {
|
||
return formatTime(completedTask.completed_at)
|
||
}
|
||
|
||
// Fallback simulation
|
||
if (tasks.length > 0) {
|
||
// Simulate Code Commit time: created_at + 45 minutes
|
||
const d = new Date(signal.created_at)
|
||
d.setMinutes(d.getMinutes() + 45)
|
||
return formatTime(d.toISOString())
|
||
}
|
||
return '等待中...'
|
||
}
|
||
|
||
const getTimelineStep = (signal: any) => {
|
||
if (!signal) return 1
|
||
const tasks = getGeneratedTasks(signal)
|
||
|
||
// Check for real completion
|
||
const hasCompletedTask = tasks.some((t: any) => t.status === 'Done')
|
||
if (hasCompletedTask) return 3 // Code Committed/Done
|
||
|
||
if (tasks.length > 0) return 2 // Tasks generated but not done (In Progress)
|
||
if (signal.strategic_analysis) return 2 // AI Done
|
||
return 1 // Signal Received
|
||
}
|
||
|
||
const getSpeedAdvantage = (signal: any) => {
|
||
if (!signal) return '0.0'
|
||
|
||
// AI Drama Business Logic:
|
||
// Competitors (某漫、某画) take 1 week (168 hours) to adapt
|
||
// Our target: 24 hours internal testing
|
||
// Lead advantage: 168h - 24h = 144 hours = 6 days
|
||
const COMPETITOR_ADAPT_HOURS = 168 // 7 days
|
||
const INTERNAL_TESTING_HOURS = 24 // Our target
|
||
|
||
// Calculate remaining time advantage
|
||
const created = new Date(signal.created_at).getTime()
|
||
const now = Date.now()
|
||
const hoursSinceSignal = (now - created) / (1000 * 60 * 60)
|
||
|
||
// If we haven't deployed yet, our advantage decays
|
||
// Maximum advantage: 144 hours (6 days)
|
||
// Current advantage: 144 - (hours elapsed - 24) if elapsed > 24
|
||
let remainingAdvantage = (COMPETITOR_ADAPT_HOURS - INTERNAL_TESTING_HOURS) - Math.max(0, hoursSinceSignal - INTERNAL_TESTING_HOURS)
|
||
|
||
if (remainingAdvantage < 0) remainingAdvantage = 0
|
||
|
||
return remainingAdvantage.toFixed(1)
|
||
}
|
||
|
||
const getRadarOption = (radarData: any) => {
|
||
return {
|
||
backgroundColor: 'transparent',
|
||
tooltip: {},
|
||
legend: {
|
||
data: ['Current Capabilities', 'Projected (With Tech)'],
|
||
textStyle: { color: '#ccc' },
|
||
bottom: 0
|
||
},
|
||
radar: {
|
||
indicator: [
|
||
{ name: 'Efficiency', max: 10 },
|
||
{ name: 'Cost', max: 10 },
|
||
{ name: 'UX', max: 10 },
|
||
{ name: 'Innovation', max: 10 },
|
||
{ name: 'Market', max: 10 }
|
||
],
|
||
shape: 'circle',
|
||
splitNumber: 5,
|
||
axisName: {
|
||
color: '#63e2b7'
|
||
},
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: [
|
||
'rgba(99, 226, 183, 0.1)',
|
||
'rgba(99, 226, 183, 0.2)',
|
||
'rgba(99, 226, 183, 0.4)',
|
||
'rgba(99, 226, 183, 0.6)',
|
||
'rgba(99, 226, 183, 0.8)',
|
||
'rgba(99, 226, 183, 1)'
|
||
].reverse()
|
||
}
|
||
},
|
||
splitArea: {
|
||
show: false
|
||
},
|
||
axisLine: {
|
||
lineStyle: {
|
||
color: 'rgba(99, 226, 183, 0.5)'
|
||
}
|
||
}
|
||
},
|
||
series: [
|
||
{
|
||
name: 'Competitiveness Comparison',
|
||
type: 'radar',
|
||
data: [
|
||
{
|
||
value: radarData.current || [5, 5, 5, 5, 5],
|
||
name: 'Current Capabilities',
|
||
itemStyle: { color: '#f5222d' },
|
||
areaStyle: { color: 'rgba(245, 34, 45, 0.3)' }
|
||
},
|
||
{
|
||
value: radarData.projected || [8, 8, 8, 8, 8],
|
||
name: 'Projected (With Tech)',
|
||
itemStyle: { color: '#63e2b7' },
|
||
areaStyle: { color: 'rgba(99, 226, 183, 0.3)' }
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
// --- Typewriter Effect ---
|
||
const startTypewriter = async (fullData: any) => {
|
||
typing.value = true
|
||
// Reset
|
||
displayedAnalysis.situation_analysis = ''
|
||
displayedAnalysis.recommendation = ''
|
||
displayedAnalysis.expected_benefits = ''
|
||
|
||
if (!fullData) {
|
||
typing.value = false
|
||
return
|
||
}
|
||
|
||
// Cancel any previous typewriter
|
||
const currentTypewriterId = Date.now()
|
||
;(window as any)._typewriterId = currentTypewriterId
|
||
|
||
const typeField = async (key: 'situation_analysis' | 'recommendation' | 'expected_benefits') => {
|
||
const text = fullData[key] || ''
|
||
// If text is already displayed (e.g. from previous run), don't retype if it's the same?
|
||
// But we cleared it.
|
||
// Optimization: if text is same as displayed, skip? No, we want the effect.
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
if ((window as any)._typewriterId !== currentTypewriterId) return // Cancelled
|
||
displayedAnalysis[key] += text[i]
|
||
await new Promise(r => setTimeout(r, 10)) // Slower typing to reduce CPU load
|
||
}
|
||
}
|
||
|
||
await typeField('situation_analysis')
|
||
await typeField('recommendation')
|
||
await typeField('expected_benefits')
|
||
|
||
if ((window as any)._typewriterId === currentTypewriterId) {
|
||
typing.value = false
|
||
}
|
||
}
|
||
|
||
// Watch selected signal to trigger typewriter
|
||
watch(selectedSignal, (newVal, oldVal) => {
|
||
if (newVal) {
|
||
// Reset deployment state for new signal
|
||
if (!oldVal || newVal.id !== oldVal.id) {
|
||
taskDeployed.value = false
|
||
taskExecuting.value = false
|
||
resetFeedback() // Reset feedback state for new signal
|
||
|
||
// Load existing feedback if any
|
||
loadSignalFeedback(newVal.id)
|
||
|
||
// Trigger compute reservation animation
|
||
triggerComputeReservation(newVal)
|
||
}
|
||
|
||
// Optimization: If ID is same, don't re-trigger typewriter unless content changed significantly
|
||
if (oldVal && newVal.id === oldVal.id) {
|
||
// Check if strategic analysis content changed
|
||
if (newVal.strategic_analysis === oldVal.strategic_analysis) {
|
||
return
|
||
}
|
||
}
|
||
|
||
const info = getStrategicInfo(newVal)
|
||
if (info.decision_proposal) {
|
||
startTypewriter(info.decision_proposal)
|
||
} else {
|
||
startTypewriter(null)
|
||
}
|
||
}
|
||
})
|
||
|
||
// Load existing feedback for a signal
|
||
const loadSignalFeedback = async (signalId: string) => {
|
||
try {
|
||
const response = await axios.get(`http://127.0.0.1:8000/signals/${signalId}/feedbacks`)
|
||
if (response.data && response.data.length > 0) {
|
||
const latestFeedback = response.data[0]
|
||
signalFeedback.value = latestFeedback
|
||
feedbackSubmitted.value = true
|
||
feedbackMatches.value = latestFeedback.is_correct
|
||
}
|
||
} catch (error) {
|
||
// No feedback exists yet, that's fine
|
||
console.log('No existing feedback for this signal')
|
||
}
|
||
}
|
||
|
||
// Compute Reservation Animation
|
||
const triggerComputeReservation = (signal: any) => {
|
||
// Calculate reserved compute based on signal impact
|
||
const impact = signal.impact_score || 0
|
||
const reserved = Math.round(impact * 25) // Up to 25% reserved for high impact
|
||
|
||
isReservingCompute.value = true
|
||
reservedComputePercent.value = 0
|
||
|
||
// Animate the reservation
|
||
const duration = 800
|
||
const steps = 20
|
||
const increment = reserved / steps
|
||
const stepDuration = duration / steps
|
||
|
||
let currentStep = 0
|
||
const animateReservation = () => {
|
||
if (currentStep < steps) {
|
||
reservedComputePercent.value = Math.round((currentStep + 1) * increment)
|
||
currentStep++
|
||
setTimeout(animateReservation, stepDuration)
|
||
} else {
|
||
// Hold for a moment, then fade out
|
||
setTimeout(() => {
|
||
isReservingCompute.value = false
|
||
reservedComputePercent.value = 0
|
||
}, 500)
|
||
}
|
||
}
|
||
|
||
animateReservation()
|
||
}
|
||
|
||
// --- Signal Handling ---
|
||
|
||
const isNewSignal = (signal: any) => {
|
||
// Logic to determine if signal is "new" (e.g. created within last 10 seconds)
|
||
const created = new Date(signal.created_at).getTime()
|
||
const now = new Date().getTime()
|
||
return (now - created) < 10000 // 10 seconds flash window
|
||
}
|
||
|
||
const fetchSignals = async (isPoll = false) => {
|
||
if (!isPoll) loading.value = true
|
||
try {
|
||
const res = await axios.get('http://127.0.0.1:8000/signals/')
|
||
const newSignals = res.data
|
||
|
||
// Check for task status updates if polling
|
||
if (isPoll && signals.value.length > 0) {
|
||
// Find differences in tasks
|
||
for (const newSig of newSignals) {
|
||
const oldSig = signals.value.find(s => s.id === newSig.id)
|
||
if (oldSig) {
|
||
const newTasks = getGeneratedTasks(newSig)
|
||
const oldTasks = getGeneratedTasks(oldSig)
|
||
|
||
if (newTasks.length > 0 && oldTasks.length > 0) {
|
||
for (const newTask of newTasks) {
|
||
const oldTask = oldTasks.find((t: any) => (t.plane_id && t.plane_id === newTask.plane_id) || t.task_name === newTask.task_name)
|
||
|
||
// Detect status change to Done
|
||
if (oldTask && oldTask.status !== 'Done' && newTask.status === 'Done') {
|
||
// Trigger Notification
|
||
message.success(`✅ 任务已就绪:${newTask.task_name} (AI 审计通过)`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Smart update to avoid replacing objects if not needed (preserves references for selectedSignal)
|
||
// Map new signals to old ones if ID matches
|
||
const updatedList = newSignals.map((newSig: any) => {
|
||
const oldSig = signals.value.find(s => s.id === newSig.id)
|
||
if (oldSig) {
|
||
// Update properties of old object to keep reactivity stable if possible,
|
||
// BUT Vue 3 refs replace objects.
|
||
// Better: Replace the object but if it is the selected one, update selectedSignal ref too.
|
||
return newSig
|
||
}
|
||
return newSig
|
||
})
|
||
|
||
signals.value = updatedList
|
||
|
||
// Fix selectedSignal reference
|
||
if (selectedSignal.value) {
|
||
const stillExists = signals.value.find(s => s.id === selectedSignal.value.id)
|
||
if (stillExists) {
|
||
// Only update reference if content changed to trigger watcher only when needed
|
||
// But we need to ensure the selectedSignal ref points to the new object so computed properties update
|
||
if (JSON.stringify(stillExists) !== JSON.stringify(selectedSignal.value)) {
|
||
selectedSignal.value = stillExists
|
||
}
|
||
}
|
||
} else if (signals.value.length > 0 && !isPoll) {
|
||
// Auto select first if none selected (only on initial load)
|
||
selectedSignal.value = signals.value[0]
|
||
}
|
||
|
||
} catch (error) {
|
||
if (!isPoll) message.error('获取信号列表失败')
|
||
} finally {
|
||
if (!isPoll) loading.value = false
|
||
}
|
||
}
|
||
|
||
const selectSignal = (signal: any) => {
|
||
// If clicking same signal, do nothing
|
||
if (selectedSignal.value && selectedSignal.value.id === signal.id) return
|
||
selectedSignal.value = signal
|
||
}
|
||
|
||
const submitSignal = async () => {
|
||
if (!formValue.value.content) return
|
||
submitting.value = true
|
||
try {
|
||
const res = await axios.post('http://127.0.0.1:8000/signals/ingest', formValue.value)
|
||
message.success('信号已注入,战略评估完成!')
|
||
showModal.value = false
|
||
await fetchSignals()
|
||
// Auto select the new signal if it's in the list
|
||
const newSignal = signals.value.find((s: any) => s.id === res.data.id)
|
||
if (newSignal) {
|
||
selectedSignal.value = newSignal
|
||
}
|
||
|
||
// 停止 Ollama 节省资源
|
||
await axios.post('http://127.0.0.1:8000/ai/stop')
|
||
ollamaReady.value = false
|
||
message.info('🌱 Ollama 已停止,节省资源')
|
||
} catch (error) {
|
||
message.error('信号注入失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// 检查 Ollama 状态
|
||
const checkOllamaStatus = async () => {
|
||
try {
|
||
const response = await axios.get('http://127.0.0.1:8000/ai/status', { timeout: 3000 })
|
||
ollamaReady.value = response.data.status === 'ready'
|
||
const modelCount = response.data.model_count || 0
|
||
const models = response.data.available_models || []
|
||
message.info(ollamaReady.value ? `✅ Ollama 已就绪 (${modelCount} 个模型: ${models.join(', ')})` : '❌ Ollama 未运行')
|
||
} catch (error) {
|
||
ollamaReady.value = false
|
||
message.warning('⚠️ 无法检测 Ollama 状态,请确保后端服务运行中')
|
||
}
|
||
}
|
||
|
||
// 启动 AI 分析
|
||
const startAnalysis = async () => {
|
||
// 先显示进度条
|
||
analyzing.value = true
|
||
analysisProgress.value = 0
|
||
analysisStatus.value = '正在启动 AI 分析...'
|
||
|
||
// 如果 Ollama 未就绪,给出提示但继续尝试
|
||
if (!ollamaReady.value) {
|
||
analysisStatus.value = '⚠️ 未检测到 Ollama,尝试连接...'
|
||
}
|
||
|
||
try {
|
||
// 模拟进度动画
|
||
const progressInterval = setInterval(() => {
|
||
if (analysisProgress.value < 90) {
|
||
analysisProgress.value += Math.random() * 10
|
||
if (analysisProgress.value > 90) analysisProgress.value = 90
|
||
}
|
||
}, 500)
|
||
|
||
// 调用后端获取并分析信号
|
||
analysisStatus.value = '正在获取 HackerNews 信号...'
|
||
const response = await axios.post('http://127.0.0.1:8000/signals/fetch')
|
||
|
||
clearInterval(progressInterval)
|
||
analysisProgress.value = 100
|
||
|
||
const newCount = response.data.new_signals_count || 0
|
||
const processedCount = response.data.processed_count || 0
|
||
analysisStatus.value = `分析完成!获取 ${newCount} 个信号,AI 研判 ${processedCount} 个`
|
||
|
||
message.success(`✅ 分析完成!获取 ${newCount} 个信号,AI 研判 ${processedCount} 个`)
|
||
|
||
// 刷新信号列表
|
||
await fetchSignals()
|
||
|
||
// 停止 Ollama 节省资源
|
||
analysisStatus.value = '正在停止 Ollama...'
|
||
await axios.post('http://127.0.0.1:8000/ai/stop')
|
||
ollamaReady.value = false
|
||
|
||
analysisStatus.value = `完成!Ollama 已停止`
|
||
|
||
setTimeout(() => {
|
||
analyzing.value = false
|
||
analysisProgress.value = 0
|
||
}, 3000)
|
||
} catch (error: any) {
|
||
analyzing.value = false
|
||
analysisProgress.value = 0
|
||
const errorMsg = error.response?.data?.detail || error.message || '未知错误'
|
||
message.error(`分析失败: ${errorMsg}`)
|
||
if (errorMsg.includes('ollama') || errorMsg.includes('11434')) {
|
||
ollamaReady.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// Load competitors from localStorage
|
||
const saved = localStorage.getItem('competitors')
|
||
if (saved) {
|
||
try {
|
||
competitors.value = JSON.parse(saved)
|
||
} catch (e) {
|
||
console.error('Failed to parse competitors from localStorage:', e)
|
||
}
|
||
}
|
||
fetchSignals()
|
||
|
||
// Poll signals every 5 seconds (reduced from 1s for better performance)
|
||
pollingInterval = setInterval(() => {
|
||
fetchSignals(true)
|
||
}, 5000)
|
||
|
||
// Update countdown separately every second
|
||
countdownInterval = setInterval(() => {
|
||
updateLeadWindow()
|
||
}, 1000)
|
||
|
||
// 检查 Ollama 状态
|
||
checkOllamaStatus()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (pollingInterval) clearInterval(pollingInterval)
|
||
if (countdownInterval) clearInterval(countdownInterval)
|
||
if (deployTimeout) clearTimeout(deployTimeout)
|
||
// Cancel typewriter effect
|
||
;(window as any)._typewriterId = null
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.moat-card {
|
||
background: linear-gradient(90deg, rgba(30, 41, 59, 0.6) 0%, rgba(30, 41, 59, 0.3) 100%);
|
||
border: 1px solid rgba(99, 226, 183, 0.2);
|
||
}
|
||
|
||
.moat-container {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 12px;
|
||
}
|
||
|
||
.moat-metric {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.moat-subtitle {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.metric-unit {
|
||
font-size: 14px;
|
||
color: #94a3b8;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.metric-icon {
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.moat-timeline {
|
||
flex: 1;
|
||
margin-left: 48px;
|
||
padding-top: 8px;
|
||
}
|
||
|
||
/* Override Step Colors for Dark Mode */
|
||
:deep(.n-step-indicator) {
|
||
background-color: transparent !important;
|
||
border-color: #63e2b7 !important;
|
||
}
|
||
:deep(.n-step-content-header__title) {
|
||
color: #e2e8f0 !important;
|
||
}
|
||
:deep(.n-step-content-description) {
|
||
color: #94a3b8 !important;
|
||
}
|
||
:deep(.n-statistic .n-statistic-value__content) {
|
||
color: #63e2b7;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* --- */
|
||
|
||
.window-countdown {
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(245, 34, 45, 0.1);
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(245, 34, 45, 0.3);
|
||
}
|
||
|
||
.countdown-icon {
|
||
font-size: 12px;
|
||
margin-right: 4px;
|
||
animation: spin 2s linear infinite;
|
||
}
|
||
|
||
.countdown-val {
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
color: #f5222d;
|
||
font-family: monospace;
|
||
}
|
||
|
||
@keyframes spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.authorize-btn {
|
||
font-weight: 800;
|
||
letter-spacing: 1px;
|
||
box-shadow: 0 0 20px rgba(208, 48, 80, 0.4);
|
||
transition: all 0.3s ease;
|
||
height: 56px; /* Taller button */
|
||
font-size: 16px;
|
||
}
|
||
|
||
.authorize-btn:hover:not(:disabled) {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 0 30px rgba(208, 48, 80, 0.6);
|
||
}
|
||
|
||
.authorize-icon {
|
||
margin-right: 8px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
/* Command Center Global Styles */
|
||
.scan-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: rgba(99, 226, 183, 0.1);
|
||
z-index: 9999;
|
||
pointer-events: none;
|
||
animation: scanScreen 1.5s ease-in-out;
|
||
}
|
||
|
||
@keyframes scanScreen {
|
||
0% { background: rgba(99, 226, 183, 0); clip-path: inset(0 0 100% 0); }
|
||
50% { background: rgba(99, 226, 183, 0.2); clip-path: inset(0 0 0 0); }
|
||
100% { background: rgba(99, 226, 183, 0); clip-path: inset(100% 0 0 0); }
|
||
}
|
||
|
||
.strategy-room {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
}
|
||
|
||
.command-center {
|
||
height: 100vh;
|
||
background-color: #020617; /* Dark Blue Background */
|
||
color: #e2e8f0;
|
||
font-family: 'Inter', sans-serif;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Glassmorphism Panels */
|
||
.glass-panel, .glass-sider, .glass-content, .glass-card {
|
||
background: rgba(30, 41, 59, 0.4);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.glass-sider {
|
||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Status Bar */
|
||
.status-bar {
|
||
min-height: 64px;
|
||
height: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 24px;
|
||
margin-bottom: 16px;
|
||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
/* CSS Text Truncation and Layout Fixes */
|
||
.status-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0 16px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||
min-width: 0; /* Enable flex item shrinking */
|
||
}
|
||
|
||
.status-label {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.status-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #e2e8f0;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.progress-comparison {
|
||
margin-top: 4px;
|
||
width: 100%;
|
||
}
|
||
|
||
.progress-row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 2px;
|
||
width: 100%;
|
||
}
|
||
|
||
.progress-row .label {
|
||
font-size: 10px;
|
||
color: #94a3b8;
|
||
width: 32px; /* Fixed width for label */
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Ensure progress bar takes remaining space */
|
||
.progress-row :deep(.n-progress) {
|
||
flex: 1;
|
||
width: 0; /* Allow flex shrink */
|
||
min-width: 0;
|
||
}
|
||
|
||
/* AI 分析区域样式 */
|
||
.status-item :deep(.n-progress) {
|
||
--n-rail-color: rgba(255, 255, 255, 0.1) !important;
|
||
--n-fill-color: #63e2b7 !important;
|
||
}
|
||
|
||
.lead-time-text {
|
||
font-size: 10px;
|
||
color: #63e2b7;
|
||
text-align: right;
|
||
margin-top: 2px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.highlight-green {
|
||
color: #63e2b7;
|
||
text-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
/* Compute Load Display */
|
||
.compute-load-item {
|
||
min-width: 140px;
|
||
}
|
||
|
||
.compute-load-display {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.status-value.reserved {
|
||
color: #f0a020;
|
||
text-shadow: 0 0 10px rgba(240, 160, 32, 0.5);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.reserved-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 6px;
|
||
background: rgba(240, 160, 32, 0.15);
|
||
border-radius: 4px;
|
||
border: 1px solid rgba(240, 160, 32, 0.4);
|
||
animation: reservedPulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.reserved-dot {
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: #f0a020;
|
||
animation: dotBlink 0.8s ease-in-out infinite;
|
||
}
|
||
|
||
.reserved-text {
|
||
font-size: 10px;
|
||
color: #f0a020;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
@keyframes reservedPulse {
|
||
0%, 100% { opacity: 0.8; }
|
||
50% { opacity: 1; box-shadow: 0 0 8px rgba(240, 160, 32, 0.3); }
|
||
}
|
||
|
||
@keyframes dotBlink {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
.compute-bar {
|
||
position: relative;
|
||
height: 4px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.compute-bar-fill {
|
||
position: absolute;
|
||
height: 100%;
|
||
border-radius: 2px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.compute-bar-fill.current {
|
||
background: linear-gradient(90deg, #63e2b7, #18a058);
|
||
left: 0;
|
||
}
|
||
|
||
.compute-bar-fill.reserved {
|
||
background: linear-gradient(90deg, #f0a020, #f59e0b);
|
||
left: 0;
|
||
opacity: 0.7;
|
||
animation: reservedFill 1s ease-in-out infinite alternate;
|
||
}
|
||
|
||
@keyframes reservedFill {
|
||
0% { opacity: 0.5; }
|
||
100% { opacity: 0.8; }
|
||
}
|
||
|
||
/* Signal List */
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.signal-list .n-list-item {
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
margin-bottom: 8px;
|
||
border: 1px solid transparent;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.signal-list .n-list-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.selected-item {
|
||
background: rgba(99, 226, 183, 0.1) !important;
|
||
border: 1px solid rgba(99, 226, 183, 0.3) !important;
|
||
}
|
||
|
||
.new-signal-flash {
|
||
animation: flashRed 2s infinite;
|
||
}
|
||
|
||
@keyframes flashRed {
|
||
0% { box-shadow: 0 0 0 rgba(208, 48, 80, 0); }
|
||
50% { box-shadow: 0 0 15px rgba(208, 48, 80, 0.4); border-color: rgba(208, 48, 80, 0.6); }
|
||
100% { box-shadow: 0 0 0 rgba(208, 48, 80, 0); }
|
||
}
|
||
|
||
.signal-content {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
/* Empty State Radar */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
height: 100%;
|
||
}
|
||
|
||
.radar-scan {
|
||
width: 100px;
|
||
height: 100px;
|
||
border: 2px solid #63e2b7;
|
||
border-radius: 50%;
|
||
position: relative;
|
||
box-shadow: 0 0 20px rgba(99, 226, 183, 0.2);
|
||
}
|
||
|
||
.priority-legend {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.radar-scan::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 50%;
|
||
height: 50%;
|
||
background: linear-gradient(45deg, rgba(99, 226, 183, 0.5), transparent 50%);
|
||
transform-origin: top left;
|
||
animation: scan 2s linear infinite;
|
||
border-radius: 100% 0 0 0;
|
||
}
|
||
|
||
@keyframes scan {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
/* Strategy Room */
|
||
.ai-analysis-card {
|
||
height: 100%;
|
||
background: rgba(15, 23, 42, 0.6);
|
||
}
|
||
|
||
.card-title {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 18px;
|
||
color: #63e2b7;
|
||
}
|
||
|
||
/* Confidence tag styles */
|
||
.card-title :deep(.n-tag.success) {
|
||
background: rgba(24, 160, 88, 0.2);
|
||
border-color: rgba(24, 160, 88, 0.5);
|
||
}
|
||
|
||
.card-title :deep(.n-tag.warning) {
|
||
background: rgba(240, 160, 32, 0.2);
|
||
border-color: rgba(240, 160, 32, 0.5);
|
||
}
|
||
|
||
.card-title :deep(.n-tag.default) {
|
||
background: rgba(148, 163, 184, 0.2);
|
||
border-color: rgba(148, 163, 184, 0.5);
|
||
}
|
||
|
||
/* Priority tag special styles */
|
||
.card-title :deep(.n-tag.error) {
|
||
/* CRITICAL priority */
|
||
background: rgba(208, 48, 80, 0.2);
|
||
border-color: rgba(208, 48, 80, 0.5);
|
||
animation: pulse-critical 2s infinite;
|
||
}
|
||
|
||
.card-title :deep(.n-tag.warning) {
|
||
/* HIGH priority */
|
||
background: rgba(240, 160, 32, 0.2);
|
||
border-color: rgba(240, 160, 32, 0.5);
|
||
}
|
||
|
||
.card-title :deep(.n-tag.info) {
|
||
/* MEDIUM priority */
|
||
background: rgba(45, 150, 250, 0.2);
|
||
border-color: rgba(45, 150, 250, 0.5);
|
||
}
|
||
|
||
@keyframes pulse-critical {
|
||
0%, 100% { box-shadow: 0 0 5px rgba(208, 48, 80, 0.3); }
|
||
50% { box-shadow: 0 0 15px rgba(208, 48, 80, 0.6); }
|
||
}
|
||
|
||
.typewriter-text {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
line-height: 1.6;
|
||
color: #e2e8f0;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.highlight-text {
|
||
color: #63e2b7;
|
||
font-weight: bold;
|
||
background: rgba(99, 226, 183, 0.1);
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
border-left: 3px solid #63e2b7;
|
||
}
|
||
|
||
/* Mission Board */
|
||
.mission-board {
|
||
height: 100%;
|
||
background: rgba(15, 23, 42, 0.6);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.mission-board.executing {
|
||
border: 1px solid rgba(99, 226, 183, 0.5);
|
||
box-shadow: 0 0 20px rgba(99, 226, 183, 0.2);
|
||
}
|
||
|
||
.executing-tag {
|
||
position: relative;
|
||
}
|
||
|
||
.executing-dot {
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: #f0a020;
|
||
margin-right: 6px;
|
||
animation: executingPulse 1s infinite;
|
||
}
|
||
|
||
@keyframes executingPulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.5; transform: scale(1.2); }
|
||
}
|
||
|
||
.task-item {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
margin-bottom: 8px;
|
||
border-radius: 6px;
|
||
padding: 10px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.task-item.pulse-active {
|
||
animation: taskPulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes taskPulse {
|
||
0%, 100% { background: rgba(255, 255, 255, 0.03); }
|
||
50% { background: rgba(99, 226, 183, 0.08); }
|
||
}
|
||
|
||
.progress-pulse :deep(.n-progress-graph-line-fill) {
|
||
animation: progressPulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes progressPulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.7; }
|
||
}
|
||
|
||
.task-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.task-name {
|
||
font-weight: 500;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.task-hours {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.glass-modal {
|
||
background: rgba(30, 41, 59, 0.9) !important;
|
||
backdrop-filter: blur(20px);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
/* Strategy Sandbox Styles */
|
||
.sandbox-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.sandbox-step {
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.final-step {
|
||
background: rgba(99, 226, 183, 0.1);
|
||
border-color: rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
.step-icon {
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.step-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.step-title {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.step-desc {
|
||
font-size: 14px;
|
||
color: #e2e8f0;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.step-arrow {
|
||
text-align: center;
|
||
color: #63e2b7;
|
||
font-size: 18px;
|
||
opacity: 0.5;
|
||
margin: -8px 0;
|
||
}
|
||
|
||
.impact-visual {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.impact-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.bar-label {
|
||
width: 60px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.bar-fill {
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: #63e2b7;
|
||
text-align: right;
|
||
padding-right: 4px;
|
||
line-height: 6px;
|
||
color: transparent; /* Hide text, just visual */
|
||
}
|
||
|
||
.impact-bar.loss .bar-fill {
|
||
background: #f5222d;
|
||
}
|
||
|
||
/* Sandbox Flowchart */
|
||
.sandbox-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.sandbox-step {
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
width: 100%;
|
||
margin-bottom: 8px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.sandbox-step:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
.final-step {
|
||
border-color: #63e2b7;
|
||
background: rgba(99, 226, 183, 0.05);
|
||
}
|
||
|
||
.step-icon {
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.step-content {
|
||
flex: 1;
|
||
}
|
||
|
||
.step-title {
|
||
font-size: 12px;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.step-desc {
|
||
font-size: 14px;
|
||
color: #e2e8f0;
|
||
}
|
||
|
||
.step-arrow {
|
||
color: rgba(255, 255, 255, 0.2);
|
||
font-size: 16px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* Impact Visual */
|
||
.impact-visual {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
width: 100%;
|
||
}
|
||
|
||
.impact-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 10px;
|
||
}
|
||
|
||
.impact-bar .bar-label {
|
||
width: 60px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.impact-bar .bar-fill {
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: #333;
|
||
position: relative;
|
||
transition: width 1s ease;
|
||
}
|
||
|
||
.impact-bar.loss .bar-fill {
|
||
background: #f5222d;
|
||
}
|
||
|
||
.impact-bar.gain .bar-fill {
|
||
background: #63e2b7;
|
||
}
|
||
|
||
/* RAG Sources */
|
||
.rag-sources {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.ai-annotation {
|
||
font-size: 11px;
|
||
color: #63e2b7;
|
||
font-style: italic;
|
||
margin-top: 4px;
|
||
animation: fadeIn 1s ease-in;
|
||
}
|
||
|
||
.clickable-tag {
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.clickable-tag:hover {
|
||
transform: translateX(4px);
|
||
box-shadow: 0 0 10px rgba(99, 226, 183, 0.4);
|
||
}
|
||
|
||
/* Dynamic RAG Section */
|
||
.dynamic-rag-section {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* Tech Stacks Section */
|
||
.tech-stacks-section {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.rag-section-title {
|
||
font-size: 11px;
|
||
color: #a0a0a0;
|
||
margin-bottom: 6px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tech-stacks-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tech-stack-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.tech-stack-tag:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 2px 8px rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
.tech-icon {
|
||
font-size: 12px;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
/* Knowledge Nodes Section */
|
||
.knowledge-nodes-section {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.knowledge-nodes-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.knowledge-node-item {
|
||
padding: 8px 10px;
|
||
background: rgba(99, 226, 183, 0.08);
|
||
border: 1px solid rgba(99, 226, 183, 0.2);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.knowledge-node-item:hover {
|
||
background: rgba(99, 226, 183, 0.12);
|
||
border-color: rgba(99, 226, 183, 0.4);
|
||
transform: translateX(4px);
|
||
box-shadow: 0 2px 8px rgba(99, 226, 183, 0.2);
|
||
}
|
||
|
||
.node-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.node-icon {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.node-title {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #e0e0e0;
|
||
flex: 1;
|
||
}
|
||
|
||
.node-summary {
|
||
font-size: 11px;
|
||
color: #a0a0a0;
|
||
line-height: 1.4;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.ai-annotation.no-data {
|
||
color: #888;
|
||
font-style: italic;
|
||
padding: 10px;
|
||
background: rgba(99, 226, 183, 0.08);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.rag-section-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #63e2b7;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
/* Voting Details Section */
|
||
.voting-details {
|
||
margin: 16px 0;
|
||
padding: 12px;
|
||
background: rgba(99, 226, 183, 0.05);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(99, 226, 183, 0.15);
|
||
}
|
||
|
||
.voting-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.voting-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #63e2b7;
|
||
}
|
||
|
||
.voting-models {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.voting-model-item {
|
||
flex: 1;
|
||
padding: 8px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-radius: 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.model-name {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.model-vote {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.voting-summary {
|
||
padding-top: 8px;
|
||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||
}
|
||
|
||
/* Fact Snippet Modal */
|
||
.fact-snippet-modal {
|
||
border: 1px solid rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
.fact-icon {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.fact-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #63e2b7;
|
||
}
|
||
|
||
.fact-content {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.fact-source {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.fact-divider {
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.fact-text {
|
||
font-size: 13px;
|
||
color: #e2e8f0;
|
||
line-height: 1.8;
|
||
white-space: pre-line;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
border-left: 3px solid #63e2b7;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
/* ROI Scorecard */
|
||
.roi-scorecard {
|
||
background: linear-gradient(135deg, rgba(99, 226, 183, 0.1) 0%, rgba(30, 41, 59, 0.6) 100%);
|
||
border: 1px solid rgba(99, 226, 183, 0.3);
|
||
border-radius: 12px;
|
||
padding: 16px;
|
||
margin-top: 16px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.roi-scorecard::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, transparent, #63e2b7, transparent);
|
||
animation: shimmer 2s infinite;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { opacity: 0.3; }
|
||
50% { opacity: 1; }
|
||
100% { opacity: 0.3; }
|
||
}
|
||
|
||
.roi-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.roi-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #63e2b7;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.roi-metrics {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
gap: 16px;
|
||
}
|
||
|
||
.roi-metric-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
padding: 12px 8px;
|
||
border-radius: 8px;
|
||
background: rgba(15, 23, 42, 0.5);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.roi-metric-item:hover {
|
||
transform: translateY(-2px);
|
||
background: rgba(15, 23, 42, 0.8);
|
||
}
|
||
|
||
.roi-metric-item.highlight {
|
||
background: rgba(99, 226, 183, 0.15);
|
||
border: 1px solid rgba(99, 226, 183, 0.3);
|
||
}
|
||
|
||
.roi-label {
|
||
font-size: 11px;
|
||
color: #94a3b8;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.roi-value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.roi-value.primary {
|
||
color: #63e2b7;
|
||
text-shadow: 0 0 10px rgba(99, 226, 183, 0.5);
|
||
}
|
||
|
||
.roi-value.warning {
|
||
color: #f0a020;
|
||
text-shadow: 0 0 10px rgba(240, 160, 32, 0.5);
|
||
}
|
||
|
||
.roi-value.success {
|
||
color: #18a058;
|
||
text-shadow: 0 0 10px rgba(24, 160, 88, 0.5);
|
||
}
|
||
|
||
.roi-sub {
|
||
font-size: 10px;
|
||
color: #64748b;
|
||
}
|
||
|
||
.roi-divider {
|
||
width: 1px;
|
||
height: 40px;
|
||
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||
}
|
||
|
||
/* Command Gate */
|
||
.command-gate {
|
||
margin-top: 24px;
|
||
width: 100%;
|
||
}
|
||
|
||
.deploy-btn {
|
||
font-weight: bold;
|
||
letter-spacing: 1px;
|
||
height: 48px;
|
||
font-size: 16px;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.deploy-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(245, 34, 45, 0.4);
|
||
}
|
||
|
||
.mission-content {
|
||
transition: filter 0.3s ease;
|
||
}
|
||
|
||
.mission-content.blurred {
|
||
filter: blur(4px);
|
||
pointer-events: none;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.mission-container {
|
||
position: relative;
|
||
height: 100%;
|
||
}
|
||
|
||
.mission-locked-overlay {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
z-index: 10;
|
||
text-align: center;
|
||
}
|
||
|
||
.lock-icon {
|
||
font-size: 32px;
|
||
margin-bottom: 8px;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
.lock-text {
|
||
color: #e2e8f0;
|
||
font-weight: bold;
|
||
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||
background: rgba(0,0,0,0.6);
|
||
padding: 8px 16px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
}
|
||
|
||
/* Dynamic Node 3 Pulse */
|
||
.pulsing-icon {
|
||
animation: pulse 1.5s infinite;
|
||
color: #63e2b7;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% { transform: scale(1); opacity: 0.8; }
|
||
50% { transform: scale(1.1); opacity: 1; }
|
||
100% { transform: scale(1); opacity: 0.8; }
|
||
}
|
||
|
||
.task-list {
|
||
height: calc(100vh - 250px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* Feedback Section */
|
||
.feedback-section {
|
||
margin-top: 16px;
|
||
padding: 12px;
|
||
background: rgba(148, 163, 184, 0.05);
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||
}
|
||
|
||
.feedback-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.feedback-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.feedback-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.feedback-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.feedback-result {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
|
||
</style>
|