feat(frontend): 因子详情页档案自述行+危险区送墓园(生产态重确认/死因必填) [vps]
CI/CD / test (push) Failing after 5s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-10-09 19:59:26 +08:00
parent 8d009dc233
commit 84d7daf139
2 changed files with 110 additions and 12 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import FactorDetail from './FactorDetail.vue'
import * as api from '@/api/pipeline'
@@ -12,7 +12,12 @@ vi.mock('vue-router', () => ({
}))
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getFactorDetail: vi.fn(), getIcTrend: vi.fn() }
return {
...actual,
getFactorDetail: vi.fn(),
getIcTrend: vi.fn(),
postFactorVerdict: vi.fn(),
}
})
const DETAIL = {
@@ -20,35 +25,82 @@ const DETAIL = {
name: 'insider_buy_decay60', status: 'incubating', origin: 'decomposer',
hypothesis: 'hyp-1', expression: 'zscore(ts_mean(x, 60))',
source: 'fundamentals_pit', justification: '增持强度衰减承载假设',
description: '高管增持后 60 日强度衰减,量度信息优势的持续期',
createdAt: '2026-10-09', version: '1', icRecentT: null, lastEvalDate: '',
},
birth: { jobId: 'job-20261009101513', startedAt: '2026-10-09T10:15:13',
rounds: 3, hypId: 'hyp-1' },
}
async function mountDetail(override: object | undefined = undefined) {
vi.mocked(api.getFactorDetail).mockResolvedValue(
override ?? (DETAIL as Awaited<ReturnType<typeof api.getFactorDetail>>))
vi.mocked(api.getIcTrend).mockRejectedValue(new Error('no data'))
const w = mount(FactorDetail, { global: { plugins: [ElementPlus] } })
await flushPromises()
return w
}
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()
it('渲染出生档案:来源假设回链/出生方式/批次/表达式/因子自述', async () => {
const w = await mountDetail()
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 轮通过')
expect(w.text()).toContain('因子自述')
expect(w.text()).toContain('高管增持后 60 日强度衰减')
})
it('manual 因子无出生批次显示 —', async () => {
vi.mocked(api.getFactorDetail).mockResolvedValue({
const w = await mountDetail({
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('无假设卡')
})
})
describe('FactorDetail.vue 危险区送墓园(2026-10-09 补丁)', () => {
beforeEach(() => vi.clearAllMocks())
it('incubating:确认+死因→postFactorVerdict(name,graveyard,[],note)', async () => {
let captured: Record<string, unknown> = {}
const promptSpy = vi.spyOn(ElMessageBox, 'prompt').mockImplementation(
(async (_m: string, _t: string, opts: Record<string, unknown> = {}) => {
captured = opts
return { value: '概念与既有因子重复' }
}) as typeof ElMessageBox.prompt)
vi.mocked(api.postFactorVerdict).mockResolvedValue({ ok: true, action: 'graveyard' })
const w = await mountDetail()
await w.find('.grave-btn').trigger('click')
// validator:空死因拒/非空过
const validator = captured.inputValidator as (v: string) => string | boolean
expect(validator('')).toBeTruthy()
expect(validator(' ')).toBeTruthy()
expect(validator('死因')).toBe(true)
expect(api.postFactorVerdict).toHaveBeenCalledWith(
'insider_buy_decay60', 'graveyard', [], '概念与既有因子重复')
promptSpy.mockRestore()
})
it('取消弹窗不发判死请求', async () => {
const promptSpy = vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue(new Error('cancel'))
const w = await mountDetail()
await w.find('.grave-btn').trigger('click')
expect(api.postFactorVerdict).not.toHaveBeenCalled()
promptSpy.mockRestore()
})
it('promoted 因子先过生产重确认,取消即止', async () => {
const confirmSpy = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue(new Error('cancel'))
const w = await mountDetail({
factor: { ...DETAIL.factor, status: 'promoted' },
birth: null,
})
await w.find('.grave-btn').trigger('click')
expect(confirmSpy).toHaveBeenCalled()
expect(api.postFactorVerdict).not.toHaveBeenCalled()
confirmSpy.mockRestore()
})
})
+47 -1
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getFactorDetail, getIcTrend } from '@/api/pipeline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getFactorDetail, getIcTrend, postFactorVerdict } 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'
@@ -29,6 +30,38 @@ onMounted(async () => {
}
trend.value = await getIcTrend(name.value).catch(() => null)
})
// —— 危险区:送墓园(2026-10-09 补丁,spec §4.2)——人工预毙入口;复用既有
// verdict 端点(状态机/回写卡片全现成),note 死因必填;生产态先过重确认.
const burying = ref(false)
const isProd = computed(() =>
detail.value?.factor.status === 'promoted' || detail.value?.factor.status === 'decaying')
async function sendToGraveyard(): Promise<void> {
if (isProd.value) {
try {
await ElMessageBox.confirm(
'该因子生产在用(合成层钉着版本),判死后档案仍留但版本不再演进。建议先走退役。确认仍要判死?',
'生产因子判死', { type: 'warning', confirmButtonText: '仍要判死', cancelButtonText: '再想想' })
} catch { return }
}
let note: string
try {
const r = await ElMessageBox.prompt('死因(必填,入墓园可检索防重复造轮)', '送墓园', {
confirmButtonText: '判死', cancelButtonText: '取消',
inputPlaceholder: '例:概念与 fa_xxx 重复,考试两次未过',
inputValidator: (v: string) => (v && v.trim() ? true : '死因必填') })
note = r.value.trim()
} catch { return }
burying.value = true
try {
await postFactorVerdict(name.value, 'graveyard', [], note)
ElMessage.success('已入墓园(卡片已回写)')
router.push('/pipeline/factors')
} catch (e) {
ElMessage.error(`判死失败: ${e instanceof Error ? e.message : e}`)
} finally { burying.value = false }
}
</script>
<template>
@@ -71,6 +104,9 @@ onMounted(async () => {
<code v-if="detail.factor.expression" class="expr">{{ detail.factor.expression }}</code>
<span v-else class="dim">—</span>
</div>
<div v-if="detail.factor.description" class="brow"><span class="bk">因子自述</span>
<span class="dim">{{ detail.factor.description }}</span>
</div>
<div v-if="detail.factor.justification" class="brow"><span class="bk">承载逻辑</span>
<span class="dim">{{ detail.factor.justification }}</span>
</div>
@@ -87,6 +123,14 @@ onMounted(async () => {
<IcTrendChart :dates="trend.dates" :rolling-t="trend.rollingT" :baseline="trend.baseline" />
</PanelCard>
<div v-else class="empty-box">IC 趋势暂无(月度批评首班未出该因子数据)</div>
<PanelCard title="危险区" sub="判死留档 · 死因可检索 · 墓园不可复活">
<div class="grave-row">
<el-button class="grave-btn" size="small" type="danger" plain :loading="burying"
@click="sendToGraveyard">送墓园(判死留档)</el-button>
<span class="dim">考不过的夜班批会自动进墓园;这里是人工预毙(如一眼识破 AI 废品){{ isProd ? '。生产因子判死需二次确认' : '' }}</span>
</div>
</PanelCard>
</template>
</div>
</template>
@@ -114,4 +158,6 @@ onMounted(async () => {
.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; }
/* 危险区:送墓园 */
.grave-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
</style>