phase 8
This commit is contained in:
145
ui/framework/src/components/ParameterEditor.vue
Normal file
145
ui/framework/src/components/ParameterEditor.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ConfigField {
|
||||
name: string
|
||||
type: string
|
||||
default: unknown
|
||||
description: string
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: string[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ConfigField[]
|
||||
values: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update': [name: string, value: unknown]
|
||||
'reset': []
|
||||
}>()
|
||||
|
||||
const numericFields = computed(() => props.fields.filter(f => f.type === 'int' || f.type === 'float'))
|
||||
const boolFields = computed(() => props.fields.filter(f => f.type === 'bool'))
|
||||
|
||||
function onInput(name: string, value: unknown) {
|
||||
emit('update', name, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="param-editor">
|
||||
<!-- Boolean fields -->
|
||||
<label v-for="f in boolFields" :key="f.name" class="param-field bool-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!values[f.name]"
|
||||
@change="(e) => onInput(f.name, (e.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/_/g, ' ') }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Numeric fields (range sliders) -->
|
||||
<div v-for="f in numericFields" :key="f.name" class="param-field">
|
||||
<div class="field-header">
|
||||
<span class="field-label" :title="f.description">{{ f.name.replace(/^edge_/, '').replace(/_/g, ' ') }}</span>
|
||||
<span class="field-value">{{ values[f.name] }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
:min="f.min ?? 0"
|
||||
:max="f.max ?? 500"
|
||||
:step="f.type === 'float' ? 0.01 : 1"
|
||||
:value="values[f.name] as number"
|
||||
@input="(e) => onInput(f.name, Number((e.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<div class="field-range">
|
||||
<span>{{ f.min ?? 0 }}</span>
|
||||
<span>{{ f.max ?? 500 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.param-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.param-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bool-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
color: var(--text-primary);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00bcd4;
|
||||
}
|
||||
</style>
|
||||
@@ -64,9 +64,11 @@ const isHorizontal = computed(() => props.direction === 'horizontal')
|
||||
|
||||
const sizedStyle = computed(() => {
|
||||
if (props.sizeMode === 'px') {
|
||||
const sizeStr = size.value + 'px'
|
||||
const minStr = props.min + 'px'
|
||||
return isHorizontal.value
|
||||
? { width: size.value + 'px', flexShrink: '0' }
|
||||
: { height: size.value + 'px', flexShrink: '0' }
|
||||
? { width: sizeStr, minWidth: minStr, flexShrink: '0' }
|
||||
: { height: sizeStr, minHeight: minStr, flexShrink: '0' }
|
||||
}
|
||||
return { flex: String(size.value) }
|
||||
})
|
||||
|
||||
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
57
ui/framework/src/composables/useEditorExecution.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface EditorExecutionOptions {
|
||||
/** Debounce delay in ms for auto-apply. Default: 150 */
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic editor execution pattern — debounced apply with auto-apply toggle,
|
||||
* loading/error/timing state tracking.
|
||||
*
|
||||
* The caller provides the actual execution function. This composable handles
|
||||
* the orchestration: debounce, auto-apply, loading state, timing.
|
||||
*/
|
||||
export function useEditorExecution(
|
||||
executeFn: () => Promise<void>,
|
||||
options: EditorExecutionOptions = {},
|
||||
) {
|
||||
const debounceMs = options.debounceMs ?? 150
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const autoApply = ref(true)
|
||||
const execTimeMs = ref<number | null>(null)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function apply() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
execTimeMs.value = null
|
||||
const t0 = performance.now()
|
||||
try {
|
||||
await executeFn()
|
||||
execTimeMs.value = Math.round(performance.now() - t0)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onParameterChange() {
|
||||
if (!autoApply.value) return
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => apply(), debounceMs)
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
autoApply,
|
||||
execTimeMs,
|
||||
apply,
|
||||
onParameterChange,
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ export { SSEDataSource } from './datasources/SSEDataSource'
|
||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||
export { useDataSource } from './composables/useDataSource'
|
||||
export { useRegistry } from './composables/useRegistry'
|
||||
export { useEditorExecution } from './composables/useEditorExecution'
|
||||
export type { EditorExecutionOptions } from './composables/useEditorExecution'
|
||||
|
||||
// Components
|
||||
export { default as Panel } from './components/Panel.vue'
|
||||
export { default as LayoutGrid } from './components/LayoutGrid.vue'
|
||||
export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||
export { default as SplitPane } from './components/SplitPane.vue'
|
||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||
|
||||
// Renderers
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
|
||||
Reference in New Issue
Block a user