feat(frontend): 决议O②判死卡片危险区按钮+陪葬确认弹窗(列活因子清单+死因必填;强断言想法死,实现陪葬) [vps]
This commit is contained in:
@@ -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)', () => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user