feat(frontend): 因子详情页档案自述行+危险区送墓园(生产态重确认/死因必填) [vps]
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user