449 lines
20 KiB
Vue
449 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { apiClient } from '@/api/client'
|
||
import { getInstances, syncInstanceParams, type Instance, type RunningAccount } from '@/api/strategy'
|
||
import { strategyLabel } from '@/constants/strategy'
|
||
import InstanceOverview from './InstanceOverview.vue'
|
||
|
||
interface CodeFile {
|
||
name: string
|
||
type: 'portfolio' | 'cta'
|
||
class_name: string
|
||
lines: number
|
||
}
|
||
|
||
const router = useRouter()
|
||
const files = ref<CodeFile[]>([])
|
||
const instances = ref<Instance[]>([])
|
||
const loading = ref(false)
|
||
const selectedFile = ref('')
|
||
const keyword = ref('')
|
||
// 巡检模式:跨全部档案看在跑运行(点统计条「在跑运行」进出)
|
||
const inspection = ref(false)
|
||
const overviewId = ref<number | null>(null)
|
||
const syncing = ref(false)
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
const [f, ins] = await Promise.all([
|
||
apiClient.get<{ files: CodeFile[] }>('/strategy/files'),
|
||
getInstances(),
|
||
])
|
||
files.value = f.data.files
|
||
instances.value = ins
|
||
if (files.value.length) selectedFile.value = files.value[0].name
|
||
} catch {
|
||
ElMessage.error('策略库加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
// ===== 左栏:代码树(中文主显 + 文件名副行 + 实例数 + 在跑绿点)=====
|
||
const treeGroups = computed(() => {
|
||
const kw = keyword.value.trim().toLowerCase()
|
||
const mk = (type: 'portfolio' | 'cta', label: string) => ({
|
||
label,
|
||
items: files.value
|
||
.filter((f) => f.type === type)
|
||
.filter((f) => {
|
||
if (!kw) return true
|
||
const insts = instancesOf(f.name)
|
||
return (
|
||
f.name.toLowerCase().includes(kw) ||
|
||
strategyLabel(f.name).toLowerCase().includes(kw) ||
|
||
insts.some((i) => i.name.toLowerCase().includes(kw))
|
||
)
|
||
})
|
||
.map((f) => ({
|
||
file: f,
|
||
label: strategyLabel(f.name),
|
||
count: instancesOf(f.name).length,
|
||
running: instancesOf(f.name).some((i) => (i.running_accounts || []).length > 0),
|
||
})),
|
||
})
|
||
return [mk('portfolio', '组合策略'), mk('cta', '个股策略')].filter((g) => g.items.length)
|
||
})
|
||
|
||
function instancesOf(fileName: string): Instance[] {
|
||
return instances.value.filter((i) => i.code_file === fileName)
|
||
}
|
||
|
||
// ===== 统计条 =====
|
||
const runningAccounts = computed<RunningAccount[]>(() =>
|
||
instances.value.flatMap((i) => i.running_accounts || []),
|
||
)
|
||
const stats = computed(() => ({
|
||
files: files.value.length,
|
||
insts: instances.value.length,
|
||
running: runningAccounts.value.length,
|
||
drift: instances.value.filter((i) => i.drift).length,
|
||
}))
|
||
|
||
function toggleInspection(): void {
|
||
inspection.value = !inspection.value
|
||
}
|
||
|
||
// ===== 右侧:选中代码的档案区 =====
|
||
const selectedInstances = computed(() => instancesOf(selectedFile.value))
|
||
const selectedMeta = computed(() => files.value.find((f) => f.name === selectedFile.value))
|
||
|
||
function selectFile(name: string): void {
|
||
selectedFile.value = name
|
||
inspection.value = false
|
||
}
|
||
|
||
// ===== 行内:运行灯(#77 ×N 徽标语义,旧 runLabel/stTxt 已并入 badge)=====
|
||
const lampOf = (s: string): string => {
|
||
if (s === 'running') return 'lamp-ok lamp-pulse'
|
||
if (s === 'done') return 'lamp-ok'
|
||
if (s === 'stopped') return 'lamp-idle'
|
||
if (s === 'failed') return 'lamp-crit'
|
||
return 'lamp-idle'
|
||
}
|
||
const pct = (v: number | null | undefined): string => (v == null ? '—' : (v * 100).toFixed(2) + '%')
|
||
const retClass = (v: number | null | undefined): string => (v == null ? '' : v >= 0 ? 'up' : 'down')
|
||
// #75 收益数值与标签同源:运行类 ret 优先(含其类型),否则回测/回放 run_returns
|
||
interface RetInfo { v: number | null; label: string }
|
||
function retInfoOf(i: Instance): RetInfo {
|
||
const runs = (i.running_accounts || []).filter((a) => a.ret != null)
|
||
if (runs.length) {
|
||
const last = runs[runs.length - 1]
|
||
const label = last.kind === 'live' ? '实盘 · 最新' : last.kind === 'shadow' ? '影子 · 最新' : '模拟实走 · 最新'
|
||
return { v: last.ret, label }
|
||
}
|
||
const rr = (i as Instance & { run_returns?: Record<string, number> }).run_returns || {}
|
||
if (rr.backtest != null) return { v: rr.backtest, label: '回测 · 最新' }
|
||
if (rr.replay != null) return { v: rr.replay, label: '回放 · 最新' }
|
||
return { v: null, label: '未运行' }
|
||
}
|
||
function latestRet(i: Instance): number | null {
|
||
return retInfoOf(i).v
|
||
}
|
||
const paramSummary = (p: Record<string, unknown>): string =>
|
||
Object.entries(p).map(([k, v]) => `${k}=${v}`).join(' · ')
|
||
|
||
// ===== 操作 =====
|
||
function editCode(name: string): void {
|
||
router.push({ path: '/strategy/code', query: { file: name } })
|
||
}
|
||
function newInstance(): void {
|
||
router.push({ path: '/strategy/instance/new', query: { code: selectedFile.value } })
|
||
}
|
||
function editInstance(i: Instance): void {
|
||
router.push(`/strategy/instance/${i.id}`)
|
||
}
|
||
// 以文件实际类型为准(老实例 type 字段可能不准,如组合实例标了 cta)
|
||
function fileTypeOf(i: Instance): 'portfolio' | 'cta' {
|
||
return files.value.find((f) => f.name === i.code_file)?.type || i.type
|
||
}
|
||
// #77 灯语义(用户定稿):点击一律进该类型列表(按实例过滤)看全部运行;
|
||
// 空列表 → 新建页。灯上带 ×N 徽标(该类型运行总数)。历史按钮已删(灯即入口)。
|
||
function countOf(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): number {
|
||
if (i.counts && i.counts[kind] != null) return i.counts[kind]
|
||
// enriched counts 缺失时的退化口径
|
||
if (kind === 'backtest' || kind === 'replay') return (i.status?.[kind] || '-') !== '-' ? 1 : 0
|
||
return (i.running_accounts || []).filter((a) => (kind === 'live' ? a.kind === 'live' : a.kind === 'paper' || a.kind === 'shadow')).length
|
||
}
|
||
// 徽标文案:×N(N>0);0 不显示(点击即新建)
|
||
function badge(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): string {
|
||
const n = countOf(i, kind)
|
||
return n > 0 ? `×${n}` : ''
|
||
}
|
||
function runGo(i: Instance, kind: 'backtest' | 'replay' | 'paper_live' | 'live'): void {
|
||
const n = countOf(i, kind)
|
||
if (kind === 'backtest') {
|
||
if (n > 0) { router.push(`/backtest/history?instance=${i.id}`); return }
|
||
const isPf = fileTypeOf(i) === 'portfolio'
|
||
router.push({
|
||
path: isPf ? '/backtest/portfolio' : '/backtest/new',
|
||
query: isPf
|
||
? { strategy: i.code_file.replace(/\.py$/, ''), instance: String(i.id) }
|
||
: {
|
||
class: files.value.find((f) => f.name === i.code_file)?.class_name || i.code_file,
|
||
instance: String(i.id),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
if (kind === 'replay') {
|
||
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
|
||
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
|
||
return
|
||
}
|
||
if (kind === 'paper_live') {
|
||
if (n > 0) { router.push(`/paper?instance=${i.id}`); return }
|
||
router.push({ path: '/paper/new', query: { mode: 'live', instance: String(i.id) } })
|
||
return
|
||
}
|
||
// live
|
||
if (n > 0) { router.push(`/live?instance=${i.id}`); return }
|
||
router.push({ path: '/live/new', query: { instance: String(i.id) } })
|
||
}
|
||
async function onSync(i: Instance): Promise<void> {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`把实例「${i.name}」当前参数同步到全部运行中的模拟账户?实走明晚结算生效;影子账户需重启影子进程生效。实盘不受影响(停了重发)。`,
|
||
'参数同步', { type: 'warning', confirmButtonText: '同步', cancelButtonText: '取消' },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
syncing.value = true
|
||
try {
|
||
const n = await syncInstanceParams(i.id)
|
||
ElMessage.success(n ? `已同步 ${n} 个模拟账户` : '没有运行中的模拟账户(参数已存档案,后续发起即用新参)')
|
||
} catch {
|
||
ElMessage.error('同步失败')
|
||
} finally {
|
||
syncing.value = false
|
||
}
|
||
}
|
||
async function onDelInst(i: Instance): Promise<void> {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除实例「${i.name}」?`, '删除实例', {
|
||
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
await apiClient.delete(`/strategy/instances/${i.id}`)
|
||
instances.value = instances.value.filter((x) => x.id !== i.id)
|
||
ElMessage.success('已删除')
|
||
} catch (e) {
|
||
// 409 删除保护:有运行中账户
|
||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||
ElMessage.warning(msg || '删除失败')
|
||
}
|
||
}
|
||
|
||
// ===== 巡检模式展示 =====
|
||
const kindLabel: Record<string, string> = { paper: '模拟', shadow: '影子', live: '实盘' }
|
||
// 构建期关联账户↔档案(模板里反查又绕又脆)
|
||
const runningRows = computed(() =>
|
||
instances.value.flatMap((i) => (i.running_accounts || []).map((r) => ({ inst: i, run: r }))),
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div v-loading="loading" class="page lib">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2 class="page-title">策略库</h2>
|
||
<p class="page-subtitle">代码 → 实例档案 → 回测 / 模拟 / 实盘 · 全生命周期</p>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="term-btn primary" @click="router.push('/strategy/new')">+ 新建策略</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- B 借来的统计条:全局一瞥 + 在跑巡检入口 -->
|
||
<div class="stats">
|
||
<div class="stat"><span class="n brand">{{ stats.files }}</span><span class="l">策略代码</span></div>
|
||
<div class="stat clickable" :class="{ on: !inspection }" @click="inspection = false">
|
||
<span class="n">{{ stats.insts }}</span><span class="l">实例档案</span>
|
||
</div>
|
||
<div class="stat clickable" :class="{ on: inspection }" title="跨全部档案只看在跑的运行" @click="toggleInspection">
|
||
<span class="n">{{ stats.running }}</span><span class="l">在跑运行 · {{ inspection ? '返回' : '巡检' }}</span>
|
||
</div>
|
||
<div class="stat"><span class="n warn">{{ stats.drift }}</span><span class="l">参数漂移</span></div>
|
||
</div>
|
||
|
||
<!-- A 主从主体 -->
|
||
<div class="body">
|
||
<aside class="tree">
|
||
<input v-model="keyword" class="search" placeholder="⌕ 搜索策略 / 实例…">
|
||
<template v-for="g in treeGroups" :key="g.label">
|
||
<div class="grp"><span class="mark"></span>{{ g.label }}</div>
|
||
<div
|
||
v-for="it in g.items" :key="it.file.name"
|
||
class="item" :class="{ on: it.file.name === selectedFile && !inspection }"
|
||
@click="selectFile(it.file.name)"
|
||
>
|
||
<div class="item-names">
|
||
<span class="item-label">{{ it.label }}</span>
|
||
<span class="item-file mono">{{ it.file.name }}</span>
|
||
</div>
|
||
<span class="item-side">
|
||
<span v-if="it.running" class="lamp lamp-ok lamp-pulse"></span>
|
||
<span class="cnt mono">{{ it.count }}</span>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
<div v-if="!treeGroups.length" class="muted" style="padding:14px;font-size:12px">无匹配策略</div>
|
||
</aside>
|
||
|
||
<div class="main">
|
||
<!-- 巡检模式:全部在跑运行 -->
|
||
<template v-if="inspection">
|
||
<div class="codehead">
|
||
<span class="fname">在跑运行</span>
|
||
<span class="chip chip-portfolio">跨全部档案</span>
|
||
<span class="muted" style="font-size:11px;margin-left:8px">实时巡检 · 点左侧统计条「在跑运行」返回</span>
|
||
</div>
|
||
<div class="runsview">
|
||
<div v-for="row in runningRows" :key="`${row.run.kind}-${row.run.aid}`" class="runrow">
|
||
<span class="chip" :class="row.run.kind === 'live' ? 'chip-cta' : 'chip-portfolio'">{{ kindLabel[row.run.kind] }}</span>
|
||
<span class="mono muted">{{ row.inst.name }}</span>
|
||
<span class="mono">{{ row.run.label }}</span>
|
||
<span class="mono" :class="retClass(row.run.ret)">{{ pct(row.run.ret) }}</span>
|
||
<button class="term-btn sm" @click="overviewId = row.inst.id">全景</button>
|
||
</div>
|
||
<div v-if="!runningRows.length" class="empty muted">当前没有在跑的运行</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 默认:选中代码的档案区 -->
|
||
<template v-else>
|
||
<div class="codehead">
|
||
<span class="fname mono">{{ selectedMeta?.name || '—' }}</span>
|
||
<span class="chip" :class="selectedMeta?.type === 'portfolio' ? 'chip-portfolio' : 'chip-cta'">
|
||
{{ selectedMeta?.type === 'portfolio' ? '组合' : 'CTA' }}
|
||
</span>
|
||
<span class="mono muted cls">{{ selectedMeta?.class_name }}</span>
|
||
<span style="margin-left:auto"></span>
|
||
<button class="term-btn sm" @click="editCode(selectedFile)">编辑代码</button>
|
||
<button class="term-btn sm primary" @click="newInstance">+ 新实例</button>
|
||
</div>
|
||
<div class="insts">
|
||
<div v-for="i in selectedInstances" :key="i.id" class="row">
|
||
<div class="iname" @click="editInstance(i)">
|
||
{{ i.name }}
|
||
<span v-if="i.drift" class="chip drift">参数已漂移</span>
|
||
<span v-if="i.code_changed" class="chip drift" title="发起后策略代码被修改过,在跑账户仍是旧代码">代码已变更</span>
|
||
<span class="sub mono">#{{ i.id }} · {{ i.interval }} · 更新 {{ i.updated_at }}</span>
|
||
</div>
|
||
<div class="params mono muted">{{ paramSummary(i.params) }}</div>
|
||
<div class="sym mono">{{ i.symbol_or_pool || '—(发起时定)' }}</div>
|
||
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retInfoOf(i).label }}</span></div>
|
||
<div class="runs">
|
||
<button class="run-btn" @click="runGo(i, 'backtest')"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测{{ badge(i, 'backtest') }}</button>
|
||
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runGo(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放{{ badge(i, 'replay') }}</button>
|
||
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runGo(i, 'paper_live')">
|
||
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>实走{{ badge(i, 'paper_live') }}
|
||
</button>
|
||
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'live') }" @click="runGo(i, 'live')">
|
||
<span class="lamp" :class="lampOf(i.status.live)"></span>实盘{{ badge(i, 'live') }}
|
||
</button>
|
||
</div>
|
||
<div class="ops">
|
||
<button v-if="i.drift" class="term-btn sm warn" :disabled="syncing" @click="onSync(i)">同步</button>
|
||
<button class="term-btn sm" @click="overviewId = i.id">全景</button>
|
||
<button class="term-btn sm" @click="editInstance(i)">编辑</button>
|
||
<button class="term-btn sm danger" @click="onDelInst(i)">删除</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!selectedInstances.length" class="empty muted">
|
||
该代码还没有实例 — 点右上「+ 新实例」创建,或直接发起运行(发起即建档)
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<InstanceOverview v-if="overviewId != null" :instance-id="overviewId" @close="overviewId = null" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.lib { display: flex; flex-direction: column; gap: 14px; }
|
||
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||
|
||
.stats { display: flex; gap: 10px; }
|
||
.stat {
|
||
flex: 1; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--bg-card);
|
||
padding: 10px 16px; display: flex; align-items: baseline; gap: 10px; cursor: default;
|
||
}
|
||
.stat .n { font-family: var(--mono); font-size: 21px; font-weight: 700; color: var(--text); }
|
||
.stat .n.brand { color: var(--brand); }
|
||
.stat .n.warn { color: var(--amber); }
|
||
.stat .l { font-size: 11.5px; color: var(--text-3); }
|
||
.stat.clickable { cursor: pointer; transition: border-color 0.12s var(--ease); }
|
||
.stat.clickable:hover { border-color: rgba(0, 229, 255, 0.45); }
|
||
.stat.on { border-color: rgba(0, 229, 255, 0.55); background: rgba(0, 229, 255, 0.06); }
|
||
.stat.on .n { color: var(--brand); }
|
||
|
||
.body {
|
||
display: grid; grid-template-columns: 232px 1fr; border: 1px solid var(--border);
|
||
border-radius: var(--r-md); overflow: hidden; background: var(--bg-card); min-height: 480px;
|
||
}
|
||
.tree { border-right: 1px solid var(--border); background: var(--panel); padding: 10px 0; }
|
||
.search {
|
||
margin: 0 10px 8px; padding: 6px 10px; border: 1px solid var(--border-2); border-radius: var(--r-sm);
|
||
background: var(--bg-card); color: var(--text-2); font-family: var(--mono); font-size: 11px;
|
||
outline: none; width: calc(100% - 20px);
|
||
}
|
||
.search:focus { border-color: rgba(0, 229, 255, 0.4); }
|
||
.grp {
|
||
padding: 10px 13px 4px; font-size: 10.5px; font-weight: 700; color: var(--text-3);
|
||
letter-spacing: 1.2px; display: flex; align-items: center; gap: 7px; font-family: var(--mono);
|
||
}
|
||
.grp .mark { width: 3px; height: 10px; background: var(--brand); box-shadow: 0 0 7px rgba(0, 229, 255, 0.6); }
|
||
.item {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||
padding: 7px 13px; border-left: 2px solid transparent; cursor: pointer; transition: background 0.1s;
|
||
}
|
||
.item:hover { background: var(--bg-hover); }
|
||
.item.on { background: rgba(0, 229, 255, 0.08); border-left-color: var(--brand); }
|
||
.item.on .item-label { color: var(--brand); }
|
||
.item-names { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||
.item-label { font-size: 12.5px; color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.item-file { font-size: 9.5px; color: var(--text-3); }
|
||
.item-side { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||
.item-side .lamp { width: 5px; height: 5px; }
|
||
.cnt { font-size: 9.5px; color: var(--text-3); background: var(--bg-hover); border-radius: 999px; padding: 0 6px; }
|
||
.item.on .cnt { color: var(--brand); }
|
||
|
||
.main { display: flex; flex-direction: column; min-width: 0; }
|
||
.codehead {
|
||
display: flex; align-items: center; gap: 12px; padding: 13px 18px;
|
||
border-bottom: 1px solid var(--border); background: var(--bg-hover);
|
||
}
|
||
.fname { font-size: 14px; font-weight: 700; color: var(--text); }
|
||
.cls { font-size: 11px; }
|
||
|
||
.insts { padding: 6px 10px; flex: 1; }
|
||
.row {
|
||
display: grid; grid-template-columns: 1.25fr 1.9fr 0.8fr 1.1fr 1.6fr auto;
|
||
gap: 12px; align-items: center; padding: 13px 10px;
|
||
border-bottom: 1px solid var(--border-2); border-radius: var(--r-sm);
|
||
}
|
||
.row:hover { background: var(--bg-hover); }
|
||
.row:last-child { border-bottom: none; }
|
||
.iname { font-size: 13px; font-weight: 600; color: var(--text); cursor: pointer; }
|
||
.iname:hover { color: var(--brand); }
|
||
.iname .sub { display: block; font-size: 9.5px; font-weight: 400; color: var(--text-3); margin-top: 2px; }
|
||
.chip.drift { color: var(--amber); border-color: rgba(255, 176, 0, 0.5); background: rgba(255, 176, 0, 0.1); margin-left: 6px; }
|
||
.params { font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sym { font-size: 11.5px; color: var(--amber); }
|
||
.ret { font-weight: 700; font-size: 13.5px; }
|
||
.ret .sub { display: block; font-size: 9.5px; font-weight: 400; color: var(--text-3); }
|
||
.up { color: var(--lamp-err); }
|
||
.down { color: var(--lamp-ok); }
|
||
.runs { display: flex; gap: 5px; flex-wrap: wrap; }
|
||
.run-btn {
|
||
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
|
||
background: transparent; border: 1px solid var(--border-2); border-radius: var(--r-sm);
|
||
color: var(--text-3); font-family: var(--mono); font-size: 10.5px; cursor: pointer;
|
||
transition: all 0.12s var(--ease);
|
||
}
|
||
.run-btn:hover { border-color: rgba(0, 229, 255, 0.5); color: var(--brand); }
|
||
.run-btn.active { color: var(--text-2); border-color: rgba(46, 230, 138, 0.35); }
|
||
.run-btn .lamp { width: 5px; height: 5px; }
|
||
.ops { display: flex; gap: 5px; justify-content: flex-end; }
|
||
.term-btn.warn { color: var(--amber); border-color: rgba(255, 176, 0, 0.4); }
|
||
|
||
.runsview { padding: 6px 10px; }
|
||
.runrow {
|
||
display: grid; grid-template-columns: 0.7fr 1.3fr 1.4fr 0.9fr auto 0.4fr;
|
||
gap: 12px; align-items: center; padding: 12px 10px;
|
||
border-bottom: 1px solid var(--border-2); font-size: 12px;
|
||
}
|
||
.runrow:hover { background: var(--bg-hover); }
|
||
.empty { padding: 34px 16px; color: var(--text-3); font-size: 12.5px; text-align: center; }
|
||
</style>
|