feat(frontend): 孵化梯接真——删 ladder/todos mock+毕业口令表单+闸门阈值页+评审按钮 [nas] [no-doc]

spec §4.5 决议 L 六件之 4 前端侧:适配层签名不变;GateConfig 两级阈值+审计;
graduate 走后端口令闸门;三按钮留痕 registry_events。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-24 21:31:51 +08:00
parent 7c255f5e6d
commit 2cc707d26d
10 changed files with 260 additions and 19 deletions
+54
View File
@@ -141,3 +141,57 @@ export async function getIcTrend(factorId: string): Promise<IcTrend> {
const { data } = await apiClient.get<IcTrend>(`/pipeline/factors/${factorId}/ic-trend`)
return data
}
// —— P2 接真增补(决议 L):阈值配置+毕业+因子评审 ——
export interface GateAuditRow {
key: string
old_value: number | null
new_value: number
changed_by: string
changed_at: string
}
export interface GateConfig {
defaults: Record<string, number>
effective: Record<string, number>
audit: GateAuditRow[]
}
export async function getGateConfig(): Promise<GateConfig> {
const { data } = await apiClient.get<GateConfig>('/pipeline/gate-config')
return data
}
export async function updateGateConfig(key: string, value: number, changedBy: string): Promise<void> {
await apiClient.put('/pipeline/gate-config', { key, value, changed_by: changedBy })
}
export interface GraduateRequest {
name: string
passphrase: string
allocMode: string
allocValue: string
reviewIssue?: number
}
export async function graduateStrategy(req: GraduateRequest): Promise<{ ok: boolean; issue?: number }> {
const { data } = await apiClient.post<{ ok: boolean; issue?: number }>('/pipeline/promotion/graduate', req)
return data
}
export type FactorVerdict = 'promote' | 'revise' | 'graveyard'
export async function postFactorVerdict(
name: string,
verdict: FactorVerdict,
answers: boolean[],
note: string,
): Promise<{ ok: boolean; action?: string }> {
const { data } = await apiClient.post<{ ok: boolean; action?: string }>(`/pipeline/factors/${name}/verdict`, {
verdict,
answers,
note,
})
return data
}
-2
View File
@@ -119,12 +119,10 @@ export function matchMock(method: string, url: string): unknown | undefined {
export const PIPELINE_MOCK = true
const pipelineRoutes: Route[] = [
{ m: 'get', re: /^\/pipeline\/todos$/, build: () => ({ items: P.todosMock }) },
{ m: 'get', re: /^\/pipeline\/hypotheses$/, build: () => ({ items: P.hypothesesMock }) },
{ m: 'get', re: /^\/pipeline\/factors$/, build: () => ({ items: P.factorsMock }) },
{ m: 'get', re: /^\/pipeline\/monthly-review$/, build: () => P.monthlyReviewMock },
{ m: 'get', re: /^\/pipeline\/composite$/, build: () => ({ items: P.compositeMock }) },
{ m: 'get', re: /^\/pipeline\/ladder$/, build: () => ({ items: P.ladderMock }) },
{ m: 'get', re: /^\/pipeline\/attribution$/, build: () => P.attributionMock },
{ m: 'get', re: /^\/pipeline\/research-card$/, build: () => ({ card: P.researchCardMock }) },
{ m: 'get', re: /^\/pipeline\/factors\/[^/]+\/ic-trend$/, build: (url) => P.icTrendMock(url.split('/')[3]) },
+4 -1
View File
@@ -15,9 +15,12 @@ if (jsdomStorage && !(globalThis as unknown as { localStorage?: Storage }).local
describe('pipeline mock 恒开短路', () => {
it('matchPipelineMock 命中已知端点,未知名返 undefined', () => {
expect(matchPipelineMock('get', '/pipeline/todos')).toBeDefined()
expect(matchPipelineMock('get', '/pipeline/hypotheses')).toBeDefined()
expect(matchPipelineMock('get', '/pipeline/factors/fa_gross_margin/ic-trend')).toBeDefined()
expect(matchPipelineMock('get', '/pipeline/nope')).toBeUndefined()
// P2 接真:ladder/todos 已删 mock,兜底不再拦截,直走真实后端
expect(matchPipelineMock('get', '/pipeline/todos')).toBeUndefined()
expect(matchPipelineMock('get', '/pipeline/ladder')).toBeUndefined()
})
it('适配函数经 interceptor 自定义 adapter 直达 mock(零网络)', async () => {
setActivePinia(createPinia())
+1
View File
@@ -45,6 +45,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'pipeline/composite', name: 'pl-composite', component: () => import('@/views/pipeline/CompositeTree.vue') },
{ path: 'pipeline/incubation', name: 'pl-incubation', component: () => import('@/views/pipeline/IncubationLadder.vue') },
{ path: 'pipeline/attribution', name: 'pl-attribution', component: () => import('@/views/pipeline/AttributionResearch.vue') },
{ path: 'pipeline/gate-config', name: 'pl-gate-config', component: () => import('@/views/pipeline/GateConfig.vue') },
],
},
]
+3
View File
@@ -51,6 +51,7 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/pipeline\/composite$/, group: '流水线', title: '合成层版本树' },
{ match: /^\/pipeline\/incubation$/, group: '流水线', title: '孵化梯' },
{ match: /^\/pipeline\/attribution$/, group: '流水线', title: '归因·研判' },
{ match: /^\/pipeline\/gate-config$/, group: '流水线', title: '闸门阈值' },
]
const pageMeta = computed(() => {
const p = route.path
@@ -94,6 +95,7 @@ const CMD_ITEMS: CmdItem[] = [
{ label: '合成层版本树', group: '流水线', path: '/pipeline/composite' },
{ label: '孵化梯', group: '流水线', path: '/pipeline/incubation' },
{ label: '归因·研判', group: '流水线', path: '/pipeline/attribution' },
{ label: '闸门阈值', group: '流水线', path: '/pipeline/gate-config' },
]
const cmdOpen = ref(false)
const cmdQuery = ref('')
@@ -217,6 +219,7 @@ function onLogout(): void {
<el-menu-item index="/pipeline/composite">合成层</el-menu-item>
<el-menu-item index="/pipeline/incubation">孵化梯</el-menu-item>
<el-menu-item index="/pipeline/attribution">归因·研判</el-menu-item>
<el-menu-item index="/pipeline/gate-config">闸门阈值</el-menu-item>
</el-sub-menu>
<el-menu-item index="/account"><span class="nav-label">账户</span></el-menu-item>
</el-menu>
@@ -0,0 +1,31 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import GateConfig from './GateConfig.vue'
import * as api from '@/api/pipeline'
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getGateConfig: vi.fn(), updateGateConfig: vi.fn() }
})
const fakeCfg = {
defaults: { backtest_sharpe_min: 1.0, backtest_max_dd: 0.3, shadow_weeks_min: 4,
shadow_te_annual_max: 0.05, shadow_fill_rate_min: 0.95, shadow_weekly_dev_max: 0.03 },
effective: { backtest_sharpe_min: 1.2, backtest_max_dd: 0.3, shadow_weeks_min: 4,
shadow_te_annual_max: 0.05, shadow_fill_rate_min: 0.95, shadow_weekly_dev_max: 0.03 },
audit: [{ key: 'backtest_sharpe_min', old_value: 1.0, new_value: 1.2,
changed_by: 'admin', changed_at: '2026-09-24 10:00:00' }],
}
describe('GateConfig.vue 闸门阈值配置', () => {
beforeEach(() => vi.clearAllMocks())
it('渲染默认/运行值/审计六行', async () => {
vi.mocked(api.getGateConfig).mockResolvedValue(fakeCfg)
const w = mount(GateConfig, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('回测夏普下限')
expect(w.text()).toContain('1.2') // 运行值(被覆盖)
expect(w.text()).toContain('admin') // 审计行
})
})
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getGateConfig, updateGateConfig } from '@/api/pipeline'
import type { GateConfig } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
const cfg = ref<GateConfig | null>(null)
const editing = ref<Record<string, number>>({})
const busy = ref(false)
onMounted(async () => {
cfg.value = await getGateConfig()
})
const LABELS: Record<string, string> = {
backtest_sharpe_min: '回测夏普下限',
backtest_max_dd: '回测最大回撤上限',
shadow_weeks_min: '影子期最少周数',
shadow_te_annual_max: '跟踪误差年化上限',
shadow_fill_rate_min: 'fillRate 下限',
shadow_weekly_dev_max: '周均收益偏差上限',
}
async function save(key: string): Promise<void> {
if (!cfg.value) return
const v = editing.value[key]
if (v === undefined) return
busy.value = true
try {
await updateGateConfig(key, v, 'console')
cfg.value = await getGateConfig()
ElMessage.success(`${key} 已更新(审计留痕)`)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="pl-page">
<PanelCard title="闸门阈值" sub="代码默认+本机运行值两级 · 改动进审计行(谁/何时/旧→新) · 双机各改各的">
<div v-if="cfg" class="tbl">
<div class="row head">
<span>指标</span><span>代码默认</span><span>本机运行值</span><span>改为</span><span></span>
</div>
<div v-for="key in Object.keys(cfg.defaults)" :key="key" class="row">
<span>{{ LABELS[key] ?? key }}</span>
<span class="mono">{{ cfg.defaults[key] }}</span>
<span class="mono eff">{{ cfg.effective[key] }}</span>
<input v-model.number="editing[key]" class="f-input" type="number" step="0.01" :placeholder="String(cfg.effective[key])" />
<el-button size="small" plain :disabled="busy" @click="save(key)">保存</el-button>
</div>
</div>
</PanelCard>
<PanelCard v-if="cfg && cfg.audit.length" title="审计" sub="谁/何时/旧→新">
<div v-for="(a, i) in cfg.audit" :key="i" class="audit mono">
{{ a.changed_at }} · {{ a.changed_by }} · {{ a.key }}: {{ a.old_value }} → {{ a.new_value }}
</div>
</PanelCard>
</div>
</template>
<style scoped>
.pl-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 14px; }
.tbl { display: flex; flex-direction: column; gap: 6px; }
.row { display: grid; grid-template-columns: 1.4fr 0.8fr 0.9fr 1fr auto; gap: 10px; align-items: center; font-size: 12.5px; color: var(--text-2); }
.row.head { font-family: var(--mono); font-size: 11px; color: var(--text-3); }
.mono { font-family: var(--mono); }
.eff { color: var(--brand); }
.f-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-2); font-family: var(--mono); font-size: 12px; padding: 4px 8px; width: 110px; }
.audit { font-size: 11.5px; color: var(--text-3); padding: 3px 0; border-top: 1px solid var(--border); }
</style>
@@ -7,7 +7,7 @@ import { ladderMock } from '@/mock/pipelineData'
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getLadder: vi.fn() }
return { ...actual, getLadder: vi.fn(), graduateStrategy: vi.fn() }
})
describe('IncubationLadder.vue', () => {
@@ -28,4 +28,22 @@ describe('IncubationLadder.vue', () => {
expect(w.text()).toContain('分配方式')
expect(w.text()).toContain('固定金额')
})
it('毕业表单填齐后调 graduate API 并刷新梯子', async () => {
vi.mocked(api.getLadder).mockResolvedValue(ladderMock)
vi.mocked(api.graduateStrategy).mockResolvedValue({ ok: true, issue: 72 })
const w = mount(IncubationLadder, { global: { plugins: [ElementPlus] } })
await flushPromises()
const inputs = w.findAll('input')
const pwd = inputs.find((i) => i.attributes('type') === 'password')
await pwd!.setValue('口令123')
const text = inputs.filter((i) => i.attributes('type') !== 'password')
await text[text.length - 1].setValue('50000')
const btn = w.findAll('button').find((b) => b.text().includes('promotion'))
await btn!.trigger('click')
await flushPromises()
expect(api.graduateStrategy).toHaveBeenCalled()
const call = vi.mocked(api.graduateStrategy).mock.calls[0][0]
expect(call.passphrase).toBe('口令123')
expect(call.name).toBe('quality_momentum_v1')
})
})
@@ -1,21 +1,50 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getLadder } from '@/api/pipeline'
import { ElMessage } from 'element-plus'
import { getLadder, graduateStrategy } from '@/api/pipeline'
import type { LadderStrategy } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
import { LADDER_LABEL, LADDER_TONE } from '@/components/pipeline/tones'
import PanelCard from './PanelCard.vue'
const items = ref<LadderStrategy[]>([])
onMounted(async () => {
items.value = await getLadder()
})
const load = async () => { items.value = await getLadder() }
onMounted(load)
const STAGES: LadderStrategy['stage'][] = ['backtest', 'paper', 'shadow', 'live']
const byStage = (s: LadderStrategy['stage']): LadderStrategy[] => items.value.filter((x) => x.stage === s)
const grads = computed(() => items.value.filter((x) => x.stage === 'shadow' && (x.shadowWeeks ?? 0) >= (x.shadowTotalWeeks ?? 4)))
const ALLOC_MODES = ['占账户比例', '固定金额']
const form = ref<Record<string, { passphrase: string; allocMode: string; allocValue: string }>>({})
const busy = ref(false)
const formOf = (g: LadderStrategy) =>
(form.value[g.id] ??= { passphrase: '', allocMode: '固定金额', allocValue: '' })
async function doGraduate(g: LadderStrategy): Promise<void> {
const f = formOf(g)
if (!f.passphrase || !f.allocValue) {
ElMessage.warning('口令与初始值必填')
return
}
busy.value = true
try {
const r = await graduateStrategy({
name: g.name,
passphrase: f.passphrase,
allocMode: f.allocMode,
allocValue: f.allocValue,
})
ElMessage.success(r.ok ? `毕业成功${r.issue ? `,issue #${r.issue}` : ''}` : '毕业失败')
f.passphrase = ''
await load()
} catch {
// axios 错误信息由拦截器/ElMessage 呈现;此处仅复位
} finally {
busy.value = false
}
}
</script>
<template>
@@ -44,16 +73,16 @@ const ALLOC_MODES = ['占账户比例', '固定金额']
<span class="mono grad-name">{{ g.name }}</span>
<div class="grad-form">
<span class="f-label">promotion 口令</span>
<input type="password" class="f-input" placeholder="用户原话口令" disabled />
<input v-model="formOf(g).passphrase" type="password" class="f-input" placeholder="用户原话口令" />
<span class="f-label">分配方式</span>
<select class="f-input" disabled>
<select v-model="formOf(g).allocMode" class="f-input">
<option v-for="m in ALLOC_MODES" :key="m" :value="m">{{ m }}</option>
</select>
<span class="f-label">初始值</span>
<input class="f-input" placeholder="如 5% 或 50,000" disabled />
<el-button size="small" type="primary" plain disabled>promotion</el-button>
<input v-model="formOf(g).allocValue" class="f-input" placeholder="如 5 或 50,000" />
<el-button size="small" type="primary" plain :disabled="busy" @click="doGraduate(g)">promotion</el-button>
</div>
<div class="grad-note">P0 原型:表单禁用仅展示流程;P1 接真后口令走后端闸门</div>
<div class="grad-note">口令走后端闸门(SANGUO_PROMOTION_PASSPHRASE);起实盘实例仍是手动动作,档案只记录</div>
</div>
</PanelCard>
</div>
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getFactors } from '@/api/pipeline'
import { postFactorVerdict } from '@/api/pipeline'
import type { PipelineFactor } from '@/api/pipeline'
import type { FactorVerdict } from '@/api/pipeline'
import PanelCard from './PanelCard.vue'
const factors = ref<PipelineFactor[]>([])
@@ -20,6 +23,33 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
{ q: '四问:覆盖度够吗?', hint: '完整性门:覆盖率≥40%,样本期≥12 个月(S 族 10 月首窗)' },
{ q: '五问:入合成的边际增量?', hint: '对照现役合成回测,无增量=观察位不入列' },
]
const answers = ref<Record<number, 'y' | 'n' | ''>>({})
const note = ref('')
const busy = ref(false)
const reload = async () => {
factors.value = (await getFactors()).filter((f) => f.status === 'assessable' && (f.icRecentT ?? 0) >= 2)
picked.value = factors.value[0]?.id ?? ''
answers.value = {}
}
async function submit(verdict: FactorVerdict): Promise<void> {
if (!current.value) return
if ([0, 1, 2, 3, 4].some((i) => !answers.value[i])) {
ElMessage.warning('五问全部作答后再提交')
return
}
const arr: boolean[] = [0, 1, 2, 3, 4].map((i) => answers.value[i] === 'y')
busy.value = true
try {
const r = await postFactorVerdict(current.value.id, verdict, arr, note.value)
ElMessage.success(`已记录:${r.action ?? verdict}`)
note.value = ''
await reload()
} finally {
busy.value = false
}
}
</script>
<template>
@@ -43,18 +73,19 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
<div class="q-head">
<span>{{ item.q }}</span>
<div class="q-ops">
<label><input type="radio" :name="`q${i}`" value="y" />是</label>
<label><input type="radio" :name="`q${i}`" value="n" />否</label>
<label><input type="radio" :name="`q${i}`" value="y" v-model="answers[i]" />是</label>
<label><input type="radio" :name="`q${i}`" value="n" v-model="answers[i]" />否</label>
</div>
</div>
<div class="q-hint">{{ item.hint }}</div>
</div>
</div>
<div class="verdict">
<el-button size="small" type="success" plain>晋级入合成候选</el-button>
<el-button size="small" type="warning" plain>退回改造(升版本)</el-button>
<el-button size="small" type="danger" plain>淘汰入墓园</el-button>
<span class="verdict-note">P0 原型:按钮仅留痕,评审记录落档在 P1 接后端</span>
<input v-model="note" class="f-input" placeholder="评审备注(入墓园=死因必填)" />
<el-button size="small" type="success" plain :disabled="busy" @click="submit('promote')">晋级入合成候选</el-button>
<el-button size="small" type="warning" plain :disabled="busy" @click="submit('revise')">退回改造(留观)</el-button>
<el-button size="small" type="danger" plain :disabled="busy" @click="submit('graveyard')">淘汰入墓园</el-button>
<span class="verdict-note">评审记录落 registry_events.jsonl(与版本快照关联);改造升版本走 factor 域流程</span>
</div>
</template>
</PanelCard>
@@ -73,6 +104,7 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
.q-ops { display: flex; gap: 12px; font-size: 12px; color: var(--text-2); }
.q-ops label { cursor: pointer; }
.q-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
.f-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-2); font-family: var(--mono); font-size: 12px; padding: 4px 8px; min-width: 120px; }
.verdict { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.verdict-note { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
</style>