feat(web): IC排行榜页——瓦片+筛选chips+全指标表格+IC趋势sparkline+信号灯,终端风1:1 mockup [nas]
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const pool = ref<string>('all')
|
||||
const categories = ref<string[]>(['alpha101', 'alpha158'])
|
||||
const startDate = ref('2018-01-01')
|
||||
const endDate = ref('2026-06-30')
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
// TODO: Implement batch evaluation submission (Task 12)
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">发起批量评估</h1>
|
||||
<p class="page-sub">后台任务 · 完成后自动生成排行榜批次 · 建议先小样本冒烟</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formcard">
|
||||
<h3><span class="step">01</span>股票池</h3>
|
||||
<div class="cs">与实盘可交易域一致 · 退市股保留(无幸存者偏差)</div>
|
||||
<div class="poolgrid">
|
||||
<button class="pool on">
|
||||
<span class="pt"><span class="radio"></span>全A 主板+创业板</span>
|
||||
<span class="pd">剔除科创板/北交所 · 剔 ST · 剔上市<120日</span>
|
||||
<span class="pc">≈ 4,382 只</span>
|
||||
</button>
|
||||
<button class="pool">
|
||||
<span class="pt"><span class="radio"></span>中证 800</span>
|
||||
<span class="pd">成份股并集 · 按评估日动态取 · 快速迭代用</span>
|
||||
<span class="pc">≈ 800 只</span>
|
||||
</button>
|
||||
<button class="pool">
|
||||
<span class="pt"><span class="radio"></span>自定义列表</span>
|
||||
<span class="pd">粘贴代码清单 · 沿用现有单因子分析入口</span>
|
||||
<span class="pc">任意</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formcard">
|
||||
<h3><span class="step">02</span>因子集</h3>
|
||||
<div class="cs">按类批量勾选 · 未就绪的因子集显示占位</div>
|
||||
<div class="checkrow">
|
||||
<button class="checkchip on">
|
||||
<span class="box"></span>alpha101 <span class="cnt">×100</span>
|
||||
</button>
|
||||
<button class="checkchip on">
|
||||
<span class="box"></span>alpha158 <span class="cnt">×158</span>
|
||||
</button>
|
||||
<button class="checkchip">
|
||||
<span class="box"></span>内置基础 <span class="cnt">×7</span>
|
||||
</button>
|
||||
<button class="checkchip" style="opacity: 0.45">
|
||||
<span class="box"></span>gtja191 <span class="cnt">×191 · 待移植</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="formcard">
|
||||
<h3><span class="step">03</span>时间窗与口径</h3>
|
||||
<div class="cs">日频因子值 · RankIC · 前瞻收益 1/5/10 日</div>
|
||||
<div class="daterow">
|
||||
<input v-model="startDate" type="date" class="datebox" />
|
||||
<span style="color: var(--text-3)">→</span>
|
||||
<input v-model="endDate" type="date" class="datebox" />
|
||||
<span style="color: var(--text-3); font-family: var(--mono); font-size: 11px">
|
||||
全量预估 ~2.5h (NAS) · 建议晚间错峰
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submitbar">
|
||||
<button class="btn primary" :disabled="loading" @click="submit">
|
||||
{{ loading ? '提交中…' : '提交评估任务' }}
|
||||
</button>
|
||||
<span class="note">提交后进入任务中心排队 · 完成自动生成「2026-08-25 批次」排行榜</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-page {
|
||||
padding: 22px 24px 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.formcard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.formcard h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.formcard h3 .step {
|
||||
color: var(--brand);
|
||||
font-family: var(--mono);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.formcard .cs {
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.poolgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.poolgrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pool {
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 13px 15px;
|
||||
background: var(--panel);
|
||||
transition: all 0.15s var(--ease);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pool:hover {
|
||||
border-color: var(--cyan-dim);
|
||||
}
|
||||
|
||||
.pool.on {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--glow-cyan);
|
||||
background: rgba(0, 229, 255, 0.04);
|
||||
}
|
||||
|
||||
.pool .pt {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pool .pd {
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pool .pc {
|
||||
font-family: var(--mono);
|
||||
color: var(--brand);
|
||||
font-size: 11.5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-3);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pool.on .radio {
|
||||
border-color: var(--brand);
|
||||
background: radial-gradient(circle, var(--brand) 40%, transparent 45%);
|
||||
}
|
||||
|
||||
.checkrow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 7px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
transition: all 0.15s var(--ease);
|
||||
letter-spacing: 0.3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkchip:hover {
|
||||
border-color: var(--cyan-dim);
|
||||
}
|
||||
|
||||
.checkchip.on {
|
||||
color: var(--brand);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1.5px solid var(--text-3);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkchip.on .box {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.checkchip.on .box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0.5px;
|
||||
width: 4px;
|
||||
height: 7px;
|
||||
border: solid #04141a;
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
|
||||
.checkchip .cnt {
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.daterow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.datebox {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 6px 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.submitbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submitbar .note {
|
||||
color: var(--text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--r-md);
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-card);
|
||||
transition: all 0.15s var(--ease);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #04141a;
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: #33ecff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,688 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getEvalRuns, getLeaderboard, type EvalRun, type LeaderboardRow, type LeaderboardTiles } from '@/api/factor'
|
||||
|
||||
const router = useRouter()
|
||||
const runs = ref<EvalRun[]>([])
|
||||
const runId = ref<string>('')
|
||||
const period = ref<'1' | '5' | '10'>('1')
|
||||
const category = ref<string>('')
|
||||
const search = ref('')
|
||||
const sort = ref('abs_icir')
|
||||
const loading = ref(false)
|
||||
const tiles = ref<LeaderboardTiles | null>(null)
|
||||
const rows = ref<LeaderboardRow[]>([])
|
||||
|
||||
const CATEGORY_CHIPS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'alpha101', label: 'Alpha101' },
|
||||
{ key: 'alpha158', label: 'Alpha158' },
|
||||
{ key: 'builtin', label: '内置' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getLeaderboard({
|
||||
...(runId.value ? { run_id: runId.value } : {}),
|
||||
period: period.value,
|
||||
...(category.value ? { category: category.value } : {}),
|
||||
...(search.value ? { search: search.value } : {}),
|
||||
sort: sort.value,
|
||||
})
|
||||
tiles.value = data.tiles
|
||||
rows.value = data.rows
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pct(v: number | null): string {
|
||||
return v == null ? '—' : `${(v * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function num(v: number | null, digits = 3): string {
|
||||
return v == null ? '—' : v.toFixed(digits)
|
||||
}
|
||||
|
||||
function sparkPath(points: { ic: number }[]): string {
|
||||
const pts = points.slice(-24)
|
||||
if (!pts.length) return ''
|
||||
const w = 88
|
||||
const h = 20
|
||||
const maxAbs = Math.max(0.02, ...pts.map((p) => Math.abs(p.ic)))
|
||||
return pts
|
||||
.map((p, i) => {
|
||||
const x = (i / Math.max(pts.length - 1, 1)) * w
|
||||
const y = h / 2 - (p.ic / maxAbs) * (h / 2 - 2)
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const CONCLUSION: Record<string, { label: string; color: string }> = {
|
||||
effective: { label: '有效', color: 'var(--lamp-ok)' },
|
||||
watch: { label: '观察', color: 'var(--lamp-warn)' },
|
||||
eliminated: { label: '淘汰', color: 'var(--lamp-idle)' },
|
||||
}
|
||||
|
||||
const selectedRun = computed(() => runs.value.find((r) => r.run_id === runId.value))
|
||||
|
||||
function goToDetail(row: LeaderboardRow) {
|
||||
if (row.error) return
|
||||
router.push(`/factor/leaderboard/${row.factor}?run=${runId.value}`)
|
||||
}
|
||||
|
||||
function getCategoryClass(cat: string): string {
|
||||
const map: Record<string, string> = {
|
||||
alpha101: 'cat-alpha101',
|
||||
alpha158: 'cat-alpha158',
|
||||
builtin: 'cat-builtin',
|
||||
}
|
||||
return map[cat] || 'cat-builtin'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
runs.value = await getEvalRuns()
|
||||
if (runs.value.length) runId.value = runs.value[0].run_id
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="leaderboard-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">IC 排行榜</h1>
|
||||
<p v-if="selectedRun" class="page-sub">
|
||||
{{ selectedRun.label }} · {{ selectedRun.universe }} {{ selectedRun.symbols_count.toLocaleString() }} 只 ·
|
||||
{{ selectedRun.start }} ~ {{ selectedRun.end }}
|
||||
</p>
|
||||
</div>
|
||||
<router-link to="/factor/batch" class="btn primary">发起新评估</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="tiles" class="stats">
|
||||
<div class="stat">
|
||||
<div class="k">参评因子</div>
|
||||
<div class="v cy">{{ tiles.factors_total }}</div>
|
||||
<div class="d">alpha101 ×100 · alpha158 ×158</div>
|
||||
</div>
|
||||
<div class="stat ok">
|
||||
<div class="k">有效因子</div>
|
||||
<div class="v" :style="{ color: 'var(--lamp-ok)' }">{{ tiles.effective }}</div>
|
||||
<div class="d">|IC| > 0.02 且 ICIR > 0.3</div>
|
||||
</div>
|
||||
<div class="stat warn">
|
||||
<div class="k">观察中</div>
|
||||
<div class="v" :style="{ color: 'var(--amber)' }">{{ tiles.watch }}</div>
|
||||
<div class="d">指标边缘 · 待分年验证</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="k">TOP1 多空年化</div>
|
||||
<div class="v up">{{ pct(tiles.top_ls_annual) }}</div>
|
||||
<div class="d">{{ tiles.top_ls_factor || '—' }} · 十组多空 · 扣费前</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filterbar">
|
||||
<el-select v-model="runId" placeholder="选择批次" @change="load" class="run-select">
|
||||
<el-option v-for="run in runs" :key="run.run_id" :label="`${run.label} · ${run.start}~${run.end}`" :value="run.run_id" />
|
||||
</el-select>
|
||||
|
||||
<div class="chips">
|
||||
<button v-for="chip in CATEGORY_CHIPS" :key="chip.key" class="chip-btn" :class="{ on: category === chip.key }" @click="category = chip.key; load()">
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-input v-model="search" placeholder="搜索因子名…" class="search-input" @keyup.enter="load" />
|
||||
|
||||
<div class="seg">
|
||||
<button :class="{ on: period === '1' }" @click="period = '1'; load()">1D</button>
|
||||
<button :class="{ on: period === '5' }" @click="period = '5'; load()">5D</button>
|
||||
<button :class="{ on: period === '10' }" @click="period = '10'; load()">10D</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="tablewrap">
|
||||
<div v-if="!loading && rows.length === 0" class="empty-state">
|
||||
<p>暂无评估结果</p>
|
||||
<router-link to="/factor/batch">去批量评估发起</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="tablescroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="l">#</th>
|
||||
<th class="l">因子</th>
|
||||
<th class="l">类别</th>
|
||||
<th>IC 均值</th>
|
||||
<th>ICIR</th>
|
||||
<th>t 值</th>
|
||||
<th>胜率</th>
|
||||
<th>多空年化</th>
|
||||
<th>换手/日</th>
|
||||
<th>IC 趋势</th>
|
||||
<th>结论</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.factor" :class="{ 'top3': row.rank <= 3, 'error-row': row.error }" @click="goToDetail(row)">
|
||||
<td class="l rank">{{ row.rank }}</td>
|
||||
<td class="l">
|
||||
<span class="fname">{{ row.factor }}</span>
|
||||
<span class="fexpr">{{ row.expression?.slice(0, 48) }}{{ row.expression?.length > 48 ? '…' : '' }}</span>
|
||||
</td>
|
||||
<td class="l">
|
||||
<span :class="['cat-chip', getCategoryClass(row.category)]">{{ row.category }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.ic_mean != null" class="icbar">
|
||||
<span class="track">
|
||||
<span class="fill" :class="row.ic_mean >= 0 ? 'pos' : 'neg'" :style="{ width: `${Math.abs(row.ic_mean) * 500}%` }"></span>
|
||||
</span>
|
||||
<span :class="row.ic_mean >= 0 ? 'pos' : 'neg'">{{ row.ic_mean.toFixed(3) }}</span>
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td :class="row.icir != null ? (row.icir >= 0 ? 'pos' : 'neg') : ''">{{ num(row.icir, 2) }}</td>
|
||||
<td :class="row.t_stat != null ? (row.t_stat >= 0 ? 'pos' : 'neg') : ''">{{ num(row.t_stat, 1) }}</td>
|
||||
<td :class="row.win_rate != null ? (row.win_rate >= 0 ? 'pos' : 'neg') : ''">{{ pct(row.win_rate) }}</td>
|
||||
<td :class="row.ls_annual != null ? (row.ls_annual >= 0 ? 'pos' : 'neg') : ''">{{ pct(row.ls_annual) }}</td>
|
||||
<td>{{ pct(row.turnover) }}</td>
|
||||
<td>
|
||||
<svg v-if="row.monthly_ic?.length" width="88" height="20" class="sparkline">
|
||||
<polyline :points="sparkPath(row.monthly_ic)" fill="none" stroke="var(--up)" stroke-width="1.5" />
|
||||
</svg>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.error" class="st st-out" :title="row.error">
|
||||
<span class="lamp"></span>错误
|
||||
</span>
|
||||
<span v-else class="st" :class="`st-${row.conclusion}`">
|
||||
<span class="lamp"></span>{{ CONCLUSION[row.conclusion]?.label || row.conclusion }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="rows.length" class="tbl-foot">
|
||||
<span>显示 {{ rows.length }} / {{ tiles?.factors_total || 0 }} · 按 |IC| 降序</span>
|
||||
<span>口径:日频 RankIC · 红正绿负 · 多空=Q10−Q1 等权 · 未做行业中性化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.leaderboard-page {
|
||||
padding: 22px 24px 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--r-md);
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-card);
|
||||
transition: all 0.15s var(--ease);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-3);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
color: #04141a;
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--glow-cyan);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #33ecff;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 13px 16px 11px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--brand);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stat.warn::before {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
.stat.ok::before {
|
||||
background: var(--lamp-ok);
|
||||
}
|
||||
|
||||
.stat .k {
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat .v {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
margin-top: 3px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat .v.up {
|
||||
color: var(--up);
|
||||
}
|
||||
|
||||
.stat .v.cy {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.stat .d {
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.filterbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 9px 12px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.run-select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.run-select :deep(.el-input__wrapper) {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.run-select :deep(.el-input__inner) {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 2px 10px;
|
||||
letter-spacing: 0.3px;
|
||||
transition: all 0.15s var(--ease);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-3);
|
||||
}
|
||||
|
||||
.chip-btn.on {
|
||||
color: var(--brand);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__inner) {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.seg button {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg button.on {
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.tablewrap {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tablescroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty-state a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: right;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
background: var(--bg-hover);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead th.l,
|
||||
tbody td.l {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
text-align: right;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
tbody tr.error-row {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
tbody tr.error-row:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
td .fname {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
td .fname:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
td .fexpr {
|
||||
color: var(--text-3);
|
||||
font-size: 10.5px;
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
letter-spacing: 0.3px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.cat-alpha101 {
|
||||
color: var(--brand);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
border-color: rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
|
||||
.cat-alpha158 {
|
||||
color: #b48cff;
|
||||
background: rgba(180, 140, 255, 0.1);
|
||||
border-color: rgba(180, 140, 255, 0.3);
|
||||
}
|
||||
|
||||
.cat-builtin {
|
||||
color: var(--amber);
|
||||
background: rgba(255, 176, 0, 0.1);
|
||||
border-color: rgba(255, 176, 0, 0.3);
|
||||
}
|
||||
|
||||
.pos {
|
||||
color: var(--up);
|
||||
}
|
||||
|
||||
.neg {
|
||||
color: var(--down);
|
||||
}
|
||||
|
||||
.icbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.icbar .track {
|
||||
width: 64px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icbar .fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.icbar .fill.pos {
|
||||
background: var(--up);
|
||||
right: 0;
|
||||
box-shadow: 0 0 5px rgba(255, 77, 94, 0.5);
|
||||
}
|
||||
|
||||
.icbar .fill.neg {
|
||||
background: var(--down);
|
||||
left: 0;
|
||||
box-shadow: 0 0 5px rgba(46, 230, 138, 0.5);
|
||||
}
|
||||
|
||||
.st {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.st-ok .lamp {
|
||||
background: var(--lamp-ok);
|
||||
box-shadow: 0 0 6px rgba(46, 230, 138, 0.7);
|
||||
}
|
||||
|
||||
.st-watch .lamp {
|
||||
background: var(--lamp-warn);
|
||||
box-shadow: 0 0 6px rgba(255, 176, 0, 0.7);
|
||||
}
|
||||
|
||||
.st-out .lamp {
|
||||
background: var(--lamp-idle);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.lamp {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
tr.top3 td.rank {
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 10px rgba(0, 229, 255, 0.5);
|
||||
}
|
||||
|
||||
.tbl-foot {
|
||||
padding: 9px 14px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const factor = route.params.factor as string
|
||||
const runId = route.query.run as string | undefined
|
||||
|
||||
function goBack() {
|
||||
router.push('/factor/leaderboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<a class="backlink" href="#" @click.prevent="goBack">← 返回排行榜</a>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">因子详情 · {{ factor }}</h1>
|
||||
<p class="page-sub">批次 {{ runId || '最新' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="placeholder">
|
||||
<p>因子详情页待实现(Task 11)</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
padding: 22px 24px 60px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backlink {
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
margin-bottom: var(--sp-3);
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.03em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.backlink:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user