web/src/views/UniversalAnalysisView.vue
2025-05-25 10:24:09 +08:00

1431 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { http } from '../services/api'
import { marked } from 'marked'
// 配置marked选项
onMounted(() => {
marked.setOptions({
breaks: true,
gfm: true, // GitHub风格Markdown支持表格等扩展语法
})
})
// 获取路由参数,确定分析类型
const route = useRoute()
const analysisType = computed(() => (route.params.type as string) || 'crypto')
// 分析标的代码
const symbolCode = ref('')
const isAnalyzing = ref(false)
const analysisContent = ref('')
const analysisContainer = ref<HTMLElement | null>(null)
const currentThought = ref('')
const showInitialView = ref(true)
const copySuccess = ref(false)
// 时间周期选项和选择值(加密货币模式下使用)
const timeframeOptions = [
{ value: '15m', label: '15分钟' },
{ value: '1h', label: '1小时' },
{ value: '4h', label: '4小时' },
{ value: '1d', label: '1天' },
{ value: '1w', label: '1周' },
]
const selectedTimeframe = ref('1h') // 默认改为1小时
// 获取时间周期的友好显示文本
const timeframeLabel = computed(() => {
const option = timeframeOptions.find((opt) => opt.value === selectedTimeframe.value)
return option ? option.label : selectedTimeframe.value
})
// 根据环境选择API基础URL
const apiBaseUrl =
import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work'
// 根据类型设置页面标题和预设列表
const pageTitle = computed(() => {
switch (analysisType.value) {
case 'stock':
return 'A股股票分析智能体'
case 'crypto':
default:
return '加密货币分析智能体'
}
})
const pageDescription = computed(() => {
switch (analysisType.value) {
case 'stock':
return '获取A股上市公司的深度AI智能分析'
case 'crypto':
default:
return '获取加密货币的深度AI智能分析'
}
})
const inputPlaceholder = computed(() => {
switch (analysisType.value) {
case 'stock':
return '请输入上市公司股票代码'
case 'crypto':
default:
return '请输入BTC、ETH 一次只能分析一个币种'
}
})
// 是否为股票分析模式
const isStockMode = computed(() => analysisType.value === 'stock')
// 常见标的列表
const commonItemsList = computed(() => {
switch (analysisType.value) {
case 'stock':
return [
{ code: '000001', label: '平安银行' },
{ code: '000651', label: '格力电器' },
{ code: '601318', label: '中国平安' },
{ code: '600519', label: '贵州茅台' },
{ code: '601888', label: '中国中免' },
{ code: '000858', label: '五粮液' },
{ code: '600276', label: '恒瑞医药' },
{ code: '002594', label: '比亚迪' },
{ code: '600036', label: '招商银行' },
{ code: '603288', label: '海天味业' },
]
case 'crypto':
default:
return [
{ code: 'BTC', label: 'BTC' },
{ code: 'ETH', label: 'ETH' },
{ code: 'SOL', label: 'SOL' },
{ code: 'SUI', label: 'SUI' },
{ code: 'TRX', label: 'TRX' },
{ code: 'XRP', label: 'XRP' },
{ code: 'BNB', label: 'BNB' },
{ code: 'ADA', label: 'ADA' },
{ code: 'DOGE', label: 'DOGE' },
{ code: 'SHIB', label: 'SHIB' },
]
}
})
// 滚动到底部
const scrollToBottom = async () => {
await nextTick()
if (analysisContainer.value) {
analysisContainer.value.scrollTop = analysisContainer.value.scrollHeight
}
}
// 处理回车键
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
handleAnalysis()
}
}
// 处理错误消息的显示
const setErrorMessage = (message: string) => {
analysisContent.value = `错误: ${message}`
currentThought.value = '分析失败'
}
// 保存分析结果到服务器
const saveAnalysisHistory = async () => {
try {
// 准备提交参数
const payload: Record<string, string> = {
content: analysisContent.value,
}
// 根据不同模式设置不同参数
if (isStockMode.value) {
payload.stock_code = symbolCode.value.trim()
payload.type = 'astock'
} else {
payload.symbol = symbolCode.value.toUpperCase().trim()
payload.timeframe = selectedTimeframe.value
payload.type = 'crypto'
}
// 使用http.post发送请求保存分析历史
const response = await http.post(`${apiBaseUrl}/analysis/analysis_history`, payload)
if (!response.ok) {
console.error('保存分析历史失败:', response.status)
}
} catch (error) {
console.error('保存分析历史异常:', error)
}
}
// 分析逻辑
const handleAnalysis = async () => {
const code = symbolCode.value.trim()
if (!code || isAnalyzing.value) return
// 股票模式下验证股票代码格式
if (isStockMode.value && !/^\d{6}$/.test(code)) {
setErrorMessage('请输入正确的6位股票代码')
return
}
// 先切换到分析视图,再设置分析状态
showInitialView.value = false
isAnalyzing.value = true
analysisContent.value = ''
currentThought.value = '准备开始分析...'
try {
let requestData
// 根据分析类型构造不同的请求参数
if (isStockMode.value) {
// A股分析参数
requestData = {
stock_code: code,
type: 'astock',
}
} else {
// 加密货币分析参数
requestData = {
symbol: code.toUpperCase(),
timeframe: selectedTimeframe.value,
type: 'crypto',
}
}
// 统一调用 /analysis/analysis 接口
const response = await http.post(`${apiBaseUrl}/analysis/analysis`, requestData)
if (!response.ok) {
// 解析错误响应
try {
const errorData = await response.json()
if (errorData && errorData.detail) {
setErrorMessage(errorData.detail)
} else {
setErrorMessage(`请求失败,状态码: ${response.status}`)
}
} catch {
setErrorMessage(`请求失败,状态码: ${response.status}`)
}
return
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法获取响应流')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue
try {
const jsonStr = line.slice(6)
const data = JSON.parse(jsonStr)
switch (data.event) {
case 'workflow_started':
currentThought.value = 'Agent 正在分析...'
break
case 'node_started':
currentThought.value = `Agent 调用: ${data.data.title}`
break
case 'node_finished':
currentThought.value = `Agent 完成: ${data.data.title}`
break
case 'node_failed':
currentThought.value = `Agent 分析失败 ${data.data.title}`
break
case 'text_chunk':
if (data.data && data.data.text) {
analysisContent.value += data.data.text
await scrollToBottom()
}
break
case 'workflow_finished':
if (data.data && data.data.outputs && data.data.outputs.text) {
if (analysisContent.value !== data.data.outputs.text) {
analysisContent.value = data.data.outputs.text
await scrollToBottom()
}
currentThought.value = `分析完成 (用时 ${Math.round(data.data.elapsed_time)}秒)`
// 分析完成后保存结果
await saveAnalysisHistory()
}
break
case 'error':
const errorMessage = data.error || '未知错误'
setErrorMessage(errorMessage)
await scrollToBottom()
break
}
} catch (error) {
console.error('解析响应数据出错:', error)
setErrorMessage('解析响应数据时出错,请稍后重试')
}
}
}
} catch {
setErrorMessage('抱歉,分析请求失败,请稍后重试')
} finally {
isAnalyzing.value = false
await scrollToBottom()
}
}
const resetView = () => {
showInitialView.value = true
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
isAnalyzing.value = false
// 重置为默认时间周期
selectedTimeframe.value = '1h'
}
// 清除输入
const clearInput = () => {
symbolCode.value = ''
analysisContent.value = ''
currentThought.value = ''
}
const copyAnalysis = async () => {
if (!analysisContent.value) return
try {
// 复制原始文本内容而不是渲染后的HTML
await navigator.clipboard.writeText(analysisContent.value)
copySuccess.value = true
setTimeout(() => {
copySuccess.value = false
}, 2000)
} catch {
console.error('复制失败')
}
}
// 处理快速分析项的点击
const handleCommonAnalysis = (item: { code: string }) => {
symbolCode.value = item.code
handleAnalysis()
}
// 监听路由变化,重置视图
watch(
() => route.params.type,
() => {
resetView()
},
)
// 解析markdown内容
const parsedContent = computed(() => {
if (!analysisContent.value || analysisContent.value.startsWith('错误:')) {
return ''
}
// 处理markdown内容
let html = marked(analysisContent.value) as string
// 将表格包装在div中以提供更好的滚动支持
html = html.replace(/<table>/g, '<div class="table-container"><table>')
html = html.replace(/<\/table>/g, '</table></div>')
return html
})
</script>
<template>
<div class="universal-analysis-view">
<div class="content-container">
<!-- 初始视图 -->
<div v-if="showInitialView" class="initial-content">
<div class="header-section">
<h1 class="title">{{ pageTitle }}</h1>
<p class="description">{{ pageDescription }}</p>
</div>
<div class="search-section">
<div class="search-container">
<div class="input-wrapper">
<div class="input-container">
<div class="input-area">
<input
v-model="symbolCode"
type="text"
class="search-input"
:placeholder="inputPlaceholder"
@keydown="handleKeydown"
:disabled="isAnalyzing"
:maxlength="isStockMode ? 6 : undefined"
/>
<button
v-if="symbolCode.trim() || analysisContent"
class="clear-button"
@click="clearInput"
:disabled="isAnalyzing"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- 加密货币模式下的时间周期选择 -->
<div v-if="!isStockMode" class="timeframe-selector">
<label class="timeframe-label">选择时间周期:</label>
<div class="timeframe-options">
<button
v-for="option in timeframeOptions"
:key="option.value"
class="timeframe-option"
:class="{ active: selectedTimeframe === option.value }"
@click="selectedTimeframe = option.value"
:disabled="isAnalyzing"
>
{{ option.label }}
</button>
</div>
</div>
<button
class="analyze-button"
@click="handleAnalysis"
:disabled="isAnalyzing || !symbolCode.trim()"
>
{{ isAnalyzing ? '正在跳转...' : '开始分析' }}
</button>
</div>
</div>
</div>
</div>
<!-- 快速分析列表 -->
<div class="common-analysis-section">
<div class="common-analysis-container">
<div class="section-header">
<span class="section-title">点击直接快速分析</span>
</div>
<div class="common-analysis-list">
<button
v-for="item in commonItemsList"
:key="item.label"
class="common-analysis-item"
@click="handleCommonAnalysis(item)"
:disabled="isAnalyzing"
>
{{ item.label }}
</button>
</div>
</div>
</div>
</div>
<!-- 分析视图 -->
<div v-else class="analysis-view">
<div class="analysis-header">
<div class="target-info">
<span class="label">分析目标:</span>
<span class="value">{{ symbolCode.toUpperCase() }}</span>
<span v-if="!isStockMode" class="label timeframe-info">时间周期:</span>
<span v-if="!isStockMode" class="value">{{ timeframeLabel }}</span>
</div>
<div class="action-buttons" v-if="!isAnalyzing">
<button class="action-button" @click="resetView">
<svg
class="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="22 12 18 12 15 12"></polyline>
<path d="M11.5 3a17 17 0 0 0-6.5 2.5L2 8"></path>
<path d="M2 3v5h5"></path>
<path d="M13.5 21a17 17 0 0 0 6.5-2.5L22 16"></path>
<path d="M22 21v-5h-5"></path>
</svg>
<span>重新分析</span>
</button>
<button
class="action-button copy"
@click="copyAnalysis"
:class="{ success: copySuccess }"
>
<span v-if="!copySuccess">复制结果</span>
<span v-else>复制成功</span>
</button>
</div>
</div>
<div v-if="isAnalyzing" class="analysis-status">
<div class="simple-loader"></div>
<div class="status-text">
<div class="thought-text" v-if="currentThought">{{ currentThought }}</div>
</div>
</div>
<div
class="analysis-container"
ref="analysisContainer"
:class="{ 'fade-in': analysisContent }"
>
<div v-if="analysisContent.startsWith('错误:')" class="error-message">
{{ analysisContent.substring(4) }}
</div>
<div v-else class="analysis-content markdown-content" v-html="parsedContent"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
:root {
--color-accent-rgb: 51, 85, 255; /* 假设accent颜色为#3355ff */
}
.universal-analysis-view {
min-height: 100vh;
height: 100vh;
background-color: var(--color-bg-primary);
padding: 1rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-container {
max-width: 800px;
margin: 0 auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: center;
}
.initial-content {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 15vh;
gap: 1.5rem;
transition: all 0.5s ease;
}
.header-section {
text-align: center;
transition: all 0.5s ease;
}
.title {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
transition: all 0.5s ease;
}
.description {
font-size: 0.95rem;
color: var(--color-text-secondary);
transition: all 0.5s ease;
}
.search-section {
width: 100%;
max-width: 800px;
transition: all 0.5s ease;
padding: 0 1.5rem;
}
.search-container {
position: relative;
padding: 1.5rem;
transition: all 0.3s ease;
}
.input-wrapper {
position: relative;
margin-bottom: 0;
}
.input-container {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 5.5rem;
border: 2px solid var(--color-accent);
border-radius: var(--border-radius);
background-color: var(--color-bg-primary);
padding: 0.75rem 1rem;
transition: all 0.3s ease;
gap: 0.75rem;
}
.input-area {
display: flex;
align-items: center;
width: 100%;
gap: 0.5rem;
}
.search-input {
flex: 1;
border: none;
background: none;
padding: 0;
font-size: 0.95rem;
color: var(--color-text-primary);
outline: none;
height: 2.5rem;
}
.clear-button {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--color-text-secondary);
opacity: 0.6;
transition: all 0.2s ease;
}
.clear-button:hover:not(:disabled) {
opacity: 1;
}
.clear-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.clear-button svg {
width: 16px;
height: 16px;
}
.analyze-button {
width: 100%;
padding: 0.75rem;
font-size: 0.9rem;
font-weight: 500;
color: white;
background-color: var(--color-accent);
border: none;
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.analyze-button:hover:not(:disabled) {
background-color: var(--color-accent-hover);
}
.analyze-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* 分析视图样式 */
.analysis-view {
display: flex;
flex-direction: column;
height: calc(100vh - 4.5rem);
padding: 0.5rem;
animation: fadeIn 0.3s ease-out;
}
.analysis-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
flex-shrink: 0;
}
.analysis-status {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding: 1rem;
text-align: left;
gap: 1rem;
margin-top: 1rem;
background: transparent;
border-radius: var(--border-radius);
}
.simple-loader {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text-secondary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-text {
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
}
.thought-text {
font-size: 0.95rem;
font-weight: 400;
color: var(--color-text-secondary);
position: relative;
padding-right: 0.5rem;
animation: none;
}
.analysis-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border-radius: var(--border-radius);
margin-top: 1rem;
display: flex;
flex-direction: column;
}
.analysis-container.fade-in {
background-color: var(--color-bg-secondary);
}
.analysis-content {
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text-primary);
padding: 1.5rem 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
.error-message {
color: #ff3333;
font-weight: 500;
padding: 1rem;
background-color: rgba(255, 51, 51, 0.08);
border: 1px solid rgba(255, 51, 51, 0.2);
border-radius: 0.5rem;
margin: 1rem;
text-align: center;
font-size: 1rem;
line-height: 1.5;
}
.error-message::before {
content: '⚠️ ';
}
.target-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.target-info .label {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.target-info .value {
color: var(--color-accent);
font-weight: 500;
font-size: 0.9rem;
}
.action-buttons {
display: flex;
gap: 1rem;
align-items: center;
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
background: transparent;
color: var(--color-accent);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
}
.action-button:hover {
background-color: var(--color-accent);
color: white;
}
.action-button.copy {
background-color: var(--color-accent);
color: white;
}
.action-button.copy:hover {
opacity: 0.9;
}
.action-button.copy.success {
background-color: #4caf50;
border-color: #4caf50;
}
.action-button .icon {
width: 16px;
height: 16px;
}
/* 快速分析列表样式 */
.common-analysis-section {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 0 1.5rem;
}
.common-analysis-container {
width: 100%;
padding: 0 1.5rem;
margin-bottom: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-title {
font-size: 0.9rem;
color: var(--color-text-secondary);
}
.common-analysis-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.common-analysis-item {
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
color: var(--color-accent);
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.common-analysis-item:hover:not(:disabled) {
background-color: var(--color-accent);
color: white;
}
.common-analysis-item:disabled {
opacity: 0.6;
cursor: not-allowed;
border-color: var(--color-border);
color: var(--color-text-secondary);
}
/* 动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.universal-analysis-view {
padding: 0.75rem;
}
.analysis-view {
padding: 0.5rem;
margin-top: 4.5rem;
}
.analysis-header {
padding: 1rem;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.action-buttons {
width: 100%;
flex-direction: row;
gap: 0.75rem;
}
.action-button {
flex: 1;
justify-content: center;
padding: 0.6rem;
font-size: 0.85rem;
}
.analysis-content {
padding: 1.25rem 1.5rem;
font-size: 0.95rem;
}
.initial-content {
margin-bottom: 12vh;
gap: 1.5rem;
}
.title {
font-size: 1.75rem;
}
.description {
font-size: 0.9rem;
}
.search-input {
font-size: 0.9rem;
}
.analyze-button {
font-size: 0.85rem;
}
.search-container {
padding: 1rem;
}
.search-section {
padding: 0 1rem;
}
.common-analysis-section {
padding: 0 1rem;
}
.common-analysis-container {
padding: 0 1rem;
}
.analysis-status {
padding: 1rem;
margin-top: 0.75rem;
}
.simple-loader {
width: 14px;
height: 14px;
border-width: 2px;
}
.thought-text {
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.universal-analysis-view {
padding: 0.5rem;
}
.analysis-view {
padding: 0.5rem;
}
.analysis-header {
padding: 1rem;
}
.target-info {
flex-wrap: wrap;
}
.target-info .label {
font-size: 0.8rem;
}
.target-info .value {
font-size: 0.85rem;
}
.analysis-content {
padding: 1rem;
font-size: 0.9rem;
line-height: 2;
}
.initial-content {
margin-bottom: 10vh;
gap: 1rem;
}
.title {
font-size: 1.5rem;
}
.description {
font-size: 0.85rem;
}
.search-input {
font-size: 0.85rem;
}
.analyze-button {
font-size: 0.85rem;
padding: 0.6rem;
}
.action-buttons {
gap: 0.5rem;
}
.action-button {
padding: 0.5rem;
font-size: 0.8rem;
}
.search-container {
padding: 0.75rem;
}
.search-section {
padding: 0 0.75rem;
}
.common-analysis-section {
padding: 0 0.75rem;
}
.common-analysis-container {
padding: 0 0.75rem;
}
.common-analysis-item {
font-size: 0.85rem;
padding: 0.3rem 0.6rem;
}
.analysis-status {
padding: 0.75rem;
flex-direction: column;
gap: 0.75rem;
align-items: center;
}
.simple-loader {
width: 12px;
height: 12px;
border-width: 1.5px;
margin: 0 auto;
}
.thought-text {
font-size: 0.8rem;
text-align: center;
}
}
.timeframe-selector {
display: flex;
flex-direction: column;
margin-bottom: 0.75rem;
margin-top: 0.5rem;
align-items: flex-start;
width: 100%;
}
.timeframe-label {
font-size: 0.85rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
white-space: nowrap;
}
.timeframe-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
}
.timeframe-option {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.timeframe-option:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.timeframe-option.active {
background-color: var(--color-accent);
color: white !important;
border-color: var(--color-accent);
}
.timeframe-option:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.timeframe-info {
margin-left: 1rem;
white-space: nowrap;
}
@media (max-width: 768px) {
.timeframe-options {
gap: 0.4rem;
}
.timeframe-option {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.timeframe-label {
font-size: 0.8rem;
}
.timeframe-options {
gap: 0.3rem;
justify-content: flex-start;
}
.timeframe-option {
padding: 0.3rem 0.5rem;
font-size: 0.75rem;
flex: none;
text-align: center;
}
.target-info {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.5rem;
width: 100%;
}
.target-info .label,
.target-info .value {
white-space: nowrap;
}
.timeframe-option.active {
background-color: var(--color-accent);
color: white !important;
border-color: var(--color-accent);
}
}
</style>
<style>
/* Markdown 样式 */
.markdown-content {
white-space: normal !important;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: 600;
line-height: 1.25;
color: var(--color-text-primary);
font-size: 1.2rem; /* 统一所有标题的字体大小 */
}
.markdown-content h1 {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.3em;
}
.markdown-content h2 {
border-bottom: 1px solid var(--color-border);
padding-bottom: 0.3em;
}
.markdown-content h3 {
}
.markdown-content h4 {
}
.markdown-content h5 {
}
.markdown-content h6 {
color: var(--color-text-secondary);
}
.markdown-content p {
margin-top: 0;
margin-bottom: 1.5rem;
line-height: 1.8;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0;
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.markdown-content li {
margin-bottom: 0.5rem;
line-height: 1.7;
}
.markdown-content li p {
margin-bottom: 0.7rem;
}
.markdown-content li:last-child {
margin-bottom: 0;
}
.markdown-content blockquote {
padding: 0.5rem 1.5rem;
color: var(--color-text-secondary);
border-left: 0.3rem solid var(--color-border);
margin: 0 0 1.5rem;
}
.markdown-content blockquote p:last-child {
margin-bottom: 0;
}
.markdown-content pre {
margin-top: 0;
margin-bottom: 1rem;
padding: 1rem;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--color-bg-secondary);
border-radius: var(--border-radius);
}
.markdown-content code {
font-family:
SFMono-Regular,
Consolas,
Liberation Mono,
Menlo,
monospace;
font-size: 85%;
padding: 0.2em 0.4em;
margin: 0;
background-color: var(--color-bg-secondary);
border-radius: 3px;
}
.markdown-content pre code {
padding: 0;
background-color: transparent;
}
/* 表格容器样式 */
.markdown-content .table-container {
width: 100%;
overflow-x: auto;
margin-bottom: 1.5rem;
}
.markdown-content table {
width: 100%;
margin-top: 0;
margin-bottom: 0;
border-spacing: 0;
border-collapse: collapse;
max-width: 100%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid var(--color-border);
overflow-x: auto;
display: block;
}
.markdown-content table th,
.markdown-content table td {
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
text-align: left;
min-width: 100px; /* 防止列过窄 */
vertical-align: top;
white-space: normal; /* 单元格内容可以换行 */
word-break: break-word;
}
.markdown-content table th {
font-weight: 600;
background-color: var(--color-bg-secondary);
white-space: nowrap; /* 表头不换行 */
position: sticky;
top: 0;
z-index: 1;
}
.markdown-content table tr {
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border);
}
.markdown-content table tr:nth-child(2n) {
background-color: var(--color-bg-secondary);
}
.markdown-content a {
color: var(--color-accent);
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content img {
max-width: 100%;
height: auto;
display: block;
margin: 1rem auto;
}
.markdown-content hr {
height: 0.25em;
padding: 0;
margin: 1.5rem 0;
background-color: var(--color-border);
border: 0;
}
/* 响应式样式 */
@media (max-width: 768px) {
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1.1rem;
}
.markdown-content table {
font-size: 0.9rem;
}
.markdown-content table th,
.markdown-content table td {
padding: 0.6rem 0.75rem;
}
}
@media (max-width: 480px) {
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1rem;
}
.markdown-content h1,
.markdown-content h2 {
padding-bottom: 0.2em;
}
.markdown-content table {
font-size: 0.85rem;
}
.markdown-content table th,
.markdown-content table td {
padding: 0.5rem 0.6rem;
min-width: 80px;
}
}
</style>