feat(api+frontend): D7 验收反馈——因子详情页(出生档案)+来源列布局+进度首轮报轮次 [vps]
CI/CD / test (push) Successful in 47s
CI/CD / nas-deploy (push) Successful in 28s
CI/CD / nas-verify (push) Successful in 7s

用户 VPS 实测四条反馈(877723a 首班):

① 因子详情页:卡片因子点名/工厂行点击原都只切工厂页或无反应
  - GET /pipeline/factors/{name}/detail:registry 条目(origin/
    hypothesis/首版 expression/source/justification/effective_from)
    +出生批次=decompose_jobs 台账中 registered 含该名的最新 job
  - 前端 FactorDetail.vue(/pipeline/factors/:name):出生档案卡
    (来源假设回链/出生方式/批次回链/表达式/承载逻辑)+近窗 t+IC 趋势

② 总览无变化=数据驱动确认,非缺陷(唯一卡已 building;落新卡即现
   琥珀项;地铁图属 D1-D6 后续批次)——spec 记录,无代码

③ 工厂来源列:width 130→min-width 200,徽标与回链竖排(挤行修复)

④ 轮次进度起跑即报「第 1/3 轮 · 生成候选中(≤8 条)…」——
   初代 LLM 生成期不带轮次字样,像没进循环(用户质询点出)

