feat(web): IC排行榜页——瓦片+筛选chips+全指标表格+IC趋势sparkline+信号灯,终端风1:1 mockup [nas]

This commit is contained in:
2026-08-25 00:37:41 +08:00
parent fa87135a44
commit 52eb5177ac
3 changed files with 1108 additions and 0 deletions
+338
View File
@@ -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 · 剔上市&lt;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>
+688
View File
@@ -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| &gt; 0.02 ICIR &gt; 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 · 红正绿负 · 多空=Q10Q1 等权 · 未做行业中性化</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>