feat(api+frontend): D7 落成边亮灯——假设→因子边可见性三处 [vps]
体验稿(8823)逐条过审拍板后动工,不造新机器只做可见性:
件① 三件套(spec §7):
- 1a 卡片已产因子清单:列表项内联 factors(registry hypothesis
血统反查:名/表达式/origin/落成日);单因子名 factorId 前端退役
- 1b 分解批次历史:GET /pipeline/hypotheses/{id}/decompose-jobs
台账新→旧(QA tasks/list 端点形状照抄),卡片折叠区按需拉取
- 1c 轮次进度:run_decompose 每轮 on_progress 回调→内存 job
progress(currentRound/totalRounds/passed/regen;真轮数非 QA 摆设),
台账仍只记起跑+终态;worker 协议改收 job_id
件② 工厂来源列:factors 端点带 origin/hypothesis 血统,
⚡decomposer/✍manual 徽标+回链假设池
件③ 琥珀待办:todos 聚合加 queued/data_check 卡(已确认未分解),
分解转 building 即消行——人工卡点②显性化
件④ 结果持久落卡:前端一次性弹层退役,终态摘要+清单+批次全在卡
测试:后端 TestD7Visibility 六件(历史端点/内联清单/进度中飞可见/
终态不带过程态/琥珀消行/工厂血统)+前端三件套三测;8 目录 2780 绿
This commit is contained in:
@@ -8,7 +8,7 @@ describe('api/pipeline 契约', () => {
|
||||
api.getCompositeVersions, api.getLadder, api.getAttribution,
|
||||
api.getResearchCard, api.getIcTrend,
|
||||
api.getLlmConfig, api.updateLlmConfig, api.testLlmConnection,
|
||||
api.startDecompose,
|
||||
api.startDecompose, api.getDecomposeJobs,
|
||||
api.getEnvStatus, api.getMonthlyStatus,
|
||||
]) {
|
||||
expect(typeof fn).toBe('function')
|
||||
|
||||
@@ -24,6 +24,16 @@ export interface Hypothesis {
|
||||
factorId?: string
|
||||
deathReason?: string
|
||||
decomposeJob?: DecomposeJob | null
|
||||
factors?: HypothesisFactor[]
|
||||
}
|
||||
|
||||
// D7 件①1a:卡片已产因子清单(registry hypothesis 血统反查,内联于列表)
|
||||
export interface HypothesisFactor {
|
||||
name: string
|
||||
expression: string | null
|
||||
origin: string
|
||||
createdAt: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export type FactorStatus = 'incubating' | 'assessable' | 'promoted' | 'decaying' | 'retired'
|
||||
@@ -33,6 +43,8 @@ export interface PipelineFactor {
|
||||
category: 'fundamental' | 'pledge' | 'research' | 'sentiment' | 'technical'
|
||||
version: string
|
||||
status: FactorStatus
|
||||
origin?: string // D7 件②:decomposer/manual 身份戳
|
||||
hypothesis?: string | null // 来源假设卡回链血统
|
||||
icRecentT: number | null
|
||||
icFullT: number | null
|
||||
promotedAtT: number | null
|
||||
@@ -317,6 +329,16 @@ export interface DecomposeJob {
|
||||
error: string | null
|
||||
startedAt: string | null
|
||||
finishedAt: string | null
|
||||
// D7 件①1c:在飞轮次进度(内存态,终态为 null/缺省)
|
||||
progress?: DecomposeProgress | null
|
||||
}
|
||||
|
||||
export interface DecomposeProgress {
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
passed: number
|
||||
regen: number
|
||||
message: string
|
||||
}
|
||||
|
||||
// 分解 job 化(10-09,QA 范式):POST 立返 202+后台任务,浏览器不再扛
|
||||
@@ -328,6 +350,13 @@ export async function startDecompose(id: string): Promise<DecomposeJob> {
|
||||
return data.job
|
||||
}
|
||||
|
||||
// D7 件①1b:该卡分解批次历史(台账新→旧,卡片折叠区按需拉取)
|
||||
export async function getDecomposeJobs(id: string): Promise<DecomposeJob[]> {
|
||||
const { data } = await apiClient.get<{ items: DecomposeJob[] }>(
|
||||
`/pipeline/hypotheses/${id}/decompose-jobs`)
|
||||
return data.items
|
||||
}
|
||||
|
||||
// —— P5.2/P5.6 设置页状态卡(只读,spec §13)——
|
||||
|
||||
export interface EnvStatusItem {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getFactors, getMonthlyReview, getIcTrend } from '@/api/pipeline'
|
||||
import type { PipelineFactor, MonthlyReview, IcTrend } from '@/api/pipeline'
|
||||
import StatusChip from '@/components/pipeline/StatusChip.vue'
|
||||
@@ -7,6 +8,8 @@ import { FACTOR_LABEL, FACTOR_TONE } from '@/components/pipeline/tones'
|
||||
import PanelCard from './PanelCard.vue'
|
||||
import IcTrendChart from './IcTrendChart.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const factors = ref<PipelineFactor[]>([])
|
||||
const review = ref<MonthlyReview | null>(null)
|
||||
const trend = ref<IcTrend | null>(null)
|
||||
@@ -51,6 +54,15 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
|
||||
<template #default="{ row }"><span class="mono" :class="{ sel: row.id === trendFactor }">{{ row.id }}</span>@{{ row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="域" width="100" />
|
||||
<!-- D7 件②:origin 身份戳+来源卡回链(⚡=AI 分解注册/✍=人工注册) -->
|
||||
<el-table-column label="来源" width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.origin === 'decomposer'" class="ob dec" title="AI 分解器自动注册">⚡ decomposer</span>
|
||||
<span v-else class="ob man" title="人工实现注册">✍ manual</span>
|
||||
<a v-if="row.hypothesis" class="src-link"
|
||||
@click.stop="router.push('/pipeline/hypotheses')">{{ row.hypothesis }} →</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="row.status === 'assessable'" content="可判=够格被评审;近窗 t≥2 才会出现在晋级评审页" placement="top">
|
||||
@@ -87,6 +99,12 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
|
||||
.kv .v.low { color: #ff5f6d; }
|
||||
.mono { font-family: var(--mono); }
|
||||
.mono.sel { color: var(--brand); }
|
||||
/* D7 件②:origin 徽标+回链 */
|
||||
.ob { display: inline-flex; align-items: center; font-size: 10.5px; border-radius: 5px; padding: 0 6px; line-height: 17px; white-space: nowrap; }
|
||||
.ob.dec { color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.45); background: var(--cyan-soft); }
|
||||
.ob.man { color: var(--text-3); border: 1px solid var(--border); }
|
||||
.src-link { font-size: 10.5px; color: var(--cyan-dim); cursor: pointer; margin-left: 6px; border-bottom: 1px dashed rgba(0, 184, 204, 0.4); }
|
||||
.src-link:hover { color: var(--brand); }
|
||||
.alert-txt { color: #ff5f6d; font-family: var(--mono); font-size: 11.5px; }
|
||||
.dim { color: var(--text-3); }
|
||||
</style>
|
||||
|
||||
@@ -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(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn() }
|
||||
return { ...actual, getHypotheses: vi.fn(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn(), getDecomposeJobs: vi.fn() }
|
||||
})
|
||||
|
||||
describe('HypothesisPool.vue', () => {
|
||||
@@ -89,3 +89,72 @@ describe('向导区(P4-1)', () => {
|
||||
expect(w.find('.wiz-form').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('D7 三件套(件①)', () => {
|
||||
const HYP = {
|
||||
id: 'hyp-d7', title: '高管增持之后股价会涨', source: 'wizard',
|
||||
logic: 'l', expectedSign: 'positive', falsifiable: 'f',
|
||||
dataNeeds: ['fundamentals_pit'], state: 'building',
|
||||
updatedAt: '2026-10-09', factorId: 'insider_buy_decay60',
|
||||
decomposeJob: {
|
||||
jobId: 'job-x', status: 'completed', rounds: 3,
|
||||
registered: [{ name: 'a', source: 'fundamentals_pit', expression: 'e', justification: 'j' }],
|
||||
failed: [], error: null, startedAt: '2026-10-09T10:15:13',
|
||||
finishedAt: '2026-10-09T10:17:56',
|
||||
},
|
||||
factors: [
|
||||
{ name: 'insider_buy_decay60', expression: 'zscore(ts_mean(x,60))', origin: 'decomposer', createdAt: '2026-10-09', status: 'incubating' },
|
||||
],
|
||||
} as unknown as api.Hypothesis
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('卡片渲染因子清单(血统反查)与终态摘要行', async () => {
|
||||
vi.mocked(api.getHypotheses).mockResolvedValue([HYP])
|
||||
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('已产因子') // 折叠头
|
||||
expect(w.text()).toContain('1 条') // 计数
|
||||
expect(w.text()).toContain('3 轮 · 通过 1') // 终态摘要
|
||||
const head = w.findAll('.sec-head').find((s) => s.text().includes('已产因子'))
|
||||
await head!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('insider_buy_decay60')
|
||||
expect(w.text()).toContain('zscore(ts_mean(x,60))')
|
||||
})
|
||||
|
||||
it('展开批次历史按需拉取台账并渲染明细', async () => {
|
||||
vi.mocked(api.getHypotheses).mockResolvedValue([HYP])
|
||||
vi.mocked(api.getDecomposeJobs).mockResolvedValue([
|
||||
{ jobId: 'job-x', status: 'completed', rounds: 3,
|
||||
registered: HYP.decomposeJob!.registered,
|
||||
failed: [{ name: 'bad_f', violations: [{ code: 'WHITELIST', detail: '列不在白名单' }] }],
|
||||
error: null, startedAt: '2026-10-09T10:15:13', finishedAt: '2026-10-09T10:17:56' },
|
||||
])
|
||||
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const head = w.findAll('.sec-head').find((s) => s.text().includes('分解批次'))
|
||||
await head!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.getDecomposeJobs).toHaveBeenCalledWith('hyp-d7')
|
||||
expect(w.text()).toContain('job-x')
|
||||
expect(w.text()).toContain('通过 1 / 拒 1')
|
||||
// 点批次行展开明细
|
||||
await w.find('.batch-h').trigger('click')
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('WHITELIST')
|
||||
})
|
||||
|
||||
it('在飞卡片渲染轮次进度行(服务端 progress)', async () => {
|
||||
const running = { ...HYP, decomposeJob: {
|
||||
jobId: 'job-run', status: 'running', rounds: null, registered: [],
|
||||
failed: [], error: null, startedAt: '2026-10-09T11:00:00', finishedAt: null,
|
||||
progress: { currentRound: 2, totalRounds: 3, passed: 6, regen: 1, message: '第 2/3 轮校验完成' },
|
||||
} } as unknown as api.Hypothesis
|
||||
vi.mocked(api.getHypotheses).mockResolvedValue([running])
|
||||
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(w.find('.prog').exists()).toBe(true)
|
||||
expect(w.text()).toContain('第 2/3 轮 · 通过 6 · 重生成 1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getHypotheses, draftHypothesis, saveHypothesis, startDecompose, draftInflight } from '@/api/pipeline'
|
||||
import { getHypotheses, draftHypothesis, saveHypothesis, startDecompose,
|
||||
getDecomposeJobs, draftInflight } from '@/api/pipeline'
|
||||
import type { Hypothesis, CardDraftInput, DecomposeJob } from '@/api/pipeline'
|
||||
import StatusChip from '@/components/pipeline/StatusChip.vue'
|
||||
import { HYPOTHESIS_LABEL, HYPOTHESIS_TONE } from '@/components/pipeline/tones'
|
||||
@@ -53,12 +54,17 @@ const canDecompose = (h: Hypothesis): boolean => DECOMPOSABLE.includes(h.state)
|
||||
// 在飞态=服务端真相(卡片 decomposeJob),前端只轮询不登记(10-09 job 化)
|
||||
const isDecomposing = (h: Hypothesis): boolean => h.decomposeJob?.status === 'running'
|
||||
|
||||
// job 终态回放:title 随快照存(job 视图不带卡片标题)
|
||||
const decomposeResult = ref<{ job: DecomposeJob; title: string } | null>(null)
|
||||
// job 终态回放:完成后结果持久落卡(件④,取消一次性弹层)
|
||||
const decomposeError = ref('')
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let watchJobId: string | null = null
|
||||
|
||||
// —— D7 件①三件套的折叠/缓存态 ——
|
||||
const openFac = ref<Record<string, boolean>>({}) // 1a 因子清单
|
||||
const openBat = ref<Record<string, boolean>>({}) // 1b 批次历史
|
||||
const openBatch = ref<Record<string, boolean>>({}) // 单批明细
|
||||
const batchCache = ref<Record<string, DecomposeJob[]>>({})
|
||||
|
||||
function patchJob(hypId: string, job: DecomposeJob) {
|
||||
const idx = all.value.findIndex((x) => x.id === hypId)
|
||||
if (idx >= 0) {
|
||||
@@ -79,22 +85,32 @@ function ensurePoll() {
|
||||
if (!items) return
|
||||
all.value = items
|
||||
if (watchJobId) {
|
||||
const w = items.find((i) => i.decomposeJob?.jobId === watchJobId)?.decomposeJob
|
||||
if (w && w.status !== 'running') settle(w)
|
||||
const hit = items.find((i) => i.decomposeJob?.jobId === watchJobId)
|
||||
if (hit?.decomposeJob && hit.decomposeJob.status !== 'running') {
|
||||
settle(hit.id, hit.decomposeJob)
|
||||
}
|
||||
} else if (!items.some((i) => i.decomposeJob?.status === 'running')) {
|
||||
stopPoll() // 无本页发起的 watch 且全站无在飞 → 停表
|
||||
}
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
function settle(job: DecomposeJob) {
|
||||
async function refreshBatches(hypId: string): Promise<void> {
|
||||
const rows = await getDecomposeJobs(hypId).catch(() => null)
|
||||
if (rows) batchCache.value = { ...batchCache.value, [hypId]: rows }
|
||||
}
|
||||
|
||||
function settle(hypId: string, job: DecomposeJob) {
|
||||
watchJobId = null
|
||||
if (job.status === 'completed') {
|
||||
decomposeError.value = ''
|
||||
const title = all.value.find((x) => x.decomposeJob?.jobId === job.jobId)?.title ?? ''
|
||||
decomposeResult.value = { job, title }
|
||||
// 完成即展开产物区(mockup 同款「长出」),批次缓存失效重拉
|
||||
openFac.value = { ...openFac.value, [hypId]: true }
|
||||
openBat.value = { ...openBat.value, [hypId]: true }
|
||||
delete batchCache.value[hypId]
|
||||
batchCache.value = { ...batchCache.value }
|
||||
refreshBatches(hypId)
|
||||
} else {
|
||||
decomposeResult.value = null
|
||||
decomposeError.value = job.error || '分解失败'
|
||||
}
|
||||
stopPoll()
|
||||
@@ -103,7 +119,6 @@ function settle(job: DecomposeJob) {
|
||||
async function runDecompose(h: Hypothesis): Promise<void> {
|
||||
if (isDecomposing(h) || !canDecompose(h)) return
|
||||
decomposeError.value = ''
|
||||
decomposeResult.value = null
|
||||
try {
|
||||
const job = await startDecompose(h.id)
|
||||
watchJobId = job.jobId
|
||||
@@ -132,6 +147,28 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
|
||||
(draft.value as any)[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
// —— 折叠交互 ——
|
||||
function toggleFac(h: Hypothesis): void {
|
||||
openFac.value = { ...openFac.value, [h.id]: !openFac.value[h.id] }
|
||||
}
|
||||
async function toggleBat(h: Hypothesis): Promise<void> {
|
||||
const open = !openBat.value[h.id]
|
||||
openBat.value = { ...openBat.value, [h.id]: open }
|
||||
if (open && !batchCache.value[h.id]) await refreshBatches(h.id)
|
||||
}
|
||||
function toggleBatch(jobId: string): void {
|
||||
openBatch.value = { ...openBatch.value, [jobId]: !openBatch.value[jobId] }
|
||||
}
|
||||
|
||||
function fmtTime(iso: string | null): string {
|
||||
return iso ? iso.slice(5, 16).replace('T', ' ') : '—'
|
||||
}
|
||||
function progText(h: Hypothesis): string {
|
||||
const p = h.decomposeJob?.progress
|
||||
if (!p) return '生成候选中(≤8 条)…'
|
||||
return `第 ${p.currentRound}/${p.totalRounds} 轮 · 通过 ${p.passed} · 重生成 ${p.regen}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -180,33 +217,6 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
|
||||
<a v-if="decomposeError.includes('LLM 未配置')" class="dec-link"
|
||||
@click="router.push('/pipeline/settings')">去设置页配置 →</a>
|
||||
</div>
|
||||
<div v-if="decomposeResult" class="dec-result">
|
||||
<div class="dec-head">
|
||||
分解完成 · {{ decomposeResult.title }}
|
||||
({{ decomposeResult.job.rounds }} 轮 · 通过 {{ decomposeResult.job.registered.length }} /
|
||||
失败 {{ decomposeResult.job.failed.length }})
|
||||
<button class="dec-close" @click="decomposeResult = null">收起</button>
|
||||
</div>
|
||||
<div v-if="decomposeResult.job.registered.length" class="dec-sec">
|
||||
✅ 已注册(incubating,进月度批评):
|
||||
<div v-for="c in decomposeResult.job.registered" :key="c.name" class="dec-item">
|
||||
<span class="mono dec-name">{{ c.name }}</span>
|
||||
<span class="dec-src">{{ c.source }}</span>
|
||||
<code class="dec-expr">{{ c.expression }}</code>
|
||||
<div class="dec-why">{{ c.justification }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="decomposeResult.job.failed.length" class="dec-sec">
|
||||
❌ 未通过硬门(不落库):
|
||||
<div v-for="(f, i) in decomposeResult.job.failed" :key="i" class="dec-item bad">
|
||||
<span class="mono">{{ f.name ?? '(未命名)' }}</span>
|
||||
<code v-if="f.expression" class="dec-expr">{{ f.expression }}</code>
|
||||
<ul class="dec-viol">
|
||||
<li v-for="(v, j) in f.violations" :key="j">{{ v.code }}:{{ v.detail }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
<div v-for="h in shown" :key="h.id" class="hcard" :class="{ dead: tab === 'graveyard' }">
|
||||
<div class="hcard-head">
|
||||
@@ -217,12 +227,77 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
|
||||
<div class="hcard-row"><span class="k">逻辑</span><span>{{ h.logic }}</span></div>
|
||||
<div class="hcard-row"><span class="k">证伪</span><span>{{ h.falsifiable || '—' }}</span></div>
|
||||
<div class="hcard-row"><span class="k">数据</span><span>{{ h.dataNeeds.length ? h.dataNeeds.join(' · ') : '—' }}</span></div>
|
||||
<div class="hcard-row"><span class="k">因子</span><span class="mono">{{ h.factorId ?? '—' }}</span></div>
|
||||
<div v-if="h.decomposeJob && h.decomposeJob.status !== 'running'" class="hcard-row">
|
||||
<!-- 件①1c:在飞轮次进度(服务端内存态,4s 轮询) -->
|
||||
<div v-if="isDecomposing(h)" class="prog">
|
||||
<span class="spin"></span><span class="ptxt">{{ progText(h) }}</span>
|
||||
<span class="pnote">后台执行 · 可离开本页</span>
|
||||
</div>
|
||||
<!-- 终态摘要行(件④:持久,刷新不丢) -->
|
||||
<div v-else-if="h.decomposeJob && h.decomposeJob.status === 'completed'" class="hcard-row">
|
||||
<span class="k">分解</span>
|
||||
<span>{{ h.decomposeJob.status === 'completed'
|
||||
? `通过 ${h.decomposeJob.registered.length} / 失败 ${h.decomposeJob.failed.length} · ${h.decomposeJob.finishedAt ?? ''}`
|
||||
: `失败:${h.decomposeJob.error ?? ''}` }}</span>
|
||||
<span>{{ h.decomposeJob.rounds }} 轮 · 通过 {{ h.decomposeJob.registered.length }} /
|
||||
拒 {{ h.decomposeJob.failed.length }} · {{ fmtTime(h.decomposeJob.finishedAt) }}</span>
|
||||
</div>
|
||||
<div v-else-if="h.decomposeJob && h.decomposeJob.status === 'failed'" class="hcard-row">
|
||||
<span class="k">分解</span>
|
||||
<span class="bad">失败:{{ h.decomposeJob.error ?? '' }}</span>
|
||||
</div>
|
||||
<!-- 件①1a:已产因子清单(registry 血统反查;单因子名 factorId 已退役) -->
|
||||
<div v-if="h.factors?.length" class="hsec">
|
||||
<div class="sec-head" @click="toggleFac(h)">
|
||||
<span class="arr">{{ openFac[h.id] ? '▾' : '▸' }}</span>
|
||||
<b>已产因子</b>{{ h.factors.length }} 条
|
||||
<span class="hint">点名进工厂 · 血统=registry 反查</span>
|
||||
</div>
|
||||
<div v-if="openFac[h.id]" class="sec-body">
|
||||
<div v-for="f in h.factors" :key="f.name" class="fac-row">
|
||||
<span class="fac-name" title="进因子工厂"
|
||||
@click="router.push('/pipeline/factors')">{{ f.name }}</span>
|
||||
<code class="fac-expr">{{ f.expression }}</code>
|
||||
<span class="fac-date">{{ f.createdAt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 件①1b:分解批次历史(decompose_jobs 台账,按需拉取) -->
|
||||
<div v-if="h.factors?.length || (h.decomposeJob && h.state !== 'queued')" class="hsec">
|
||||
<div class="sec-head" @click="toggleBat(h)">
|
||||
<span class="arr">{{ openBat[h.id] ? '▾' : '▸' }}</span>
|
||||
<b>分解批次</b>{{ batchCache[h.id]?.length ?? '…' }} 批
|
||||
<span class="hint">台账新→旧 · 点批次看明细</span>
|
||||
</div>
|
||||
<div v-if="openBat[h.id]" class="sec-body">
|
||||
<div v-if="!batchCache[h.id]?.length" class="hint">(无批次记录)</div>
|
||||
<div v-for="job in batchCache[h.id]" :key="job.jobId" class="batch">
|
||||
<div class="batch-h" @click="toggleBatch(job.jobId)">
|
||||
<b class="bt">{{ fmtTime(job.startedAt) }}</b>
|
||||
<span :class="job.status === 'completed' ? 'okn' : 'badn'">
|
||||
{{ job.status === 'completed' ? '完成' : '失败' }}</span>
|
||||
<span>通过 {{ job.registered.length }} / 拒 {{ job.failed.length }}</span>
|
||||
<span class="dim">{{ job.rounds ?? '—' }} 轮</span>
|
||||
<span class="bt jid">{{ job.jobId }}</span>
|
||||
<span class="arr">{{ openBatch[job.jobId] ? '▴' : '▾' }}</span>
|
||||
</div>
|
||||
<div v-if="openBatch[job.jobId]" class="batch-b">
|
||||
<div v-if="job.error" class="bad">{{ job.error }}</div>
|
||||
<div v-if="job.registered.length" class="bmem">
|
||||
✅ 注册(incubating):
|
||||
<span v-for="c in job.registered" :key="c.name" class="bm"
|
||||
:title="c.expression">{{ c.name }}</span>
|
||||
</div>
|
||||
<div v-if="job.failed.length" class="bmem">
|
||||
❌ 未过硬门(不落库):
|
||||
<span v-for="(f, i) in job.failed" :key="i" class="bm bad-bm"
|
||||
:title="f.violations?.map((v) => `${v.code}:${v.detail}`).join(';')">
|
||||
{{ f.name ?? '(未命名)' }}</span>
|
||||
</div>
|
||||
<div v-if="job.failed.length" class="bviol">
|
||||
<div v-for="(f, i) in job.failed" :key="i">
|
||||
{{ f.name ?? '(未命名)' }}:<template v-for="(v, j) in f.violations" :key="j">{{ v.code }}:{{ v.detail }} </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="h.deathReason" class="hcard-death">☠ {{ h.deathReason }}</div>
|
||||
<div v-if="canDecompose(h) || isDecomposing(h)" class="hcard-ops">
|
||||
@@ -265,15 +340,38 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
|
||||
.hcard-ops { display: flex; justify-content: flex-end; }
|
||||
.dec-error { font-size: 11.5px; color: #ff5f6d; margin-bottom: 10px; }
|
||||
.dec-link { color: var(--brand); cursor: pointer; margin-left: 8px; text-decoration: underline; }
|
||||
.dec-result { border: 1px solid rgba(0, 229, 255, 0.35); border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: 12px; font-size: 11.5px; }
|
||||
.dec-head { font-family: var(--mono); color: var(--brand); margin-bottom: 8px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
|
||||
.dec-close { margin-left: auto; background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-3); font-size: 10px; padding: 2px 8px; cursor: pointer; }
|
||||
.dec-sec { color: var(--text-2); margin-bottom: 8px; }
|
||||
.dec-item { border-top: 1px dashed var(--border); padding: 6px 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.dec-item.bad { color: var(--text-3); }
|
||||
.dec-name { color: var(--text); }
|
||||
.dec-src { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
|
||||
.dec-expr { font-family: var(--mono); font-size: 10.5px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 2px 6px; word-break: break-all; }
|
||||
.dec-why { color: var(--text-3); }
|
||||
.dec-viol { margin: 2px 0 0 16px; padding: 0; color: var(--text-3); }
|
||||
/* —— D7 件①三件套 —— */
|
||||
.prog { border: 1px solid rgba(0, 229, 255, 0.35); background: var(--cyan-soft); border-radius: var(--r-sm); padding: 6px 10px; font-size: 11.5px; display: flex; align-items: center; gap: 8px; }
|
||||
.spin { width: 11px; height: 11px; border: 2px solid rgba(0, 229, 255, 0.5); border-top-color: transparent; border-radius: 50%; animation: d7spin 1s linear infinite; flex-shrink: 0; }
|
||||
@keyframes d7spin { to { transform: rotate(360deg); } }
|
||||
.prog .ptxt { color: var(--brand); font-family: var(--mono); }
|
||||
.prog .pnote { margin-left: auto; color: var(--text-3); font-size: 10.5px; }
|
||||
.hsec { border-top: 1px dashed var(--border); padding-top: 5px; }
|
||||
.sec-head { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 11.5px; color: var(--text-2); user-select: none; flex-wrap: wrap; }
|
||||
.sec-head:hover { color: var(--text); }
|
||||
.sec-head b { color: var(--text); }
|
||||
.sec-head .arr { font-size: 10px; color: var(--text-3); }
|
||||
.sec-head .hint { color: var(--text-3); font-size: 10.5px; }
|
||||
.sec-body { padding: 6px 0 2px; }
|
||||
.fac-row { display: flex; align-items: baseline; gap: 8px; padding: 2.5px 0; font-size: 11px; border-bottom: 1px solid var(--border); }
|
||||
.fac-row:last-child { border-bottom: none; }
|
||||
.fac-name { font-family: var(--mono); color: var(--brand); cursor: pointer; white-space: nowrap; }
|
||||
.fac-name:hover { text-decoration: underline; }
|
||||
.fac-expr { flex: 1; min-width: 0; font-family: var(--mono); font-size: 10px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fac-date { color: var(--text-3); font-size: 10px; white-space: nowrap; }
|
||||
.batch { border: 1px solid var(--border); border-radius: var(--r-sm); margin-bottom: 5px; }
|
||||
.batch-h { display: flex; align-items: center; gap: 7px; padding: 5px 8px; cursor: pointer; font-size: 11px; flex-wrap: wrap; }
|
||||
.batch-h:hover { background: var(--cyan-soft); }
|
||||
.batch-h .bt { font-family: var(--mono); color: var(--text-2); font-size: 10.5px; }
|
||||
.batch-h .jid { margin-left: auto; color: var(--text-3); font-size: 9.5px; }
|
||||
.batch-h .arr { color: var(--text-3); font-size: 10px; }
|
||||
.batch-b { border-top: 1px solid var(--border); padding: 6px 8px; font-size: 11px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.bmem { display: flex; flex-wrap: wrap; gap: 4px; align-items: baseline; color: var(--text-3); }
|
||||
.bm { font-family: var(--mono); font-size: 10px; color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.3); border-radius: 4px; padding: 0 6px; line-height: 16px; }
|
||||
.bm.bad-bm { color: var(--text-3); border-color: var(--border); text-decoration: line-through; }
|
||||
.bviol { color: var(--text-3); font-size: 10.5px; }
|
||||
.okn { color: #2ee68a; }
|
||||
.badn { color: #ff5f6d; }
|
||||
.bad { color: #ff5f6d; }
|
||||
.dim { color: var(--text-3); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user