feat(api+frontend): D7 验收反馈——因子详情页(出生档案)+来源列布局+进度首轮报轮次 [vps]
用户 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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user