427 lines
23 KiB
Vue
427 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { getHypotheses, getGraveyardFactors, draftHypothesis, saveHypothesis, startDecompose,
|
||
getDecomposeJobs, draftInflight } 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'
|
||
import PanelCard from './PanelCard.vue'
|
||
|
||
const all = ref<Hypothesis[]>([])
|
||
const tab = ref<'pool' | 'graveyard'>('pool')
|
||
// 因子墓园(10-09 可见性补):判死因子不上工厂板/详情 404,唯一视图在此
|
||
const deadFactors = ref<GraveyardFactor[]>([])
|
||
onMounted(async () => {
|
||
// 后端未接真(P4 排期):失败退化空池,不裸抛 unhandled rejection
|
||
all.value = await getHypotheses().catch(() => [])
|
||
deadFactors.value = await getGraveyardFactors().catch(() => [])
|
||
ensurePoll() // 挂载即查:服务端在飞分解(切页/刷新回来)续接轮询
|
||
})
|
||
onUnmounted(stopPoll)
|
||
|
||
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))
|
||
|
||
const sentence = ref('')
|
||
const draft = ref<CardDraftInput | null>(null)
|
||
const busy = ref<'idle' | 'drafting' | 'saving'>('idle')
|
||
const error = ref('')
|
||
|
||
async function genDraft() {
|
||
if (!sentence.value.trim() || busy.value !== 'idle' || draftInflight.value) return
|
||
busy.value = 'drafting'; error.value = ''
|
||
try {
|
||
draft.value = await draftHypothesis(sentence.value.trim())
|
||
} catch (e: any) {
|
||
error.value = e?.response?.data?.detail ?? e?.message ?? '生成失败(LLM 未配置或返回异常)'
|
||
} finally { busy.value = 'idle' }
|
||
}
|
||
|
||
async function confirmSave() {
|
||
if (!draft.value || busy.value !== 'idle') return
|
||
busy.value = 'saving'; error.value = ''
|
||
try {
|
||
const item = await saveHypothesis(draft.value, sentence.value.trim())
|
||
all.value = [item, ...all.value.filter((h) => h.id !== item.id)]
|
||
draft.value = null; sentence.value = ''
|
||
} catch (e: any) {
|
||
error.value = e?.response?.data?.detail ?? '落卡失败'
|
||
} finally { busy.value = 'idle' }
|
||
}
|
||
|
||
const router = useRouter()
|
||
const DECOMPOSABLE: Hypothesis['state'][] = ['queued', 'data_check', 'building']
|
||
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
|
||
// 在飞态=服务端真相(卡片 decomposeJob),前端只轮询不登记(10-09 job 化)
|
||
const isDecomposing = (h: Hypothesis): boolean => h.decomposeJob?.status === 'running'
|
||
|
||
// 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) {
|
||
all.value = [...all.value.slice(0, idx),
|
||
{ ...all.value[idx], decomposeJob: job },
|
||
...all.value.slice(idx + 1)]
|
||
}
|
||
}
|
||
|
||
function stopPoll() {
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||
}
|
||
|
||
function ensurePoll() {
|
||
if (pollTimer) return
|
||
pollTimer = setInterval(async () => {
|
||
const items = await getHypotheses().catch(() => null)
|
||
if (!items) return
|
||
all.value = items
|
||
if (watchJobId) {
|
||
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)
|
||
}
|
||
|
||
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 = ''
|
||
// 完成即展开产物区(mockup 同款「长出」),批次缓存失效重拉
|
||
openFac.value = { ...openFac.value, [hypId]: true }
|
||
openBat.value = { ...openBat.value, [hypId]: true }
|
||
delete batchCache.value[hypId]
|
||
batchCache.value = { ...batchCache.value }
|
||
refreshBatches(hypId)
|
||
} else {
|
||
decomposeError.value = job.error || '分解失败'
|
||
}
|
||
stopPoll()
|
||
}
|
||
|
||
async function runDecompose(h: Hypothesis): Promise<void> {
|
||
if (isDecomposing(h) || !canDecompose(h)) return
|
||
decomposeError.value = ''
|
||
try {
|
||
const job = await startDecompose(h.id)
|
||
watchJobId = job.jobId
|
||
patchJob(h.id, job)
|
||
ensurePoll()
|
||
} catch (e: any) {
|
||
const status = e?.response?.status
|
||
const detail = e?.response?.data?.detail ?? e?.message ?? ''
|
||
if (status === 503) {
|
||
decomposeError.value = `LLM 未配置——${detail}`
|
||
} else if (status === 409) {
|
||
// 409 双义:「在飞(后台任务)」原文直出 /「状态不可分解」带前缀
|
||
decomposeError.value = detail.includes('进行中')
|
||
? detail : `当前状态不可分解——${detail}`
|
||
} else {
|
||
decomposeError.value = detail || '分解启动失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
function onDraftInput(key: keyof CardDraftInput, v: string) {
|
||
if (!draft.value) return
|
||
if (key === 'dataNeeds') {
|
||
draft.value.dataNeeds = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
|
||
} else {
|
||
(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>
|
||
<div class="pl-page">
|
||
<PanelCard title="假设池" :sub="`输入即持久工件 · 池内 ${pool.length} 张 · 墓园 ${graveyard.length} 张(负结果留档防重复造轮)`">
|
||
<template #action>
|
||
<div class="tabs">
|
||
<button class="tab-btn" :class="{ on: tab === 'pool' }" @click="tab = 'pool'">活跃池</button>
|
||
<button class="tab-btn" :class="{ on: tab === 'graveyard' }" @click="tab = 'graveyard'">墓园</button>
|
||
</div>
|
||
</template>
|
||
<!-- 向导区(决议 M:一句话→五字段草稿→人工确认落卡;LLM 不落库) -->
|
||
<div class="wizard">
|
||
<div class="wiz-row">
|
||
<input v-model="sentence" class="wiz-input" maxlength="500"
|
||
placeholder="一句话描述投资假设,例:高管增持之后股价会涨" />
|
||
<button class="wiz-btn" :disabled="busy !== 'idle' || draftInflight || !sentence.trim()"
|
||
@click="genDraft">{{ busy === 'drafting' || draftInflight ? '生成中…' : 'AI 生成草稿' }}</button>
|
||
</div>
|
||
<div v-if="error" class="wiz-error">{{ error }}</div>
|
||
<div v-if="draft" class="wiz-form">
|
||
<label>假设陈述 <input class="wiz-input" :value="draft.title"
|
||
@input="onDraftInput('title', ($event.target as HTMLInputElement).value)" /></label>
|
||
<label>依据(条件句式) <input class="wiz-input" :value="draft.logic"
|
||
@input="onDraftInput('logic', ($event.target as HTMLInputElement).value)" /></label>
|
||
<label>预期方向
|
||
<select class="wiz-input" :value="draft.expectedSign"
|
||
@change="onDraftInput('expectedSign', ($event.target as HTMLSelectElement).value)">
|
||
<option value="positive">positive(正向)</option>
|
||
<option value="negative">negative(反向)</option>
|
||
</select>
|
||
</label>
|
||
<label>可证伪声明 <input class="wiz-input" :value="draft.falsifiable"
|
||
@input="onDraftInput('falsifiable', ($event.target as HTMLInputElement).value)" /></label>
|
||
<label>关联数据域(逗号分隔) <input class="wiz-input" :value="draft.dataNeeds.join(',')"
|
||
@input="onDraftInput('dataNeeds', ($event.target as HTMLInputElement).value)" /></label>
|
||
<div class="wiz-row">
|
||
<button class="wiz-btn primary" :disabled="busy !== 'idle'"
|
||
@click="confirmSave">{{ busy === 'saving' ? '落卡中…' : '确认落卡' }}</button>
|
||
<button class="wiz-btn" @click="draft = null">放弃草稿</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="decomposeError" class="dec-error">
|
||
{{ decomposeError }}
|
||
<a v-if="decomposeError.includes('LLM 未配置')" class="dec-link"
|
||
@click="router.push('/pipeline/settings')">去设置页配置 →</a>
|
||
</div>
|
||
<div class="cards">
|
||
<div v-for="h in shown" :key="h.id" class="hcard" :class="{ dead: tab === 'graveyard' }">
|
||
<div class="hcard-head">
|
||
<span class="hcard-title">{{ h.title }}</span>
|
||
<StatusChip :label="HYPOTHESIS_LABEL[h.state]" :tone="HYPOTHESIS_TONE[h.state]" />
|
||
<span v-if="deadFactorCount(h)" class="skull-badge"
|
||
:title="`卡下 ${deadFactorCount(h)}/${h.factors!.length} 因子已判死(卡留活跃池)`">
|
||
☠{{ deadFactorCount(h) }}/{{ h.factors!.length }}
|
||
</span>
|
||
</div>
|
||
<div class="hcard-row"><span class="k">来源</span><span>{{ h.source }}</span></div>
|
||
<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>
|
||
<!-- 件①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.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">
|
||
<template v-for="(f, i) in h.factors" :key="f.name">
|
||
<!-- 批次分割线:批次切换处插行;首条无批次而清单非空=早期段 -->
|
||
<div v-if="f.batch && (i === 0 || h.factors[i - 1].batch?.jobId !== f.batch.jobId)"
|
||
class="fac-batch">
|
||
── 第 {{ f.batch.startedAt.slice(5, 16).replace('T', ' ') }} 批 · {{ f.batch.jobId }} ──
|
||
</div>
|
||
<div v-else-if="!f.batch && (i === 0 || h.factors[i - 1].batch)" class="fac-batch early">
|
||
── 早期(无批次记录) ──
|
||
</div>
|
||
<div class="fac-row">
|
||
<span v-if="f.status === 'graveyard'" class="fac-name dead"
|
||
:title="`☠ ${f.cause ?? '未注死因'}`">☠ {{ f.name }}</span>
|
||
<span v-else class="fac-name" title="看出生档案"
|
||
@click="router.push(`/pipeline/factors/${f.name}`)">{{ f.name }}</span>
|
||
<code class="fac-expr">{{ f.expression }}</code>
|
||
<span class="fac-date">{{ f.createdAt }}</span>
|
||
</div>
|
||
</template>
|
||
</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">
|
||
<button class="wiz-btn" :disabled="isDecomposing(h)"
|
||
@click="runDecompose(h)">
|
||
{{ isDecomposing(h) ? '分解中(后台执行,可离开本页)…' : '⚡ AI 分解' }}
|
||
</button>
|
||
</div>
|
||
<div class="hcard-foot">{{ h.updatedAt }}</div>
|
||
</div>
|
||
</div>
|
||
<!-- 因子墓园(判死因子档案:registry graveyard 条目,死因可检索防重复造轮) -->
|
||
<div v-if="tab === 'graveyard'" class="gfac">
|
||
<div class="gfac-head">
|
||
☠ 判死因子 {{ deadFactors.length }} 条
|
||
<span class="hint">注册表档案 · 不上工厂板 · 不可复活</span>
|
||
</div>
|
||
<div v-if="!deadFactors.length" class="hint">(暂无判死因子——工厂详情页「送墓园」与考不过的夜班批会进这里)</div>
|
||
<div v-for="f in deadFactors" :key="f.name" class="gfac-row">
|
||
<span class="fac-name mono">{{ f.name }}</span>
|
||
<span class="dim">{{ f.hypothesis || '无假设卡(存量/早期)' }}</span>
|
||
<span class="gfac-cause">{{ f.cause }}</span>
|
||
</div>
|
||
</div>
|
||
</PanelCard>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.pl-page { max-width: 1080px; margin: 0 auto; }
|
||
.tabs { display: flex; gap: 6px; }
|
||
.tab-btn { background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-3); font-family: var(--mono); font-size: 11px; padding: 3px 12px; cursor: pointer; transition: all 0.15s var(--ease); }
|
||
.tab-btn.on { color: var(--brand); border-color: rgba(0, 229, 255, 0.5); background: var(--cyan-soft); }
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||
.hcard { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 10px 12px; display: flex; flex-direction: column; gap: 5px; }
|
||
.hcard.dead { opacity: 0.75; }
|
||
.hcard-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||
.hcard-title { font-size: 13px; color: var(--text); font-weight: 600; }
|
||
.hcard-row { display: flex; gap: 8px; font-size: 11.5px; }
|
||
.hcard-row .k { color: var(--text-3); font-family: var(--mono); flex-shrink: 0; width: 34px; }
|
||
.hcard-death { font-size: 11px; color: #ff5f6d; border-top: 1px dashed var(--border); padding-top: 5px; }
|
||
/* 因子墓园区(判死因子档案) */
|
||
.gfac { margin-top: 10px; border-top: 1px dashed var(--border); padding-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||
.gfac-head { font-size: 12px; color: var(--text-2); font-weight: 600; display: flex; gap: 8px; align-items: baseline; }
|
||
.gfac-row { display: flex; gap: 10px; font-size: 11.5px; align-items: baseline; flex-wrap: wrap; }
|
||
.gfac-row .fac-name { color: var(--text-3); text-decoration: line-through; text-decoration-color: rgba(255, 95, 109, 0.5); }
|
||
.gfac-cause { color: #ff8a94; flex: 1; min-width: 200px; }
|
||
.hint { color: var(--text-3); font-size: 11px; }
|
||
.hcard-foot { font-size: 10px; color: var(--text-3); font-family: var(--mono); text-align: right; }
|
||
.mono { font-family: var(--mono); }
|
||
.wizard { border: 1px dashed var(--border); border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||
.wiz-row { display: flex; gap: 8px; }
|
||
.wiz-input { flex: 1; background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-size: 12px; padding: 6px 8px; }
|
||
.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-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; }
|
||
.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; }
|
||
/* —— 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-name.dead { text-decoration: line-through; color: var(--text-3); cursor: default; }
|
||
.skull-badge { font-family: var(--mono); font-size: 10px; color: #ff5f6d;
|
||
border: 1px solid rgba(255, 95, 109, 0.4); border-radius: var(--r-sm);
|
||
padding: 0 5px; line-height: 16px; flex-shrink: 0; }
|
||
.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; }
|
||
.fac-batch { font-family: var(--mono); font-size: 9.5px; color: var(--cyan-dim); border-top: 1px dashed rgba(0, 184, 204, 0.35); margin-top: 4px; padding-top: 3px; }
|
||
.fac-batch:first-child { border-top: none; margin-top: 0; padding-top: 0; }
|
||
.fac-batch.early { color: var(--text-3); border-top-color: var(--border); }
|
||
.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>
|