feat(api+frontend): D7 落成边亮灯——假设→因子边可见性三处 [vps]
CI/CD / test (push) Successful in 40s
CI/CD / nas-deploy (push) Successful in 30s
CI/CD / nas-verify (push) Successful in 9s

体验稿(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:
2026-10-09 11:51:00 +08:00
parent d92492a554
commit 877723ab4a
11 changed files with 521 additions and 72 deletions
+1 -1
View File
@@ -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')
+29
View File
@@ -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')
})
})
+151 -53
View File
@@ -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>