fix(web): 完善模型组件校验与控件复用
This commit is contained in:
8 files changed
+322
-161
No files matched your search
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
import { computed, nextTick, ref, useAttrs, useId } from 'vue'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
id?: string
|
||||
type?: 'text' | 'search'
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
describedBy?: string
|
||||
clearLabel?: string
|
||||
appearance?: 'surface' | 'sunken'
|
||||
size?: 'md' | 'lg' | 'touch'
|
||||
autocomplete?: string
|
||||
spellcheck?: boolean
|
||||
monospace?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
type: 'text',
|
||||
placeholder: undefined,
|
||||
disabled: false,
|
||||
invalid: false,
|
||||
describedBy: undefined,
|
||||
clearLabel: undefined,
|
||||
appearance: 'surface',
|
||||
size: 'md',
|
||||
autocomplete: 'off',
|
||||
spellcheck: true,
|
||||
monospace: false,
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const inputId = computed(() => props.id ?? `${generatedId}-input`)
|
||||
const input = ref<HTMLInputElement>()
|
||||
|
||||
function focus(): void {
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
async function clear(): Promise<void> {
|
||||
emit('update:modelValue', '')
|
||||
await nextTick()
|
||||
focus()
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="app-text-input"
|
||||
:class="[
|
||||
`app-text-input--${appearance}`,
|
||||
`app-text-input--${size}`,
|
||||
{ 'app-text-input--disabled': disabled, 'app-text-input--invalid': invalid },
|
||||
]"
|
||||
>
|
||||
<label class="sr-only" :for="inputId">{{ label }}</label>
|
||||
<span v-if="$slots.leading" class="app-text-input__leading" aria-hidden="true">
|
||||
<slot name="leading" />
|
||||
</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="input"
|
||||
v-bind="attrs"
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
:class="{ 'app-text-input__input--mono': monospace }"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="clearLabel && modelValue"
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
:disabled="disabled"
|
||||
:label="clearLabel"
|
||||
@click="clear"
|
||||
>
|
||||
<X :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-text-input {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--color-text-muted);
|
||||
padding-left: var(--space-3);
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--easing-standard),
|
||||
box-shadow var(--duration-fast) var(--easing-standard),
|
||||
opacity var(--duration-fast) var(--easing-standard);
|
||||
}
|
||||
|
||||
.app-text-input--surface {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.app-text-input--sunken {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.app-text-input--md {
|
||||
min-height: var(--control-md);
|
||||
}
|
||||
|
||||
.app-text-input--lg {
|
||||
min-height: var(--control-lg);
|
||||
}
|
||||
|
||||
.app-text-input--touch {
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
.app-text-input:focus-within:not(.app-text-input--disabled) {
|
||||
border-color: var(--color-action);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.app-text-input--invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.app-text-input--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-text-input input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: inherit;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-text-input input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-text-input input.app-text-input__input--mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.app-text-input__leading {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.app-text-input :deep(.icon-button) {
|
||||
width: var(--touch-target);
|
||||
height: var(--touch-target);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -25,6 +25,7 @@ import ModelDiscoveryDrawer from '@/features/models/ModelDiscoveryDrawer.vue'
|
||||
import {
|
||||
createModelDraft,
|
||||
findModelNameConflicts,
|
||||
modelDraftValidity,
|
||||
normalizedModels,
|
||||
sameModels,
|
||||
type ModelAliasEditorLabels,
|
||||
@@ -58,14 +59,7 @@ const conflicts = computed(() =>
|
||||
? serverConflicts.value
|
||||
: findModelNameConflicts(normalizedModels(draft.value)),
|
||||
)
|
||||
const emptyAliasIndexes = computed(
|
||||
() =>
|
||||
new Set(
|
||||
draft.value.flatMap((item, index) =>
|
||||
item.alias_enabled && !item.alias.trim() ? [index] : [],
|
||||
),
|
||||
),
|
||||
)
|
||||
const validity = computed(() => modelDraftValidity(draft.value, conflicts.value))
|
||||
const dirty = computed(() => !sameModels(saved.value, draft.value))
|
||||
const empty = computed(() => normalizedModels(draft.value).length === 0)
|
||||
const canSave = computed(
|
||||
@@ -73,7 +67,7 @@ const canSave = computed(
|
||||
dirty.value &&
|
||||
pending.value === null &&
|
||||
conflicts.value.length === 0 &&
|
||||
emptyAliasIndexes.value.size === 0,
|
||||
validity.value.emptyAliasIndexes.size === 0,
|
||||
)
|
||||
const unpriced = computed(
|
||||
() => draft.value.filter((item) => item.pricing_status === 'unpriced').length,
|
||||
@@ -95,7 +89,10 @@ const aliasEditorLabels = computed<ModelAliasEditorLabels>(() => ({
|
||||
manualId: t('group.modelEditor.manualId'),
|
||||
add: t('group.modelEditor.add'),
|
||||
empty: t('group.modelEditor.empty'),
|
||||
noMatches: t('group.modelEditor.noMatches'),
|
||||
conflictSummary: t('group.modelEditor.conflictSummary'),
|
||||
emptyAliasSummary: t('group.modelEditor.emptyAliasSummary'),
|
||||
locateFirstInvalid: t('group.modelEditor.locateFirstInvalid'),
|
||||
nameConflict: (name) => t('group.modelEditor.nameConflict', { name }),
|
||||
}))
|
||||
const discoveryDrawerLabels = computed<ModelDiscoveryDrawerLabels>(() => ({
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts" generic="T extends ModelDraftValue">
|
||||
import { Plus, Search, Trash2, X } from '@lucide/vue'
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import { Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
|
||||
import LedgerRecordList from '@/components/collection/LedgerRecordList.vue'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
|
||||
import {
|
||||
indexesWithConflicts,
|
||||
modelDraftValidity,
|
||||
type ModelAliasEditorLabels,
|
||||
type ModelDraftValue,
|
||||
type ModelNameConflict,
|
||||
@@ -34,16 +35,17 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: T[]] }>()
|
||||
|
||||
const instanceId = useId()
|
||||
const root = ref<HTMLElement>()
|
||||
const search = ref('')
|
||||
const manualID = ref('')
|
||||
const conflictIndexes = computed(() => indexesWithConflicts(props.conflicts))
|
||||
const emptyAliasIndexes = computed(
|
||||
() =>
|
||||
new Set(
|
||||
props.modelValue.flatMap((item, index) =>
|
||||
item.alias_enabled && !item.alias.trim() ? [index] : [],
|
||||
),
|
||||
),
|
||||
const validity = computed(() => modelDraftValidity(props.modelValue, props.conflicts))
|
||||
const validationSummary = computed(() =>
|
||||
[
|
||||
props.conflicts.length ? props.labels.conflictSummary : '',
|
||||
validity.value.emptyAliasIndexes.size ? props.labels.emptyAliasSummary : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
)
|
||||
const visibleRows = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
@@ -92,36 +94,41 @@ function conflictMessage(index: number): string {
|
||||
const conflict = props.conflicts.find((item) => item.indexes.includes(index))
|
||||
return conflict ? props.labels.nameConflict(conflict.client_model) : ''
|
||||
}
|
||||
|
||||
async function focusFirstInvalid(): Promise<void> {
|
||||
const index = Math.min(...validity.value.invalidIndexes)
|
||||
if (!Number.isFinite(index)) return
|
||||
search.value = ''
|
||||
await nextTick()
|
||||
const selector = props.modelValue[index]?.alias_enabled
|
||||
? `[data-alias-input-index="${index}"]`
|
||||
: `[data-alias-toggle-index="${index}"]`
|
||||
root.value?.querySelector<HTMLInputElement>(selector)?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-alias-editor">
|
||||
<InlineFeedback v-if="conflicts.length" tone="danger">
|
||||
{{ labels.conflictSummary }}
|
||||
</InlineFeedback>
|
||||
<div ref="root" class="model-alias-editor">
|
||||
<div v-if="validity.invalidIndexes.size" class="model-alias-editor__validation">
|
||||
<InlineFeedback tone="danger">{{ validationSummary }}</InlineFeedback>
|
||||
<AppButton variant="link" size="inline" :disabled="disabled" @click="focusFirstInvalid">
|
||||
{{ labels.locateFirstInvalid }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="searchable" class="model-alias-editor__toolbar">
|
||||
<label class="model-alias-editor__search">
|
||||
<span class="sr-only">{{ labels.search }}</span>
|
||||
<Search :size="16" aria-hidden="true" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.search"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="search"
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
:disabled="disabled"
|
||||
:label="labels.clearSearch"
|
||||
@click="search = ''"
|
||||
>
|
||||
<X :size="15" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</label>
|
||||
<AppTextInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
appearance="sunken"
|
||||
size="touch"
|
||||
:label="labels.search"
|
||||
:placeholder="labels.search"
|
||||
:clear-label="labels.clearSearch"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #leading><Search :size="16" /></template>
|
||||
</AppTextInput>
|
||||
</div>
|
||||
|
||||
<LedgerRecordList
|
||||
@@ -142,7 +149,7 @@ function conflictMessage(index: number): string {
|
||||
v-for="({ item, index }, visibleIndex) in visibleRows"
|
||||
:key="item.key"
|
||||
class="ledger-record-list__record model-alias-editor__record"
|
||||
:class="{ 'model-alias-editor__record--conflict': conflictIndexes.has(index) }"
|
||||
:class="{ 'model-alias-editor__record--invalid': validity.invalidIndexes.has(index) }"
|
||||
role="row"
|
||||
:aria-rowindex="visibleIndex + 2"
|
||||
>
|
||||
@@ -157,9 +164,14 @@ function conflictMessage(index: number): string {
|
||||
<label class="model-alias-editor__alias-toggle">
|
||||
<span class="sr-only">{{ labels.aliasEnabledFor(item.id) }}</span>
|
||||
<input
|
||||
:data-alias-toggle-index="index"
|
||||
type="checkbox"
|
||||
:checked="item.alias_enabled"
|
||||
:disabled="disabled"
|
||||
:aria-invalid="validity.invalidIndexes.has(index) || undefined"
|
||||
:aria-describedby="
|
||||
validity.invalidIndexes.has(index) ? `${instanceId}-error-${index}` : undefined
|
||||
"
|
||||
@change="
|
||||
updateRow(index, {
|
||||
alias_enabled: ($event.target as HTMLInputElement).checked,
|
||||
@@ -167,34 +179,30 @@ function conflictMessage(index: number): string {
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:value="item.alias"
|
||||
<AppTextInput
|
||||
:model-value="item.alias"
|
||||
appearance="sunken"
|
||||
:label="labels.aliasFor(item.id)"
|
||||
:disabled="disabled || !item.alias_enabled"
|
||||
:placeholder="labels.aliasPlaceholder"
|
||||
:aria-label="labels.aliasFor(item.id)"
|
||||
:aria-invalid="
|
||||
conflictIndexes.has(index) || emptyAliasIndexes.has(index) || undefined
|
||||
:invalid="validity.invalidIndexes.has(index)"
|
||||
:described-by="
|
||||
validity.invalidIndexes.has(index) ? `${instanceId}-error-${index}` : undefined
|
||||
"
|
||||
:aria-describedby="
|
||||
conflictIndexes.has(index) || emptyAliasIndexes.has(index)
|
||||
? `${instanceId}-error-${index}`
|
||||
: undefined
|
||||
"
|
||||
@input="updateRow(index, { alias: ($event.target as HTMLInputElement).value })"
|
||||
:data-alias-input-index="index"
|
||||
:spellcheck="false"
|
||||
@update:model-value="updateRow(index, { alias: $event })"
|
||||
/>
|
||||
</div>
|
||||
<small
|
||||
v-if="conflictIndexes.has(index)"
|
||||
v-if="validity.conflictIndexes.has(index)"
|
||||
:id="`${instanceId}-error-${index}`"
|
||||
class="model-alias-editor__error"
|
||||
>
|
||||
{{ conflictMessage(index) }}
|
||||
</small>
|
||||
<small
|
||||
v-else-if="emptyAliasIndexes.has(index)"
|
||||
v-else-if="validity.emptyAliasIndexes.has(index)"
|
||||
:id="`${instanceId}-error-${index}`"
|
||||
class="model-alias-editor__error"
|
||||
>
|
||||
@@ -226,21 +234,25 @@ function conflictMessage(index: number): string {
|
||||
role="row"
|
||||
aria-rowindex="2"
|
||||
>
|
||||
<span class="ledger-record-list__cell" role="cell">{{ labels.empty }}</span>
|
||||
<span class="ledger-record-list__cell" role="cell">
|
||||
{{ modelValue.length ? labels.noMatches : labels.empty }}
|
||||
</span>
|
||||
</div>
|
||||
</LedgerRecordList>
|
||||
|
||||
<form v-if="addable" class="model-alias-editor__add" @submit.prevent="addManual">
|
||||
<label class="sr-only" :for="`${instanceId}-manual-id`">{{ labels.manualId }}</label>
|
||||
<input
|
||||
:id="`${instanceId}-manual-id`"
|
||||
v-model="manualID"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="disabled"
|
||||
:placeholder="labels.manualId"
|
||||
/>
|
||||
<div class="model-alias-editor__manual-input">
|
||||
<AppTextInput
|
||||
:id="`${instanceId}-manual-id`"
|
||||
v-model="manualID"
|
||||
appearance="sunken"
|
||||
monospace
|
||||
:label="labels.manualId"
|
||||
:placeholder="labels.manualId"
|
||||
:spellcheck="false"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
<AppButton
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
@@ -260,42 +272,16 @@ function conflictMessage(index: number): string {
|
||||
}
|
||||
|
||||
.model-alias-editor__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-alias-editor__search {
|
||||
display: flex;
|
||||
width: min(100%, 420px);
|
||||
min-height: var(--touch-target);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
padding-left: var(--space-3);
|
||||
}
|
||||
|
||||
.model-alias-editor__search:focus-within {
|
||||
border-color: var(--color-action);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.model-alias-editor__search > input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-alias-editor__search :deep(.icon-button) {
|
||||
width: var(--touch-target);
|
||||
height: var(--touch-target);
|
||||
flex: none;
|
||||
.model-alias-editor__validation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-alias-editor__grid {
|
||||
@@ -303,7 +289,7 @@ function conflictMessage(index: number): string {
|
||||
--ledger-record-list-column-gap: 16px;
|
||||
}
|
||||
|
||||
.model-alias-editor__record--conflict {
|
||||
.model-alias-editor__record--invalid {
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
@@ -340,26 +326,8 @@ function conflictMessage(index: number): string {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.model-alias-editor__alias-control > input,
|
||||
.model-alias-editor__add > input {
|
||||
width: 100%;
|
||||
min-height: var(--control-md);
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text);
|
||||
padding: 0 var(--space-3);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-alias-editor__alias-control > input[aria-invalid='true'] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.model-alias-editor__alias-control > input:disabled,
|
||||
.model-alias-editor__add > input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
.model-alias-editor__alias-control :deep(.app-text-input) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-alias-editor__error {
|
||||
@@ -396,9 +364,9 @@ function conflictMessage(index: number): string {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.model-alias-editor__add > input {
|
||||
.model-alias-editor__manual-input {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -445,14 +413,14 @@ function conflictMessage(index: number): string {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.model-alias-editor__validation,
|
||||
.model-alias-editor__add {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.model-alias-editor__add > input {
|
||||
.model-alias-editor__manual-input {
|
||||
max-width: none;
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
.model-alias-editor__add :deep(.app-button) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppDrawer from '@/components/ui/AppDrawer.vue'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import SegmentedControl from '@/components/ui/SegmentedControl.vue'
|
||||
@@ -142,11 +143,16 @@ function confirm(): void {
|
||||
<template v-else>
|
||||
<p class="model-discovery-drawer__notice">{{ labels.notice }}</p>
|
||||
<div class="model-discovery-drawer__filters">
|
||||
<label class="model-discovery-drawer__search">
|
||||
<span class="sr-only">{{ labels.search }}</span>
|
||||
<Search :size="16" aria-hidden="true" />
|
||||
<input v-model="search" type="search" autocomplete="off" :placeholder="labels.search" />
|
||||
</label>
|
||||
<AppTextInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
appearance="sunken"
|
||||
size="touch"
|
||||
:label="labels.search"
|
||||
:placeholder="labels.search"
|
||||
>
|
||||
<template #leading><Search :size="16" /></template>
|
||||
</AppTextInput>
|
||||
<SegmentedControl
|
||||
:model-value="filter"
|
||||
:label="labels.filterLabel"
|
||||
@@ -228,33 +234,6 @@ function confirm(): void {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.model-discovery-drawer__search {
|
||||
display: flex;
|
||||
min-height: var(--touch-target);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-sunken);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.model-discovery-drawer__search:focus-within {
|
||||
border-color: var(--color-action);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.model-discovery-drawer__search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.model-discovery-drawer__candidate-list {
|
||||
display: grid;
|
||||
max-height: 420px;
|
||||
|
||||
@@ -27,7 +27,10 @@ export interface ModelAliasEditorLabels {
|
||||
manualId: string
|
||||
add: string
|
||||
empty: string
|
||||
noMatches: string
|
||||
conflictSummary: string
|
||||
emptyAliasSummary: string
|
||||
locateFirstInvalid: string
|
||||
nameConflict: (name: string) => string
|
||||
}
|
||||
|
||||
@@ -84,6 +87,29 @@ export function indexesWithConflicts(conflicts: readonly ModelNameConflict[]): S
|
||||
return new Set(conflicts.flatMap((conflict) => conflict.indexes))
|
||||
}
|
||||
|
||||
export function indexesWithEmptyAliases(models: readonly GroupModelUpdateDto[]): Set<number> {
|
||||
return new Set(
|
||||
models.flatMap((model, index) => (model.alias_enabled && !model.alias.trim() ? [index] : [])),
|
||||
)
|
||||
}
|
||||
|
||||
export function modelDraftValidity(
|
||||
models: readonly GroupModelUpdateDto[],
|
||||
conflicts: readonly ModelNameConflict[] = findModelNameConflicts(models),
|
||||
): {
|
||||
conflictIndexes: Set<number>
|
||||
emptyAliasIndexes: Set<number>
|
||||
invalidIndexes: Set<number>
|
||||
} {
|
||||
const conflictIndexes = indexesWithConflicts(conflicts)
|
||||
const emptyAliasIndexes = indexesWithEmptyAliases(models)
|
||||
return {
|
||||
conflictIndexes,
|
||||
emptyAliasIndexes,
|
||||
invalidIndexes: new Set([...conflictIndexes, ...emptyAliasIndexes]),
|
||||
}
|
||||
}
|
||||
|
||||
export function createModelDraft<T extends GroupModelUpdateDto>(
|
||||
items: readonly T[],
|
||||
): Array<T & { key: number }>
|
||||
|
||||
@@ -97,8 +97,11 @@ export default {
|
||||
manualId: 'Add model ID',
|
||||
add: 'Add model',
|
||||
empty: 'No models have been added',
|
||||
noMatches: 'No models match the current search',
|
||||
nameConflict: 'Client name “{name}” is duplicated',
|
||||
conflictSummary: 'Duplicate client model names must be resolved before saving',
|
||||
emptyAliasSummary: 'Enabled aliases without a client name must be completed before saving',
|
||||
locateFirstInvalid: 'Clear search and locate the first issue',
|
||||
pricingStatus: { priced: 'Priced', unpriced: 'Unpriced' },
|
||||
drawer: {
|
||||
title: 'Get upstream models',
|
||||
|
||||
@@ -98,8 +98,11 @@ export default {
|
||||
manualId: 'モデル ID を追加',
|
||||
add: 'モデルを追加',
|
||||
empty: 'モデルはまだ追加されていません',
|
||||
noMatches: '現在の検索条件に一致するモデルはありません',
|
||||
nameConflict: 'クライアント名「{name}」が重複しています',
|
||||
conflictSummary: '保存前に重複するクライアントモデル名を解消してください',
|
||||
emptyAliasSummary: '有効な別名にクライアント名がないモデルを保存前に修正してください',
|
||||
locateFirstInvalid: '検索をクリアして最初の問題に移動',
|
||||
pricingStatus: { priced: '価格設定済み', unpriced: '未価格設定' },
|
||||
drawer: {
|
||||
title: 'アップストリームモデルを取得',
|
||||
|
||||
@@ -96,8 +96,11 @@ export default {
|
||||
manualId: '添加模型 ID',
|
||||
add: '添加模型',
|
||||
empty: '尚未添加模型',
|
||||
noMatches: '没有符合当前搜索条件的模型',
|
||||
nameConflict: '对外名称“{name}”重复',
|
||||
conflictSummary: '存在重复的对外模型名称,修正后才能保存',
|
||||
emptyAliasSummary: '存在已启用但未填写对外别名的模型,修正后才能保存',
|
||||
locateFirstInvalid: '清除搜索并定位首个问题',
|
||||
pricingStatus: { priced: '已定价', unpriced: '未定价' },
|
||||
drawer: {
|
||||
title: '获取上游模型列表',
|
||||
|
||||
Reference in new issue
Block a user