feat(frontend): 决议O②判死卡片危险区按钮+陪葬确认弹窗(列活因子清单+死因必填;强断言想法死,实现陪葬) [vps]

This commit is contained in:
2026-10-10 22:45:06 +08:00
parent bbdea6ece4
commit 8e8005e7b9
3 changed files with 109 additions and 4 deletions
+9
View File
@@ -205,6 +205,15 @@ export async function saveHypothesis(card: CardDraftInput, sentence?: string): P
return data.item
}
// 决议O②:人工判卡片死(危险区)——卡下活因子陪葬,cause=reason+(随卡判死)
export async function postHypothesisVerdict(
id: string, reason: string,
): Promise<{ ok: boolean; buried: string[] }> {
const { data } = await apiClient.post<{ ok: boolean; buried: string[] }>(
`/pipeline/hypotheses/${id}/verdict`, { reason })
return data
}
export async function getFactors(): Promise<PipelineFactor[]> {
const { data } = await apiClient.get<{ items: PipelineFactor[] }>('/pipeline/factors')
return data.items
@@ -8,7 +8,7 @@ import { hypothesesMock } from '@/mock/pipelineData'
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getHypotheses: vi.fn(), getGraveyardFactors: vi.fn(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn(), getDecomposeJobs: vi.fn() }
return { ...actual, getHypotheses: vi.fn(), getGraveyardFactors: vi.fn(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn(), getDecomposeJobs: vi.fn(), postHypothesisVerdict: vi.fn() }
})
describe('HypothesisPool.vue', () => {
@@ -68,6 +68,34 @@ describe('HypothesisPool.vue', () => {
expect(dead[0].text()).toContain('fa高管增持超额')
expect(dead[0].attributes('title')).toContain('IC 两窗反号')
})
it('决议O②:判死卡片弹窗列陪葬清单+确认后调端点', async () => {
vi.mocked(api.getHypotheses).mockResolvedValue(hypothesesMock)
vi.mocked(api.postHypothesisVerdict).mockResolvedValue(
{ ok: true, buried: ['fa高管增持量比'] })
// el-dialog teleport 到 document.body,须 attachTo 才能从真实 DOM 查弹窗内容
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
await w.findAll('.wiz-btn.danger')[0].trigger('click')
await flushPromises()
const dlg = document.querySelector('.el-dialog')
expect(dlg?.textContent).toContain('判死卡片')
expect(dlg?.textContent).toContain('陪葬清单(1 条)') // mock 1 死 1 活,只列活
expect(dlg?.textContent).toContain('fa高管增持量比')
// 填死因+确认
const ta = dlg?.querySelector('textarea') as HTMLTextAreaElement
ta.value = '逻辑前提失效'
ta.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const btn = Array.from(document.querySelectorAll('.el-dialog button'))
.find((b) => b.textContent?.includes('确认判死'))
btn?.click()
await flushPromises()
expect(api.postHypothesisVerdict).toHaveBeenCalledWith(
expect.any(String), '逻辑前提失效')
// 清理:防 teleport 残留污染后续用例
w.unmount()
document.querySelectorAll('.el-overlay').forEach((el) => el.remove())
})
})
describe('向导区(P4-1)', () => {
+71 -3
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getHypotheses, getGraveyardFactors, draftHypothesis, saveHypothesis, startDecompose,
getDecomposeJobs, draftInflight } from '@/api/pipeline'
getDecomposeJobs, draftInflight, postHypothesisVerdict } from '@/api/pipeline'
import type { Hypothesis, CardDraftInput, DecomposeJob, GraveyardFactor } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
import { HYPOTHESIS_LABEL, HYPOTHESIS_TONE } from '@/components/pipeline/tones'
@@ -20,6 +21,12 @@ onMounted(async () => {
})
onUnmounted(stopPoll)
// 刷新(判死/落卡等操作后重拉;失败保旧值,不裸抛)
async function load() {
all.value = await getHypotheses().catch(() => all.value)
deadFactors.value = await getGraveyardFactors().catch(() => deadFactors.value)
}
const pool = computed(() => all.value.filter((h) => h.state !== 'graveyard'))
const graveyard = computed(() => all.value.filter((h) => h.state === 'graveyard'))
const shown = computed(() => (tab.value === 'pool' ? pool.value : graveyard.value))
@@ -57,6 +64,31 @@ const canDecompose = (h: Hypothesis): boolean => DECOMPOSABLE.includes(h.state)
// 决议O:部分死=卡留活跃池+角标☠n/N(n=死因子数,0 不显示)
const deadFactorCount = (h: Hypothesis): number =>
(h.factors || []).filter((f) => f.status === 'graveyard').length
// 决议O②:判死卡片(危险区)——强断言想法死,活因子陪葬(终态不可逆)
const kill = ref<{ card: Hypothesis; reason: string; loading: boolean } | null>(null)
const killBuriable = computed(() =>
(kill.value?.card.factors || []).filter((f) => f.status !== 'graveyard'))
const openKill = (h: Hypothesis) => {
kill.value = { card: h, reason: '', loading: false }
}
async function confirmKill() {
if (!kill.value) return
const reason = kill.value.reason.trim()
if (!reason) { ElMessage.warning('死因必填(可检索防重复造轮)'); return }
kill.value.loading = true
try {
const r = await postHypothesisVerdict(kill.value.card.id, reason)
ElMessage.success(
r.buried.length ? `卡片已判死,陪葬 ${r.buried.length} 条因子` : '卡片已判死(纯想法,无陪葬)')
kill.value = null
await load()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '判死失败')
if (kill.value) kill.value.loading = false
}
}
const killable = (h: Hypothesis): boolean =>
h.state !== 'graveyard' && h.state !== 'promoted'
// 在飞态=服务端真相(卡片 decomposeJob),前端只轮询不登记(10-09 job 化)
const isDecomposing = (h: Hypothesis): boolean => h.decomposeJob?.status === 'running'
@@ -322,11 +354,14 @@ function progText(h: Hypothesis): string {
</div>
</div>
<div v-if="h.deathReason" class="hcard-death">☠ {{ h.deathReason }}</div>
<div v-if="canDecompose(h) || isDecomposing(h)" class="hcard-ops">
<button class="wiz-btn" :disabled="isDecomposing(h)"
<div v-if="canDecompose(h) || isDecomposing(h) || killable(h)" class="hcard-ops">
<button v-if="canDecompose(h) || isDecomposing(h)" class="wiz-btn"
:disabled="isDecomposing(h)"
@click="runDecompose(h)">
{{ isDecomposing(h) ? '分解中(后台执行,可离开本页)…' : '⚡ AI 分解' }}
</button>
<button v-if="killable(h)" class="wiz-btn danger"
@click="openKill(h)">☠ 判死卡片</button>
</div>
<div class="hcard-foot">{{ h.updatedAt }}</div>
</div>
@@ -344,6 +379,30 @@ function progText(h: Hypothesis): string {
<span class="gfac-cause">{{ f.cause }}</span>
</div>
</div>
<!-- 决议O②:判死卡片确认弹窗(危险区,强断言想法死) -->
<el-dialog :model-value="kill !== null" title="☠ 判死卡片(危险区)"
width="480px" @update:model-value="kill = null">
<template v-if="kill">
<div class="kill-warn">
强断言「想法死了」——卡下活因子将全部陪葬入墓园(终态不可逆,重推想法=开新卡)。
</div>
<div v-if="killBuriable.length" class="kill-list">
<div class="kill-list-h">陪葬清单({{ killBuriable.length }} 条):</div>
<div v-for="f in killBuriable" :key="f.name" class="kill-item">
<span class="mono">{{ f.name }}</span>
<span class="dim">{{ f.status }}</span>
</div>
</div>
<div v-else class="kill-list dim">本卡无因子——纯想法毙掉,无陪葬。</div>
<el-input v-model="kill.reason" type="textarea" :rows="2"
placeholder="死因(必填,可检索防重复造轮)" style="margin-top: 10px" />
</template>
<template #footer>
<el-button @click="kill = null">取消</el-button>
<el-button type="danger" :loading="kill?.loading"
@click="confirmKill">确认判死</el-button>
</template>
</el-dialog>
</PanelCard>
</div>
</template>
@@ -376,6 +435,15 @@ function progText(h: Hypothesis): string {
.wiz-btn { background: var(--cyan-soft); border: 1px solid rgba(0, 229, 255, 0.5); border-radius: var(--r-sm); color: var(--brand); font-size: 12px; padding: 6px 14px; cursor: pointer; }
.wiz-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wiz-btn.primary { background: rgba(0, 229, 255, 0.18); }
.wiz-btn.danger { color: #ff5f6d; border-color: rgba(255, 95, 109, 0.5); }
.wiz-btn.danger:hover { background: rgba(255, 95, 109, 0.12); }
.kill-warn { font-size: 12px; color: #ffb84d; padding: 8px 10px;
border: 1px solid rgba(255, 184, 77, 0.4); border-radius: var(--r-sm); }
.kill-list { margin-top: 10px; font-size: 12px; }
.kill-list-h { color: var(--text-2); margin-bottom: 4px; }
.kill-item { display: flex; justify-content: space-between;
padding: 3px 8px; border-bottom: 1px dashed var(--border); }
.kill-item .dim { color: var(--text-3); font-size: 11px; }
.wiz-error { font-size: 11px; color: #ff5f6d; }
.wiz-form { display: flex; flex-direction: column; gap: 6px; }
.wiz-form label { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-3); flex-shrink: 0; }