feat(frontend): 孵化梯接真——删 ladder/todos mock+毕业口令表单+闸门阈值页+评审按钮 [nas] [no-doc]
spec §4.5 决议 L 六件之 4 前端侧:适配层签名不变;GateConfig 两级阈值+审计; graduate 走后端口令闸门;三按钮留痕 registry_events。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -141,3 +141,57 @@ export async function getIcTrend(factorId: string): Promise<IcTrend> {
|
||||
const { data } = await apiClient.get<IcTrend>(`/pipeline/factors/${factorId}/ic-trend`)
|
||||
return data
|
||||
}
|
||||
|
||||
// —— P2 接真增补(决议 L):阈值配置+毕业+因子评审 ——
|
||||
|
||||
export interface GateAuditRow {
|
||||
key: string
|
||||
old_value: number | null
|
||||
new_value: number
|
||||
changed_by: string
|
||||
changed_at: string
|
||||
}
|
||||
|
||||
export interface GateConfig {
|
||||
defaults: Record<string, number>
|
||||
effective: Record<string, number>
|
||||
audit: GateAuditRow[]
|
||||
}
|
||||
|
||||
export async function getGateConfig(): Promise<GateConfig> {
|
||||
const { data } = await apiClient.get<GateConfig>('/pipeline/gate-config')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateGateConfig(key: string, value: number, changedBy: string): Promise<void> {
|
||||
await apiClient.put('/pipeline/gate-config', { key, value, changed_by: changedBy })
|
||||
}
|
||||
|
||||
export interface GraduateRequest {
|
||||
name: string
|
||||
passphrase: string
|
||||
allocMode: string
|
||||
allocValue: string
|
||||
reviewIssue?: number
|
||||
}
|
||||
|
||||
export async function graduateStrategy(req: GraduateRequest): Promise<{ ok: boolean; issue?: number }> {
|
||||
const { data } = await apiClient.post<{ ok: boolean; issue?: number }>('/pipeline/promotion/graduate', req)
|
||||
return data
|
||||
}
|
||||
|
||||
export type FactorVerdict = 'promote' | 'revise' | 'graveyard'
|
||||
|
||||
export async function postFactorVerdict(
|
||||
name: string,
|
||||
verdict: FactorVerdict,
|
||||
answers: boolean[],
|
||||
note: string,
|
||||
): Promise<{ ok: boolean; action?: string }> {
|
||||
const { data } = await apiClient.post<{ ok: boolean; action?: string }>(`/pipeline/factors/${name}/verdict`, {
|
||||
verdict,
|
||||
answers,
|
||||
note,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -119,12 +119,10 @@ export function matchMock(method: string, url: string): unknown | undefined {
|
||||
export const PIPELINE_MOCK = true
|
||||
|
||||
const pipelineRoutes: Route[] = [
|
||||
{ m: 'get', re: /^\/pipeline\/todos$/, build: () => ({ items: P.todosMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/hypotheses$/, build: () => ({ items: P.hypothesesMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/factors$/, build: () => ({ items: P.factorsMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/monthly-review$/, build: () => P.monthlyReviewMock },
|
||||
{ m: 'get', re: /^\/pipeline\/composite$/, build: () => ({ items: P.compositeMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/ladder$/, build: () => ({ items: P.ladderMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/attribution$/, build: () => P.attributionMock },
|
||||
{ m: 'get', re: /^\/pipeline\/research-card$/, build: () => ({ card: P.researchCardMock }) },
|
||||
{ m: 'get', re: /^\/pipeline\/factors\/[^/]+\/ic-trend$/, build: (url) => P.icTrendMock(url.split('/')[3]) },
|
||||
|
||||
@@ -15,9 +15,12 @@ if (jsdomStorage && !(globalThis as unknown as { localStorage?: Storage }).local
|
||||
|
||||
describe('pipeline mock 恒开短路', () => {
|
||||
it('matchPipelineMock 命中已知端点,未知名返 undefined', () => {
|
||||
expect(matchPipelineMock('get', '/pipeline/todos')).toBeDefined()
|
||||
expect(matchPipelineMock('get', '/pipeline/hypotheses')).toBeDefined()
|
||||
expect(matchPipelineMock('get', '/pipeline/factors/fa_gross_margin/ic-trend')).toBeDefined()
|
||||
expect(matchPipelineMock('get', '/pipeline/nope')).toBeUndefined()
|
||||
// P2 接真:ladder/todos 已删 mock,兜底不再拦截,直走真实后端
|
||||
expect(matchPipelineMock('get', '/pipeline/todos')).toBeUndefined()
|
||||
expect(matchPipelineMock('get', '/pipeline/ladder')).toBeUndefined()
|
||||
})
|
||||
it('适配函数经 interceptor 自定义 adapter 直达 mock(零网络)', async () => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
@@ -45,6 +45,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'pipeline/composite', name: 'pl-composite', component: () => import('@/views/pipeline/CompositeTree.vue') },
|
||||
{ path: 'pipeline/incubation', name: 'pl-incubation', component: () => import('@/views/pipeline/IncubationLadder.vue') },
|
||||
{ path: 'pipeline/attribution', name: 'pl-attribution', component: () => import('@/views/pipeline/AttributionResearch.vue') },
|
||||
{ path: 'pipeline/gate-config', name: 'pl-gate-config', component: () => import('@/views/pipeline/GateConfig.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -51,6 +51,7 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
|
||||
{ match: /^\/pipeline\/composite$/, group: '流水线', title: '合成层版本树' },
|
||||
{ match: /^\/pipeline\/incubation$/, group: '流水线', title: '孵化梯' },
|
||||
{ match: /^\/pipeline\/attribution$/, group: '流水线', title: '归因·研判' },
|
||||
{ match: /^\/pipeline\/gate-config$/, group: '流水线', title: '闸门阈值' },
|
||||
]
|
||||
const pageMeta = computed(() => {
|
||||
const p = route.path
|
||||
@@ -94,6 +95,7 @@ const CMD_ITEMS: CmdItem[] = [
|
||||
{ label: '合成层版本树', group: '流水线', path: '/pipeline/composite' },
|
||||
{ label: '孵化梯', group: '流水线', path: '/pipeline/incubation' },
|
||||
{ label: '归因·研判', group: '流水线', path: '/pipeline/attribution' },
|
||||
{ label: '闸门阈值', group: '流水线', path: '/pipeline/gate-config' },
|
||||
]
|
||||
const cmdOpen = ref(false)
|
||||
const cmdQuery = ref('')
|
||||
@@ -217,6 +219,7 @@ function onLogout(): void {
|
||||
<el-menu-item index="/pipeline/composite">合成层</el-menu-item>
|
||||
<el-menu-item index="/pipeline/incubation">孵化梯</el-menu-item>
|
||||
<el-menu-item index="/pipeline/attribution">归因·研判</el-menu-item>
|
||||
<el-menu-item index="/pipeline/gate-config">闸门阈值</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/account"><span class="nav-label">账户</span></el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import GateConfig from './GateConfig.vue'
|
||||
import * as api from '@/api/pipeline'
|
||||
|
||||
vi.mock('@/api/pipeline', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/pipeline')>()
|
||||
return { ...actual, getGateConfig: vi.fn(), updateGateConfig: vi.fn() }
|
||||
})
|
||||
|
||||
const fakeCfg = {
|
||||
defaults: { backtest_sharpe_min: 1.0, backtest_max_dd: 0.3, shadow_weeks_min: 4,
|
||||
shadow_te_annual_max: 0.05, shadow_fill_rate_min: 0.95, shadow_weekly_dev_max: 0.03 },
|
||||
effective: { backtest_sharpe_min: 1.2, backtest_max_dd: 0.3, shadow_weeks_min: 4,
|
||||
shadow_te_annual_max: 0.05, shadow_fill_rate_min: 0.95, shadow_weekly_dev_max: 0.03 },
|
||||
audit: [{ key: 'backtest_sharpe_min', old_value: 1.0, new_value: 1.2,
|
||||
changed_by: 'admin', changed_at: '2026-09-24 10:00:00' }],
|
||||
}
|
||||
|
||||
describe('GateConfig.vue 闸门阈值配置', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('渲染默认/运行值/审计六行', async () => {
|
||||
vi.mocked(api.getGateConfig).mockResolvedValue(fakeCfg)
|
||||
const w = mount(GateConfig, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(w.text()).toContain('回测夏普下限')
|
||||
expect(w.text()).toContain('1.2') // 运行值(被覆盖)
|
||||
expect(w.text()).toContain('admin') // 审计行
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getGateConfig, updateGateConfig } from '@/api/pipeline'
|
||||
import type { GateConfig } from '@/api/pipeline'
|
||||
import PanelCard from './PanelCard.vue'
|
||||
|
||||
const cfg = ref<GateConfig | null>(null)
|
||||
const editing = ref<Record<string, number>>({})
|
||||
const busy = ref(false)
|
||||
onMounted(async () => {
|
||||
cfg.value = await getGateConfig()
|
||||
})
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
backtest_sharpe_min: '回测夏普下限',
|
||||
backtest_max_dd: '回测最大回撤上限',
|
||||
shadow_weeks_min: '影子期最少周数',
|
||||
shadow_te_annual_max: '跟踪误差年化上限',
|
||||
shadow_fill_rate_min: 'fillRate 下限',
|
||||
shadow_weekly_dev_max: '周均收益偏差上限',
|
||||
}
|
||||
|
||||
async function save(key: string): Promise<void> {
|
||||
if (!cfg.value) return
|
||||
const v = editing.value[key]
|
||||
if (v === undefined) return
|
||||
busy.value = true
|
||||
try {
|
||||
await updateGateConfig(key, v, 'console')
|
||||
cfg.value = await getGateConfig()
|
||||
ElMessage.success(`${key} 已更新(审计留痕)`)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pl-page">
|
||||
<PanelCard title="闸门阈值" sub="代码默认+本机运行值两级 · 改动进审计行(谁/何时/旧→新) · 双机各改各的">
|
||||
<div v-if="cfg" class="tbl">
|
||||
<div class="row head">
|
||||
<span>指标</span><span>代码默认</span><span>本机运行值</span><span>改为</span><span></span>
|
||||
</div>
|
||||
<div v-for="key in Object.keys(cfg.defaults)" :key="key" class="row">
|
||||
<span>{{ LABELS[key] ?? key }}</span>
|
||||
<span class="mono">{{ cfg.defaults[key] }}</span>
|
||||
<span class="mono eff">{{ cfg.effective[key] }}</span>
|
||||
<input v-model.number="editing[key]" class="f-input" type="number" step="0.01" :placeholder="String(cfg.effective[key])" />
|
||||
<el-button size="small" plain :disabled="busy" @click="save(key)">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
<PanelCard v-if="cfg && cfg.audit.length" title="审计" sub="谁/何时/旧→新">
|
||||
<div v-for="(a, i) in cfg.audit" :key="i" class="audit mono">
|
||||
{{ a.changed_at }} · {{ a.changed_by }} · {{ a.key }}: {{ a.old_value }} → {{ a.new_value }}
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pl-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 14px; }
|
||||
.tbl { display: flex; flex-direction: column; gap: 6px; }
|
||||
.row { display: grid; grid-template-columns: 1.4fr 0.8fr 0.9fr 1fr auto; gap: 10px; align-items: center; font-size: 12.5px; color: var(--text-2); }
|
||||
.row.head { font-family: var(--mono); font-size: 11px; color: var(--text-3); }
|
||||
.mono { font-family: var(--mono); }
|
||||
.eff { color: var(--brand); }
|
||||
.f-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-2); font-family: var(--mono); font-size: 12px; padding: 4px 8px; width: 110px; }
|
||||
.audit { font-size: 11.5px; color: var(--text-3); padding: 3px 0; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@ import { ladderMock } from '@/mock/pipelineData'
|
||||
|
||||
vi.mock('@/api/pipeline', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/pipeline')>()
|
||||
return { ...actual, getLadder: vi.fn() }
|
||||
return { ...actual, getLadder: vi.fn(), graduateStrategy: vi.fn() }
|
||||
})
|
||||
|
||||
describe('IncubationLadder.vue', () => {
|
||||
@@ -28,4 +28,22 @@ describe('IncubationLadder.vue', () => {
|
||||
expect(w.text()).toContain('分配方式')
|
||||
expect(w.text()).toContain('固定金额')
|
||||
})
|
||||
it('毕业表单填齐后调 graduate API 并刷新梯子', async () => {
|
||||
vi.mocked(api.getLadder).mockResolvedValue(ladderMock)
|
||||
vi.mocked(api.graduateStrategy).mockResolvedValue({ ok: true, issue: 72 })
|
||||
const w = mount(IncubationLadder, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const inputs = w.findAll('input')
|
||||
const pwd = inputs.find((i) => i.attributes('type') === 'password')
|
||||
await pwd!.setValue('口令123')
|
||||
const text = inputs.filter((i) => i.attributes('type') !== 'password')
|
||||
await text[text.length - 1].setValue('50000')
|
||||
const btn = w.findAll('button').find((b) => b.text().includes('promotion'))
|
||||
await btn!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.graduateStrategy).toHaveBeenCalled()
|
||||
const call = vi.mocked(api.graduateStrategy).mock.calls[0][0]
|
||||
expect(call.passphrase).toBe('口令123')
|
||||
expect(call.name).toBe('quality_momentum_v1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getLadder } from '@/api/pipeline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getLadder, graduateStrategy } from '@/api/pipeline'
|
||||
import type { LadderStrategy } from '@/api/pipeline'
|
||||
import StatusChip from '@/components/pipeline/StatusChip.vue'
|
||||
import { LADDER_LABEL, LADDER_TONE } from '@/components/pipeline/tones'
|
||||
import PanelCard from './PanelCard.vue'
|
||||
|
||||
const items = ref<LadderStrategy[]>([])
|
||||
onMounted(async () => {
|
||||
items.value = await getLadder()
|
||||
})
|
||||
const load = async () => { items.value = await getLadder() }
|
||||
onMounted(load)
|
||||
|
||||
const STAGES: LadderStrategy['stage'][] = ['backtest', 'paper', 'shadow', 'live']
|
||||
const byStage = (s: LadderStrategy['stage']): LadderStrategy[] => items.value.filter((x) => x.stage === s)
|
||||
const grads = computed(() => items.value.filter((x) => x.stage === 'shadow' && (x.shadowWeeks ?? 0) >= (x.shadowTotalWeeks ?? 4)))
|
||||
|
||||
const ALLOC_MODES = ['占账户比例', '固定金额']
|
||||
const form = ref<Record<string, { passphrase: string; allocMode: string; allocValue: string }>>({})
|
||||
const busy = ref(false)
|
||||
|
||||
const formOf = (g: LadderStrategy) =>
|
||||
(form.value[g.id] ??= { passphrase: '', allocMode: '固定金额', allocValue: '' })
|
||||
|
||||
async function doGraduate(g: LadderStrategy): Promise<void> {
|
||||
const f = formOf(g)
|
||||
if (!f.passphrase || !f.allocValue) {
|
||||
ElMessage.warning('口令与初始值必填')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await graduateStrategy({
|
||||
name: g.name,
|
||||
passphrase: f.passphrase,
|
||||
allocMode: f.allocMode,
|
||||
allocValue: f.allocValue,
|
||||
})
|
||||
ElMessage.success(r.ok ? `毕业成功${r.issue ? `,issue #${r.issue}` : ''}` : '毕业失败')
|
||||
f.passphrase = ''
|
||||
await load()
|
||||
} catch {
|
||||
// axios 错误信息由拦截器/ElMessage 呈现;此处仅复位
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,16 +73,16 @@ const ALLOC_MODES = ['占账户比例', '固定金额']
|
||||
<span class="mono grad-name">{{ g.name }}</span>
|
||||
<div class="grad-form">
|
||||
<span class="f-label">promotion 口令</span>
|
||||
<input type="password" class="f-input" placeholder="用户原话口令" disabled />
|
||||
<input v-model="formOf(g).passphrase" type="password" class="f-input" placeholder="用户原话口令" />
|
||||
<span class="f-label">分配方式</span>
|
||||
<select class="f-input" disabled>
|
||||
<select v-model="formOf(g).allocMode" class="f-input">
|
||||
<option v-for="m in ALLOC_MODES" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<span class="f-label">初始值</span>
|
||||
<input class="f-input" placeholder="如 5% 或 50,000" disabled />
|
||||
<el-button size="small" type="primary" plain disabled>promotion</el-button>
|
||||
<input v-model="formOf(g).allocValue" class="f-input" placeholder="如 5 或 50,000" />
|
||||
<el-button size="small" type="primary" plain :disabled="busy" @click="doGraduate(g)">promotion</el-button>
|
||||
</div>
|
||||
<div class="grad-note">P0 原型:表单禁用仅展示流程;P1 接真后口令走后端闸门</div>
|
||||
<div class="grad-note">口令走后端闸门(SANGUO_PROMOTION_PASSPHRASE);起实盘实例仍是手动动作,档案只记录</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getFactors } from '@/api/pipeline'
|
||||
import { postFactorVerdict } from '@/api/pipeline'
|
||||
import type { PipelineFactor } from '@/api/pipeline'
|
||||
import type { FactorVerdict } from '@/api/pipeline'
|
||||
import PanelCard from './PanelCard.vue'
|
||||
|
||||
const factors = ref<PipelineFactor[]>([])
|
||||
@@ -20,6 +23,33 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
|
||||
{ q: '四问:覆盖度够吗?', hint: '完整性门:覆盖率≥40%,样本期≥12 个月(S 族 10 月首窗)' },
|
||||
{ q: '五问:入合成的边际增量?', hint: '对照现役合成回测,无增量=观察位不入列' },
|
||||
]
|
||||
|
||||
const answers = ref<Record<number, 'y' | 'n' | ''>>({})
|
||||
const note = ref('')
|
||||
const busy = ref(false)
|
||||
const reload = async () => {
|
||||
factors.value = (await getFactors()).filter((f) => f.status === 'assessable' && (f.icRecentT ?? 0) >= 2)
|
||||
picked.value = factors.value[0]?.id ?? ''
|
||||
answers.value = {}
|
||||
}
|
||||
|
||||
async function submit(verdict: FactorVerdict): Promise<void> {
|
||||
if (!current.value) return
|
||||
if ([0, 1, 2, 3, 4].some((i) => !answers.value[i])) {
|
||||
ElMessage.warning('五问全部作答后再提交')
|
||||
return
|
||||
}
|
||||
const arr: boolean[] = [0, 1, 2, 3, 4].map((i) => answers.value[i] === 'y')
|
||||
busy.value = true
|
||||
try {
|
||||
const r = await postFactorVerdict(current.value.id, verdict, arr, note.value)
|
||||
ElMessage.success(`已记录:${r.action ?? verdict}`)
|
||||
note.value = ''
|
||||
await reload()
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,18 +73,19 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
|
||||
<div class="q-head">
|
||||
<span>{{ item.q }}</span>
|
||||
<div class="q-ops">
|
||||
<label><input type="radio" :name="`q${i}`" value="y" />是</label>
|
||||
<label><input type="radio" :name="`q${i}`" value="n" />否</label>
|
||||
<label><input type="radio" :name="`q${i}`" value="y" v-model="answers[i]" />是</label>
|
||||
<label><input type="radio" :name="`q${i}`" value="n" v-model="answers[i]" />否</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-hint">{{ item.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verdict">
|
||||
<el-button size="small" type="success" plain>晋级入合成候选</el-button>
|
||||
<el-button size="small" type="warning" plain>退回改造(升版本)</el-button>
|
||||
<el-button size="small" type="danger" plain>淘汰入墓园</el-button>
|
||||
<span class="verdict-note">P0 原型:按钮仅留痕,评审记录落档在 P1 接后端</span>
|
||||
<input v-model="note" class="f-input" placeholder="评审备注(入墓园=死因必填)" />
|
||||
<el-button size="small" type="success" plain :disabled="busy" @click="submit('promote')">晋级入合成候选</el-button>
|
||||
<el-button size="small" type="warning" plain :disabled="busy" @click="submit('revise')">退回改造(留观)</el-button>
|
||||
<el-button size="small" type="danger" plain :disabled="busy" @click="submit('graveyard')">淘汰入墓园</el-button>
|
||||
<span class="verdict-note">评审记录落 registry_events.jsonl(与版本快照关联);改造升版本走 factor 域流程</span>
|
||||
</div>
|
||||
</template>
|
||||
</PanelCard>
|
||||
@@ -73,6 +104,7 @@ const FIVE_QUESTIONS: Array<{ q: string; hint: string }> = [
|
||||
.q-ops { display: flex; gap: 12px; font-size: 12px; color: var(--text-2); }
|
||||
.q-ops label { cursor: pointer; }
|
||||
.q-hint { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
||||
.f-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-2); font-family: var(--mono); font-size: 12px; padding: 4px 8px; min-width: 120px; }
|
||||
.verdict { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.verdict-note { font-size: 10.5px; color: var(--text-3); font-family: var(--mono); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user