feat: Models List page (T02 master-detail template) — second KBX v60 page
Implement ModelList.vue using KBX Foundation v60 master-detail pattern: - T02 Master-Detail template for model browsing - KbxScreenFrame wrapper with breadcrumb/title - Left side: Scrollable model list with metrics (PBO, DSR, Return) - Right side: Detail panel with performance metrics and configuration - KbxTemplateStateBoundary for async state management - Status indicators (Active/Inactive) with phase color coding - Metric cards (PBO, DSR, OOS) with validation hints - Configuration display (lookback, rebalance, risk limits) - Action buttons (View Results, Start Shadow Run, Edit Config) - Dark mode and responsive layout New files: - features/models/pages/ModelList.vue (T02 master-detail page) Updated: - features/models/registry.ts (import ScreenDefinition from @kbx/contracts) Uses existing useModelsList, useModelDetail composables with TanStack Query. Demo data: 3 models (Validate, Review, Mature phases). Fully integrated with KBX v60 component library. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,492 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Models List (T02 Master-Detail Template)
|
||||||
|
* Display model list with detail panel
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { reactive, computed, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
KbxScreenFrame,
|
||||||
|
KbxMasterTemplate,
|
||||||
|
KbxTemplateStateBoundary,
|
||||||
|
KbxButton,
|
||||||
|
KbxStatusTag,
|
||||||
|
KbxInput,
|
||||||
|
KbxSelect,
|
||||||
|
KbxDataGrid,
|
||||||
|
KbxSectionHeader,
|
||||||
|
KbxFormSection,
|
||||||
|
KbxFormGrid,
|
||||||
|
} from '@kbx/ui'
|
||||||
|
import { useModelsList, useModelDetail } from '../composables/useModels'
|
||||||
|
|
||||||
|
const listQuery = useModelsList()
|
||||||
|
const selectedModelId = computed(() => listQuery.data.value?.items[0]?.modelId || null)
|
||||||
|
const detailQuery = useModelDetail(selectedModelId.value)
|
||||||
|
|
||||||
|
const filterModel = reactive({
|
||||||
|
search: '',
|
||||||
|
phase: '',
|
||||||
|
active: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumb = [
|
||||||
|
{ label: 'Model Operations', href: '/model-ops' },
|
||||||
|
{ label: 'Models' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
listQuery.refetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPhaseColor = (phase: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
'Freeze': 'default',
|
||||||
|
'Mature': 'info',
|
||||||
|
'Score': 'info',
|
||||||
|
'Diagnose': 'warning',
|
||||||
|
'Hypothesis': 'warning',
|
||||||
|
'Challenger': 'warning',
|
||||||
|
'Validate': 'success',
|
||||||
|
'Review': 'success',
|
||||||
|
'Manual Activation': 'danger',
|
||||||
|
}
|
||||||
|
return colors[phase] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPercentage = (value: number) => {
|
||||||
|
return (value || 0).toFixed(2) + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedModels = computed(() => {
|
||||||
|
return listQuery.data.value?.items?.map(m => ({
|
||||||
|
...m,
|
||||||
|
pboDisplay: formatPercentage(m.pbo),
|
||||||
|
dsrDisplay: formatPercentage(m.dsr),
|
||||||
|
returnDisplay: formatPercentage(m.returnMtd),
|
||||||
|
createdAtDisplay: formatDate(m.createdAt),
|
||||||
|
})) || []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<KbxScreenFrame
|
||||||
|
title="Models"
|
||||||
|
:breadcrumb="breadcrumb"
|
||||||
|
class="model-list"
|
||||||
|
>
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="model-list__filters">
|
||||||
|
<KbxInput
|
||||||
|
v-model="filterModel.search"
|
||||||
|
label="Search"
|
||||||
|
placeholder="Model name..."
|
||||||
|
/>
|
||||||
|
<KbxSelect
|
||||||
|
v-model="filterModel.phase"
|
||||||
|
label="Phase"
|
||||||
|
:options="[
|
||||||
|
{ value: '', label: 'All Phases' },
|
||||||
|
{ value: 'Freeze', label: 'Freeze' },
|
||||||
|
{ value: 'Mature', label: 'Mature' },
|
||||||
|
{ value: 'Validate', label: 'Validate' },
|
||||||
|
{ value: 'Review', label: 'Review' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<KbxSelect
|
||||||
|
v-model="filterModel.active"
|
||||||
|
label="Status"
|
||||||
|
:options="[
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'true', label: 'Active' },
|
||||||
|
{ value: 'false', label: 'Inactive' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<KbxButton variant="primary" size="md" label="New Model" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Master-Detail Layout -->
|
||||||
|
<KbxTemplateStateBoundary
|
||||||
|
:state="listQuery.isPending ? 'loading' : listQuery.isError ? 'error' : 'idle'"
|
||||||
|
:error="listQuery.error?.message || null"
|
||||||
|
@retry="listQuery.refetch"
|
||||||
|
>
|
||||||
|
<KbxMasterTemplate v-if="formattedModels.length">
|
||||||
|
<!-- Master: List Side -->
|
||||||
|
<template #list>
|
||||||
|
<div class="model-list__items">
|
||||||
|
<div
|
||||||
|
v-for="model in formattedModels"
|
||||||
|
:key="model.modelId"
|
||||||
|
class="model-list__item"
|
||||||
|
:class="{ 'is-active': model.active, 'is-selected': selectedModelId === model.modelId }"
|
||||||
|
@click="selectedModelId = model.modelId"
|
||||||
|
>
|
||||||
|
<div class="item-header">
|
||||||
|
<strong>{{ model.name }}</strong>
|
||||||
|
<KbxStatusTag :tone="getPhaseColor(model.phase)" :label="model.phase" />
|
||||||
|
</div>
|
||||||
|
<div class="item-metrics">
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">PBO</span>
|
||||||
|
<span class="value">{{ model.pboDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">DSR</span>
|
||||||
|
<span class="value">{{ model.dsrDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="label">Return</span>
|
||||||
|
<span class="value">{{ model.returnDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-footer">
|
||||||
|
<span v-if="model.active" class="badge-active">🟢 Active</span>
|
||||||
|
<span v-else class="badge-inactive">⚪ Inactive</span>
|
||||||
|
<span class="date">{{ model.createdAtDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Detail: Right Side -->
|
||||||
|
<template #detail>
|
||||||
|
<KbxTemplateStateBoundary
|
||||||
|
v-if="selectedModelId"
|
||||||
|
:state="detailQuery.isPending ? 'loading' : detailQuery.isError ? 'error' : 'idle'"
|
||||||
|
>
|
||||||
|
<div class="model-detail">
|
||||||
|
<KbxSectionHeader
|
||||||
|
:title="detailQuery.data.value?.name || 'Model Details'"
|
||||||
|
description="Full model configuration and validation metrics"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Metrics Grid -->
|
||||||
|
<KbxFormSection title="Performance Metrics">
|
||||||
|
<KbxFormGrid :columns="3">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Probability of Backtest Overfit</div>
|
||||||
|
<div class="metric-value">{{ formatPercentage(detailQuery.data.value?.pbo) }}</div>
|
||||||
|
<div class="metric-hint">Lower is better (≤20%)</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Daily Sharpe Ratio</div>
|
||||||
|
<div class="metric-value">{{ formatPercentage(detailQuery.data.value?.dsr) }}</div>
|
||||||
|
<div class="metric-hint">Higher is better (≥95%)</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Out-of-Sample Error</div>
|
||||||
|
<div class="metric-value">{{ formatPercentage(detailQuery.data.value?.oos) }}</div>
|
||||||
|
<div class="metric-hint">Lower is better (≤2.5%)</div>
|
||||||
|
</div>
|
||||||
|
</KbxFormGrid>
|
||||||
|
</KbxFormSection>
|
||||||
|
|
||||||
|
<!-- Configuration -->
|
||||||
|
<KbxFormSection title="Configuration">
|
||||||
|
<KbxFormGrid :columns="2">
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-label">Lookback Period</div>
|
||||||
|
<div class="config-value">{{ detailQuery.data.value?.configuration?.lookbackPeriod }} days</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-label">Rebalance Frequency</div>
|
||||||
|
<div class="config-value">{{ detailQuery.data.value?.configuration?.rebalanceFrequency }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-label">Risk Limit</div>
|
||||||
|
<div class="config-value">{{ detailQuery.data.value?.configuration?.riskLimit }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-label">Max Positions</div>
|
||||||
|
<div class="config-value">{{ detailQuery.data.value?.configuration?.maxPositions }}</div>
|
||||||
|
</div>
|
||||||
|
</KbxFormGrid>
|
||||||
|
</KbxFormSection>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="model-detail__actions">
|
||||||
|
<KbxButton variant="primary" size="md" label="View Full Results" />
|
||||||
|
<KbxButton variant="secondary" size="md" label="Start Shadow Run" />
|
||||||
|
<KbxButton variant="secondary" size="md" label="Edit Config" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</KbxTemplateStateBoundary>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="model-detail__empty">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<p>Select a model to view details</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</KbxMasterTemplate>
|
||||||
|
|
||||||
|
<!-- Empty List -->
|
||||||
|
<template #empty>
|
||||||
|
<div class="model-list__empty">
|
||||||
|
<div class="empty-icon">🎯</div>
|
||||||
|
<h3>No models yet</h3>
|
||||||
|
<p>Create your first trading model to get started</p>
|
||||||
|
<KbxButton variant="primary" size="md" label="Create New Model" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</KbxTemplateStateBoundary>
|
||||||
|
</KbxScreenFrame>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.model-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 150px 150px 140px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--kbx-color-surface-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--kbx-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--kbx-color-surface);
|
||||||
|
border: 1px solid var(--kbx-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item:hover {
|
||||||
|
background: var(--kbx-color-surface-hover);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item.is-selected {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item.is-active {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .label {
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric .value {
|
||||||
|
color: var(--kbx-color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
color: #10b981;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--kbx-color-surface-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--kbx-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--kbx-color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--kbx-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail__empty h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-detail__empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__empty .empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__empty h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__empty p {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--kbx-color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-list__filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__items {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.model-list__filters {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item {
|
||||||
|
background: #111827;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list__item.is-selected {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
* Define all screens in the models feature module
|
* Define all screens in the models feature module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { KbxScreenDefinition } from '@shared/contracts/kbx-types'
|
import type { ScreenDefinition } from '@kbx/contracts'
|
||||||
|
|
||||||
export const modelsListScreen: KbxScreenDefinition = {
|
export const modelsListScreen: ScreenDefinition = {
|
||||||
screenId: 'model-ops.models.list',
|
screenId: 'model-ops.models.list',
|
||||||
title: 'Model Management',
|
title: 'Model Management',
|
||||||
module: 'ModelOps',
|
module: 'ModelOps',
|
||||||
@@ -55,7 +55,7 @@ export const modelsListScreen: KbxScreenDefinition = {
|
|||||||
telemetry: { enabled: true },
|
telemetry: { enabled: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const modelsDetailScreen: KbxScreenDefinition = {
|
export const modelsDetailScreen: ScreenDefinition = {
|
||||||
screenId: 'model-ops.models.detail',
|
screenId: 'model-ops.models.detail',
|
||||||
title: 'Model Details',
|
title: 'Model Details',
|
||||||
module: 'ModelOps',
|
module: 'ModelOps',
|
||||||
@@ -93,4 +93,4 @@ export const modelsDetailScreen: KbxScreenDefinition = {
|
|||||||
/**
|
/**
|
||||||
* All screens in models module
|
* All screens in models module
|
||||||
*/
|
*/
|
||||||
export const modelScreens: KbxScreenDefinition[] = [modelsListScreen, modelsDetailScreen]
|
export const modelScreens: ScreenDefinition[] = [modelsListScreen, modelsDetailScreen]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Models Feature Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
modelId: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
status: 'draft' | 'training' | 'mature' | 'active' | 'retired'
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
createdBy: string
|
||||||
|
accuracy: number
|
||||||
|
sharpeRatio: number
|
||||||
|
maxDrawdown: number
|
||||||
|
trades: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelFilter {
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
minAccuracy?: number
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user