feat(frontend): 终端化重构+策略管理(代码→实例→4运行)+撮合设计+回测参数 [nas]
CI/CD / test (push) Successful in 18s
CI/CD / nas-deploy (push) Failing after 17s
CI/CD / nas-verify (push) Has been skipped

- 终端化: 科幻色板(tokens/EP/reset/chips/echarts)+Layout(Cmd+K/时钟/状态灯)+mock全接口+Dashboard/Result/Monitor样板+6图表配色
- 策略管理: 策略库三层(代码→实例→4运行)+新建策略(组合/CTA模板+Monaco)+在线代码编辑(Monaco)+实例CRUD(参数反射/标的池type切/interval/match_session)
- 实例→运行关联: backtest/paper/live New页读instance预填
- 撮合设计: match_session(下一根K线开盘/收盘/集合竞价)+interval定频率+实走限日线/分钟走miniQMT
- 回测参数: 基准下拉+日期默认1年localStorage记忆+滑点/手续费UI
- 账户管理页+命名重整(模拟盘/实盘,消除撞名)
- spec §11-13(菜单/策略管理/撮合设计) + Monaco编辑器 + 策略实例删除/编辑入口 + 列表实例列
This commit is contained in:
2026-08-12 23:23:23 +08:00
parent 58b5d08280
commit 5c8a7e12c6
45 changed files with 3935 additions and 459 deletions
+3
View File
@@ -128,6 +128,9 @@ function statusLabel(s: string): string {
</template>
</el-table-column>
<el-table-column prop="strategy" label="策略 / 因子" min-width="160" show-overflow-tooltip />
<el-table-column prop="instance" label="实例" min-width="160">
<template #default="{ row }"><span class="mono">{{ row.instance || '—' }}</span></template>
</el-table-column>
<el-table-column prop="symbol" label="标的" width="92" />
<el-table-column label="状态" width="84">
<template #default="{ row }">
+118 -21
View File
@@ -1,33 +1,116 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, type StrategyItem } from '@/api/strategy'
import { submitCta } from '@/api/backtest'
import { apiClient } from '@/api/client'
interface InstLike {
code_file: string
symbol_or_pool: string
interval: string
params: Record<string, unknown>
}
interface FileLike {
name: string
class_name: string
}
const route = useRoute()
const router = useRouter()
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const paramsList = ref<string[]>([])
const fromInstance = ref('')
let inited = false
const BENCHMARK_OPTIONS = [
{ label: '沪深 300', value: 'hs300' },
{ label: '中证 500', value: 'zz500' },
{ label: '中证 1000', value: 'zz1000' },
{ label: '中证 2000', value: 'zz2000' },
]
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('bt_date_range')
if (s) return JSON.parse(s) as { start: string; end: string }
} catch {
/* ignore */
}
const end = new Date()
const start = new Date(end)
start.setFullYear(start.getFullYear() - 1)
const f = (d: Date): string => d.toISOString().slice(0, 10)
return { start: f(start), end: f(end) }
}
const dr = loadDateRange()
const form = reactive({
strategy: '',
symbol: '600000',
start: '2024-01-01',
end: '2024-06-30',
start: dr.start,
end: dr.end,
benchmark: 'hs300',
interval: 'd',
commission_rate: 0.0003,
stamp_duty_rate: 0.001,
slippage: 0.001,
})
watch(
() => [form.start, form.end],
([s, e]) => {
try {
localStorage.setItem('bt_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const paramValues = reactive<Record<string, string>>({})
async function loadParams(cls: string, override: Record<string, unknown> | null): Promise<void> {
if (!cls) return
try {
const p = await getParams(cls)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
const ov = override && override[k] != null ? override[k] : p.defaults[k]
paramValues[k] = ov != null ? String(ov) : ''
})
} catch {
paramsList.value = []
}
}
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
if (strategies.value.length && !form.strategy) {
const instId = route.query.instance
if (instId) {
try {
const [{ data: ir }, { data: fr }] = await Promise.all([
apiClient.get<{ instance: InstLike & { name?: string } }>(`/strategy/instances/${instId}`),
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
])
const inst = ir.instance
fromInstance.value = inst.name || String(instId)
const file = fr.files.find((f) => f.name === inst.code_file)
form.strategy = file?.class_name || inst.code_file
form.symbol = inst.symbol_or_pool || form.symbol
form.interval = inst.interval || form.interval
await loadParams(form.strategy, inst.params)
} catch {
/* 预填失败,走默认 */
}
}
if (!form.strategy && strategies.value.length) {
form.strategy = strategies.value[0].name
}
inited = true
if (!route.query.instance) await loadParams(form.strategy, null)
} catch {
ElMessage.error('策略列表加载失败')
} finally {
@@ -35,19 +118,13 @@ onMounted(async () => {
}
})
watch(() => form.strategy, async (name) => {
if (!name) return
try {
const p = await getParams(name)
paramsList.value = p.parameters
Object.keys(paramValues).forEach((k) => delete paramValues[k])
p.parameters.forEach((k) => {
paramValues[k] = String(p.defaults[k] ?? '')
})
} catch {
paramsList.value = []
}
})
watch(
() => form.strategy,
async (name) => {
if (!inited || !name) return
await loadParams(name, null)
},
)
async function onSubmit(): Promise<void> {
if (!form.strategy || !form.symbol) {
@@ -69,6 +146,9 @@ async function onSubmit(): Promise<void> {
end: form.end,
benchmark: form.benchmark,
interval: form.interval,
commission_rate: Number(form.commission_rate),
stamp_duty_rate: Number(form.stamp_duty_rate),
slippage: Number(form.slippage),
})
ElMessage.success('回测已提交')
router.push(`/backtest/progress/${tid}`)
@@ -136,15 +216,32 @@ async function onSubmit(): Promise<void> {
<template #header><span class="section-title">基准</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="比较基准">
<el-radio-group v-model="form.benchmark">
<el-radio-button value="hs300">沪深 300</el-radio-button>
<el-radio-button value="zz500">中证 500</el-radio-button>
</el-radio-group>
<el-select v-model="form.benchmark" style="width: 220px">
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">费用与滑点</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="佣金率">
<el-input v-model="form.commission_rate" style="width: 160px" />
<span class="muted form-hint">0.0003 = 万3双边最低 5 </span>
</el-form-item>
<el-form-item label="印花税率">
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
<span class="muted form-hint">0.001 = 千1仅卖出</span>
</el-form-item>
<el-form-item label="滑点(比率)">
<el-input v-model="form.slippage" style="width: 160px" />
<span class="muted form-hint">0.001 = 万10</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
提交回测
+240 -203
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
getRelativeMetrics, getBenchmarkCurve, getRiskSeries, getLog,
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
type RelativeMetrics, type BenchmarkCurveData, type RiskSeriesData,
type BacktestResultInfo,
} from '@/api/backtest'
import TradesTable from '@/components/TradesTable.vue'
import MetricCards from '@/components/backtest/MetricCards.vue'
@@ -17,10 +19,10 @@ import VolatilityChart from '@/components/backtest/VolatilityChart.vue'
import EquityCurve from '@/components/backtest/EquityCurve.vue'
const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const loading = ref(true)
// New data for enhanced result page
const relativeMetrics = ref<RelativeMetrics>({
total_return: 0,
annual_return: 0,
@@ -37,7 +39,7 @@ const relativeMetrics = ref<RelativeMetrics>({
const benchmarkCurve = ref<BenchmarkCurveData>({ dates: [], strategy: [], benchmark: [] })
const riskSeries = ref<RiskSeriesData>({ dates: [], alpha: [], beta: [], drawdown: [] })
// Existing data
const resultInfo = ref<BacktestResultInfo | null>(null)
const statistics = ref<Record<string, unknown>>({})
const equity = ref<EquityPoint[]>([])
const pnl = ref<PnlPoint[]>([])
@@ -45,16 +47,14 @@ const trades = ref<Trade[]>([])
const kline = ref<KlineBar[]>([])
const logText = ref('')
// statEntries is no longer used in the new layout but kept for potential future use
// const statEntries = computed(() =>
// Object.entries(statistics.value)
// .map(([k, v]) => ({
// key: k,
// value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
// }))
// )
const statusLamp = computed<string>(() => {
const s = resultInfo.value?.status
if (s === 'done') return 'lamp-ok'
if (s === 'running') return 'lamp-warn lamp-pulse'
if (s === 'failed') return 'lamp-crit'
return 'lamp-idle'
})
// Time range filtering
type TimeRange = '1w' | '1m' | '6m' | '1y' | 'all'
const selectedTimeRange = ref<TimeRange>('all')
@@ -84,18 +84,16 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
return data
}
const startIndex = dates.findIndex(date => new Date(date) >= cutoffDate)
const startIndex = dates.findIndex((date) => new Date(date) >= cutoffDate)
if (startIndex === -1) {
const emptyData: any = { dates: [] }
Object.keys(data).forEach(key => {
if (key !== 'dates') {
emptyData[key] = []
}
const emptyData: Record<string, unknown> = { dates: [] }
Object.keys(data).forEach((key) => {
if (key !== 'dates') emptyData[key] = []
})
return emptyData as T
}
const filteredData: any = { dates: dates.slice(startIndex) }
const filteredData: Record<string, unknown> = { dates: dates.slice(startIndex) }
Object.entries(data).forEach(([key, value]) => {
if (key !== 'dates' && Array.isArray(value)) {
filteredData[key] = value.slice(startIndex)
@@ -110,16 +108,10 @@ function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
const filteredBenchmarkCurve = computed(() => filterDataByTimeRange(benchmarkCurve.value))
const filteredRiskSeries = computed(() => filterDataByTimeRange(riskSeries.value))
// 净值曲线(走 equity-curve 接口,独立于 benchmark-curve)。benchmark-curve/risk-series
// 依赖 *_metrics.json,CTA 引擎当前未生成该文件 → 收益概述图全空;equity-curve 直接
// 来自 BacktestResult.equity_curve(零成交也有 N 天平线),单独画保证"有结果就有图"。
const equityDates = computed(() => equity.value.map((e) => fmtDate(e.date)))
const equityValues = computed(() => equity.value.map((e) => e.balance))
onMounted(async () => {
// 请求隔离:任一接口失败(如 benchmark-curve/risk-series 数据缺失)不得阻塞
// 其余请求。statistics/equity/trades 有数据时必须正常渲染。用 Promise.allSettled
// 保留并发,逐个取值,失败项保留默认空值 + console.warn。
const settled = await Promise.allSettled([
getResult(taskId),
getRelativeMetrics(taskId),
@@ -132,42 +124,24 @@ onMounted(async () => {
const [rInfo, rRel, rBench, rRisk, rEq, rPnl, rTr] = settled
if (rInfo.status === 'fulfilled') {
resultInfo.value = rInfo.value
statistics.value = rInfo.value.statistics || {}
} else {
console.warn('[Result] getResult failed:', rInfo.reason)
}
if (rRel.status === 'fulfilled') {
relativeMetrics.value = rRel.value
} else {
console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
}
if (rBench.status === 'fulfilled') {
benchmarkCurve.value = rBench.value
} else {
console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
}
if (rRisk.status === 'fulfilled') {
riskSeries.value = rRisk.value
} else {
console.warn('[Result] getRiskSeries failed:', rRisk.reason)
}
if (rEq.status === 'fulfilled') {
equity.value = rEq.value
} else {
console.warn('[Result] getEquityCurve failed:', rEq.reason)
}
if (rPnl.status === 'fulfilled') {
pnl.value = rPnl.value
} else {
console.warn('[Result] getDailyPnl failed:', rPnl.reason)
}
if (rTr.status === 'fulfilled') {
trades.value = rTr.value
} else {
console.warn('[Result] getTrades failed:', rTr.reason)
}
if (rRel.status === 'fulfilled') relativeMetrics.value = rRel.value
else console.warn('[Result] getRelativeMetrics failed:', rRel.reason)
if (rBench.status === 'fulfilled') benchmarkCurve.value = rBench.value
else console.warn('[Result] getBenchmarkCurve failed:', rBench.reason)
if (rRisk.status === 'fulfilled') riskSeries.value = rRisk.value
else console.warn('[Result] getRiskSeries failed:', rRisk.reason)
if (rEq.status === 'fulfilled') equity.value = rEq.value
else console.warn('[Result] getEquityCurve failed:', rEq.reason)
if (rPnl.status === 'fulfilled') pnl.value = rPnl.value
else console.warn('[Result] getDailyPnl failed:', rPnl.reason)
if (rTr.status === 'fulfilled') trades.value = rTr.value
else console.warn('[Result] getTrades failed:', rTr.reason)
// kline 依赖 getResult 返回的 symbol/start/end,单独隔离
const info = rInfo.status === 'fulfilled' ? rInfo.value : null
if (info?.symbol && info?.start && info?.end) {
try {
@@ -188,7 +162,6 @@ onMounted(async () => {
loading.value = false
})
// 每日收益格式化:浮点精度 → 2 位;日期去 00:00:00
function fmtPnl(v: number | string): string {
const n = typeof v === 'number' ? v : parseFloat(v)
if (!Number.isFinite(n)) return String(v)
@@ -204,187 +177,251 @@ function fmtDate(d: string): string {
const s = String(d).slice(0, 19).replace('T', ' ')
return s.endsWith(' 00:00:00') ? s.slice(0, 10) : s
}
function onClone(): void {
router.push('/backtest/new')
}
function onExport(): void {
ElMessage.info('报告导出(Excel/PDF)· S2 上线')
}
</script>
<template>
<div v-loading="loading" class="page">
<div class="page-head">
<div>
<h2 class="page-title">回测结果 <span class="muted">{{ taskId }}</span></h2>
<p class="page-subtitle">统计指标 / 基准对比 / 风险分析 / 交易明细</p>
<div v-loading="loading" class="result-page">
<!-- 顶栏状态 + 任务元信息 + 操作 -->
<div class="result-head term-corners">
<div class="head-left">
<div class="head-title">
<span class="lamp" :class="statusLamp"></span>
<span class="title-txt">回测结果</span>
<span class="task-id mono">{{ taskId }}</span>
</div>
<div class="head-meta mono">
<span class="meta-code">{{ resultInfo?.symbol || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.start || '—' }} {{ resultInfo?.end || '—' }}</span>
<span class="dim"></span>
<span>{{ resultInfo?.strategy || '—' }}</span>
</div>
</div>
<div class="head-actions">
<button class="term-btn" @click="onClone">克隆重跑</button>
<button class="term-btn" @click="onExport">导出报告</button>
</div>
</div>
<!-- Top: Metric Cards -->
<!-- 指标塔 -->
<MetricCards :metrics="relativeMetrics" />
<!-- Time Range Selector -->
<div class="time-range-selector">
<span class="selector-label">时间范围</span>
<!-- 工具条时间范围 -->
<div class="toolbar">
<span class="toolbar-label mono">TIME RANGE</span>
<el-radio-group v-model="selectedTimeRange" size="small">
<el-radio-button value="1w">1</el-radio-button>
<el-radio-button value="1m">1</el-radio-button>
<el-radio-button value="6m">6</el-radio-button>
<el-radio-button value="1y">1</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="1w">1W</el-radio-button>
<el-radio-button value="1m">1M</el-radio-button>
<el-radio-button value="6m">6M</el-radio-button>
<el-radio-button value="1y">1Y</el-radio-button>
<el-radio-button value="all">ALL</el-radio-button>
</el-radio-group>
</div>
<!-- Tabs Layout -->
<el-tabs type="border-card">
<!-- Tab 1: 收益概述 -->
<el-tab-pane label="收益概述">
<div class="charts-container">
<el-card>
<EquityCurve :dates="equityDates" :equity="equityValues" />
</el-card>
<el-card>
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
<el-card>
<AlphaChart
:dates="filteredRiskSeries.dates"
:alpha="filteredRiskSeries.alpha"
/>
</el-card>
<el-card>
<BetaChart
:dates="filteredRiskSeries.dates"
:beta="filteredRiskSeries.beta"
/>
</el-card>
<el-card>
<DrawdownChart
:dates="filteredRiskSeries.dates"
:drawdown="filteredRiskSeries.drawdown"
/>
</el-card>
<el-card>
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
</el-tab-pane>
<!-- 主图净值 + 基准对比 -->
<div class="grid-2">
<el-card shadow="never" class="panel"><EquityCurve :dates="equityDates" :equity="equityValues" /></el-card>
<el-card shadow="never" class="panel">
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
</div>
<!-- Tab 2: 交易详情 -->
<!-- 风险分析 4 -->
<div class="section-bar"><span class="section-mark"></span>RISK ANALYSIS · 风险分析</div>
<div class="grid-4">
<el-card shadow="never" class="panel"><AlphaChart :dates="filteredRiskSeries.dates" :alpha="filteredRiskSeries.alpha" /></el-card>
<el-card shadow="never" class="panel"><BetaChart :dates="filteredRiskSeries.dates" :beta="filteredRiskSeries.beta" /></el-card>
<el-card shadow="never" class="panel"><DrawdownChart :dates="filteredRiskSeries.dates" :drawdown="filteredRiskSeries.drawdown" /></el-card>
<el-card shadow="never" class="panel">
<VolatilityChart
:dates="filteredRiskSeries.dates"
:strategy="filteredRiskSeries.strategy_vol ?? []"
:benchmark="filteredRiskSeries.benchmark_vol ?? []"
/>
</el-card>
</div>
<!-- 详情 tabs -->
<el-tabs class="detail-tabs">
<el-tab-pane label="交易详情">
<el-card>
<template #header><span class="section-title">成交记录</span></template>
<el-card shadow="never">
<TradesTable :trades="trades" />
</el-card>
</el-tab-pane>
<!-- Tab 3: 每日持仓&收益 -->
<el-tab-pane label="每日持仓&收益">
<el-card>
<template #header><span class="section-title">每日收益</span></template>
<div class="daily-pnl-section">
<el-table :data="pnl" stripe size="small" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="收益" width="120" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</div>
<el-tab-pane label="每日收益">
<el-card shadow="never">
<el-table :data="pnl" size="small" height="420" empty-text="无数据">
<el-table-column label="日期" width="140">
<template #default="{ row }"><span class="mono">{{ fmtDate(row.date) }}</span></template>
</el-table-column>
<el-table-column label="日收益 (‱)" width="140" align="right">
<template #default="{ row }"><span class="mono" :class="fmtPnlClass(row.pnl)">{{ fmtPnl(row.pnl) }}</span></template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- Tab 4: 日志输出 -->
<el-tab-pane label="日志输出">
<el-card>
<template #header><span class="section-title">系统日志</span></template>
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-card>
<el-tab-pane label="系统日志">
<pre class="log-output">{{ logText || '暂无日志数据' }}</pre>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #e6edf3;
margin: 0 0 8px 0;
}
.muted {
color: #8b949e;
font-size: 16px;
font-weight: 400;
}
.page-subtitle {
color: #8b949e;
font-size: 14px;
margin: 0;
}
.time-range-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 16px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.selector-label {
color: #8b949e;
font-size: 14px;
font-weight: 500;
}
.charts-container {
.result-page {
display: flex;
flex-direction: column;
gap: 14px;
}
/* —— 顶栏 —— */
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.charts-container .el-card {
margin-bottom: 0;
.head-title {
display: flex;
align-items: center;
gap: 10px;
}
.daily-pnl-section {
padding: 8px 0;
.title-txt {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.5px;
}
.log-output {
background: #0d1117;
color: #8b949e;
padding: 16px;
border-radius: 6px;
.task-id {
font-size: 12px;
line-height: 1.6;
max-height: 400px;
color: var(--brand);
padding: 2px 8px;
background: var(--cyan-soft);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-sm);
}
.head-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 11.5px;
color: var(--text-2);
}
.meta-code { color: var(--amber); }
.dim { color: var(--text-3); }
.head-actions { display: flex; gap: 8px; }
.term-btn {
padding: 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--text-2);
font-family: var(--mono);
font-size: 11.5px;
letter-spacing: 0.3px;
cursor: pointer;
transition: border-color 0.15s var(--ease), color 0.15s var(--ease), box-shadow 0.15s var(--ease);
}
.term-btn:hover {
border-color: rgba(0, 229, 255, 0.5);
color: var(--brand);
box-shadow: 0 0 12px rgba(0, 229, 255, 0.18);
}
/* —— 工具条 —— */
.toolbar {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
}
.toolbar-label {
font-size: 10.5px;
color: var(--text-3);
letter-spacing: 1.5px;
}
/* —— 图表面板网格 —— */
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 14px;
}
.panel {
min-height: 280px;
}
.panel :deep(.el-card__body) { padding: 12px 14px; }
/* —— 分节标题 —— */
.section-bar {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
letter-spacing: 1.5px;
color: var(--text-2);
text-transform: uppercase;
margin-top: 2px;
}
.section-mark {
display: inline-block;
width: 3px;
height: 12px;
background: var(--brand);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.6);
}
/* —— tabs —— */
.detail-tabs {
margin-top: 2px;
}
.detail-tabs :deep(.el-tabs__header) {
margin: 0 0 12px;
}
/* —— 日志 —— */
.log-output {
background: var(--panel);
color: var(--text-2);
padding: 14px 16px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
max-height: 420px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
}
.section-title {
color: #e6edf3;
font-size: 14px;
font-weight: 600;
@media (max-width: 980px) {
.grid-2 { grid-template-columns: 1fr; }
}
</style>