测试:detail 端点+出生档案断言(decomposer birth/manual null/404)
+FactorDetail.spec 两件;前端 76 绿+构建过+后端 1707 绿
This commit is contained in:
2026-10-09 12:44:57 +08:00
parent 877723ab4a
commit c04bd8dd0c
10 changed files with 278 additions and 11 deletions
+24
View File
@@ -171,6 +171,30 @@ export async function getFactors(): Promise<PipelineFactor[]> {
return data.items
}
// —— D7 验收反馈①:因子详情/出生档案(谁生的/哪批生的/点得回去) ——
export interface FactorDetail {
factor: {
name: string
status: string
origin: string
hypothesis: string | null
expression: string | null
source: string | null
justification: string | null
createdAt: string | null
version: string
icRecentT: number | null
lastEvalDate: string
}
birth: { jobId: string; startedAt: string; rounds: number | null; hypId: string } | null
}
export async function getFactorDetail(name: string): Promise<FactorDetail> {
const { data } = await apiClient.get<FactorDetail>(`/pipeline/factors/${name}/detail`)
return data
}
export async function getMonthlyReview(): Promise<MonthlyReview> {
const { data } = await apiClient.get<MonthlyReview>('/pipeline/monthly-review')
return data
+1
View File
@@ -41,6 +41,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'pipeline', name: 'pl-home', component: () => import('@/views/pipeline/Home.vue') },
{ path: 'pipeline/hypotheses', name: 'pl-hypotheses', component: () => import('@/views/pipeline/HypothesisPool.vue') },
{ path: 'pipeline/factors', name: 'pl-factors', component: () => import('@/views/pipeline/FactorFactory.vue') },
{ path: 'pipeline/factors/:name', name: 'pl-factor-detail', component: () => import('@/views/pipeline/FactorDetail.vue') },
{ path: 'pipeline/review', name: 'pl-review', component: () => import('@/views/pipeline/PromotionReview.vue') },
{ path: 'pipeline/composite', name: 'pl-composite', component: () => import('@/views/pipeline/CompositeTree.vue') },
{ path: 'pipeline/incubation', name: 'pl-incubation', component: () => import('@/views/pipeline/IncubationLadder.vue') },
@@ -0,0 +1,54 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import FactorDetail from './FactorDetail.vue'
import * as api from '@/api/pipeline'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { name: 'insider_buy_decay60' } }),
useRouter: () => ({ push: vi.fn() }),
createRouter: () => ({ beforeEach: () => {}, push: vi.fn() }),
createWebHistory: () => ({}),
}))
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getFactorDetail: vi.fn(), getIcTrend: vi.fn() }
})
const DETAIL = {
factor: {
name: 'insider_buy_decay60', status: 'incubating', origin: 'decomposer',
hypothesis: 'hyp-1', expression: 'zscore(ts_mean(x, 60))',
source: 'fundamentals_pit', justification: '增持强度衰减承载假设',
createdAt: '2026-10-09', version: '1', icRecentT: null, lastEvalDate: '',
},
birth: { jobId: 'job-20261009101513', startedAt: '2026-10-09T10:15:13',
rounds: 3, hypId: 'hyp-1' },
}
describe('FactorDetail.vue(出生档案,验收反馈①)', () => {
beforeEach(() => vi.clearAllMocks())
it('渲染出生档案:来源假设回链/出生方式/批次/表达式', async () => {
vi.mocked(api.getFactorDetail).mockResolvedValue(DETAIL)
vi.mocked(api.getIcTrend).mockRejectedValue(new Error('no data'))
const w = mount(FactorDetail, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('insider_buy_decay60')
expect(w.text()).toContain('⚡ AI 分解')
expect(w.text()).toContain('hyp-1')
expect(w.text()).toContain('job-20261009101513')
expect(w.text()).toContain('zscore(ts_mean(x, 60))')
expect(w.text()).toContain('3 轮通过')
})
it('manual 因子无出生批次显示 —', async () => {
vi.mocked(api.getFactorDetail).mockResolvedValue({
factor: { ...DETAIL.factor, origin: 'manual', hypothesis: null },
birth: null,
})
vi.mocked(api.getIcTrend).mockRejectedValue(new Error('no data'))
const w = mount(FactorDetail, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('✍ 人工实现注册')
expect(w.text()).toContain('无假设卡')
})
})
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getFactorDetail, getIcTrend } from '@/api/pipeline'
import type { FactorDetail, FactorStatus, IcTrend } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
import { FACTOR_LABEL, FACTOR_TONE } from '@/components/pipeline/tones'
import type { Tone } from '@/components/pipeline/tones'
import PanelCard from './PanelCard.vue'
import IcTrendChart from './IcTrendChart.vue'
const route = useRoute()
const router = useRouter()
const name = computed(() => String(route.params.name ?? ''))
const detail = ref<FactorDetail | null>(null)
const trend = ref<IcTrend | null>(null)
const notFound = ref(false)
const stLabel = computed(() =>
FACTOR_LABEL[detail.value?.factor.status as FactorStatus] ?? detail.value?.factor.status ?? '')
const stTone = computed((): Tone =>
FACTOR_TONE[detail.value?.factor.status as FactorStatus] ?? 'dim')
onMounted(async () => {
try {
detail.value = await getFactorDetail(name.value)
} catch {
notFound.value = true
return
}
trend.value = await getIcTrend(name.value).catch(() => null)
})
</script>
<template>
<div class="pl-page">
<span class="back" @click="router.push('/pipeline/factors')">← 因子工厂</span>
<div v-if="notFound" class="empty-box">因子不存在或已入墓园(墓园归假设池页)</div>
<template v-else-if="detail">
<div class="obj-h">
<span class="obj-id mono">{{ detail.factor.name }}</span>
<StatusChip :label="stLabel" :tone="stTone" />
<span v-if="detail.factor.origin === 'decomposer'" class="ob dec" title="AI 分解器自动注册">⚡ decomposer</span>
<span v-else class="ob man" title="人工实现注册">✍ manual</span>
</div>
<PanelCard title="出生档案" sub="谁生的 · 哪批生的 · 点得回去">
<div class="brow"><span class="bk">来源假设</span>
<span v-if="detail.factor.hypothesis">
<a class="src-link" @click="router.push('/pipeline/hypotheses')">{{ detail.factor.hypothesis }} →</a>
<span class="dim">(回假设池看此卡全部产物)</span>
</span>
<span v-else class="dim">—(人工实现,无假设卡)</span>
</div>
<div class="brow"><span class="bk">出生方式</span>
<span :class="detail.factor.origin === 'decomposer' ? 'born-dec' : 'dim'">
{{ detail.factor.origin === 'decomposer' ? '⚡ AI 分解(decomposer 自动注册)' : '✍ 人工实现注册' }}
</span>
</div>
<div class="brow"><span class="bk">出生批次</span>
<span v-if="detail.birth">
<a class="src-link" @click="router.push('/pipeline/hypotheses')">{{ detail.birth.jobId }}</a>
<span class="dim"> · {{ detail.birth.startedAt }} · {{ detail.birth.rounds ?? '—' }} 轮通过</span>
</span>
<span v-else class="dim">—</span>
</div>
<div class="brow"><span class="bk">落成时间</span><span class="mono val">{{ detail.factor.createdAt ?? '—' }}</span></div>
<div class="brow"><span class="bk">数据域</span><span class="mono val">{{ detail.factor.source ?? '—' }}</span></div>
<div class="brow"><span class="bk">表达式</span>
<code v-if="detail.factor.expression" class="expr">{{ detail.factor.expression }}</code>
<span v-else class="dim">—</span>
</div>
<div v-if="detail.factor.justification" class="brow"><span class="bk">承载逻辑</span>
<span class="dim">{{ detail.factor.justification }}</span>
</div>
</PanelCard>
<div class="stats-row">
<div class="stat"><span class="k">近窗 t</span>
<span class="v">{{ detail.factor.icRecentT === null ? '—' : detail.factor.icRecentT.toFixed(2) }}</span></div>
<div class="stat"><span class="k">末次月度批</span><span class="v sm">{{ detail.factor.lastEvalDate || '—' }}</span></div>
<div class="stat"><span class="k">版本</span><span class="v sm">v{{ detail.factor.version }}</span></div>
</div>
<PanelCard v-if="trend" :title="`IC 趋势 · ${name}`" sub="跌破红色虚线=相对降幅过半,叠加 t<2.0 且连续 3 月才开告警">
<IcTrendChart :dates="trend.dates" :rolling-t="trend.rollingT" :baseline="trend.baseline" />
</PanelCard>
<div v-else class="empty-box">IC 趋势暂无(月度批评首班未出该因子数据)</div>
</template>
</div>
</template>
<style scoped>
.pl-page { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.back { color: var(--text-3); font-size: 12px; cursor: pointer; }
.back:hover { color: var(--brand); }
.obj-h { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.obj-id { font-size: 19px; font-weight: 700; letter-spacing: 0.5px; }
.brow { display: flex; gap: 10px; font-size: 12.5px; padding: 5px 0; flex-wrap: wrap; align-items: baseline; }
.brow .bk { width: 66px; color: var(--text-3); flex-shrink: 0; font-size: 11.5px; }
.brow .val { color: var(--text-2); font-size: 11.5px; }
.expr { font-family: var(--mono); font-size: 11.5px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 4px 10px; word-break: break-all; }
.src-link { color: var(--cyan-dim); cursor: pointer; border-bottom: 1px dashed rgba(0, 184, 204, 0.4); font-size: 11.5px; }
.src-link:hover { color: var(--brand); }
.ob { display: inline-flex; align-items: center; font-size: 10.5px; border-radius: 5px; padding: 0 6px; line-height: 17px; white-space: nowrap; }
.ob.dec { color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.45); background: var(--cyan-soft); }
.ob.man { color: var(--text-3); border: 1px solid var(--border); }
.born-dec { color: var(--brand); }
.dim { color: var(--text-3); font-size: 11.5px; }
.stats-row { display: flex; gap: 28px; flex-wrap: wrap; padding: 2px 4px; }
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat .k { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.stat .v { font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.stat .v.sm { font-size: 13px; }
.empty-box { color: var(--text-3); font-size: 12px; padding: 14px 4px; }
</style>
+15 -9
View File
@@ -48,19 +48,22 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
</div>
</PanelCard>
<PanelCard title="因子注册表" sub="衰减态置顶 · 版本化血统 · 孵化态无晋级基准(成熟度门)">
<el-table :data="sorted" size="small" @row-click="(r: PipelineFactor) => selectTrend(r.id)">
<PanelCard title="因子注册表" sub="衰减态置顶 · 版本化血统 · 点行进出生档案">
<el-table :data="sorted" size="small" @row-click="(r: PipelineFactor) => router.push(`/pipeline/factors/${r.id}`)">
<el-table-column prop="id" label="因子" min-width="160">
<template #default="{ row }"><span class="mono" :class="{ sel: row.id === trendFactor }">{{ row.id }}</span>@{{ row.version }}</template>
</el-table-column>
<el-table-column prop="category" label="域" width="100" />
<el-table-column prop="category" label="域" width="90" />
<!-- D7 件②:origin 身份戳+来源卡回链(⚡=AI 分解注册/✍=人工注册) -->
<el-table-column label="来源" width="130">
<el-table-column label="来源" min-width="200">
<template #default="{ row }">
<span v-if="row.origin === 'decomposer'" class="ob dec" title="AI 分解器自动注册">⚡ decomposer</span>
<span v-else class="ob man" title="人工实现注册">✍ manual</span>
<a v-if="row.hypothesis" class="src-link"
@click.stop="router.push('/pipeline/hypotheses')">{{ row.hypothesis }} →</a>
<div class="src-cell">
<span v-if="row.origin === 'decomposer'" class="ob dec" title="AI 分解器自动注册">⚡ decomposer</span>
<span v-else class="ob man" title="人工实现注册">✍ manual</span>
<a v-if="row.hypothesis" class="src-link"
@click.stop="router.push('/pipeline/hypotheses')">{{ row.hypothesis }} →</a>
<span v-else class="src-none">—</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
@@ -103,8 +106,11 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
.ob { display: inline-flex; align-items: center; font-size: 10.5px; border-radius: 5px; padding: 0 6px; line-height: 17px; white-space: nowrap; }
.ob.dec { color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.45); background: var(--cyan-soft); }
.ob.man { color: var(--text-3); border: 1px solid var(--border); }
.src-link { font-size: 10.5px; color: var(--cyan-dim); cursor: pointer; margin-left: 6px; border-bottom: 1px dashed rgba(0, 184, 204, 0.4); }
.src-cell { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; padding: 2px 0; }
.src-link { font-size: 10.5px; color: var(--cyan-dim); cursor: pointer; border-bottom: 1px dashed rgba(0, 184, 204, 0.4); }
.src-link:hover { color: var(--brand); }
.src-none { color: var(--text-3); font-size: 10.5px; }
.el-table { cursor: pointer; }
.alert-txt { color: #ff5f6d; font-family: var(--mono); font-size: 11.5px; }
.dim { color: var(--text-3); }
</style>
@@ -251,8 +251,8 @@ function progText(h: Hypothesis): string {
</div>
<div v-if="openFac[h.id]" class="sec-body">
<div v-for="f in h.factors" :key="f.name" class="fac-row">
<span class="fac-name" title="进因子工厂"
@click="router.push('/pipeline/factors')">{{ f.name }}</span>
<span class="fac-name" title="看出生档案"
@click="router.push(`/pipeline/factors/${f.name}`)">{{ f.name }}</span>
<code class="fac-expr">{{ f.expression }}</code>
<span class="fac-date">{{ f.createdAt }}</span>
</div>