fix(web): 完善模型组件校验与控件复用

This commit is contained in:
tbphp committed 2026-08-01 19:03:33 +08:00
1 parent 758f216639
commit d5694bc269
8 files changed
+322 -161

No files matched your search

+182
View File
@@ -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>(() => ({
+88 -120
View File
@@ -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;
+26
View File
@@ -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 }>
+3
View File
@@ -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',
+3
View File
@@ -98,8 +98,11 @@ export default {
manualId: 'モデル ID を追加',
add: 'モデルを追加',
empty: 'モデルはまだ追加されていません',
noMatches: '現在の検索条件に一致するモデルはありません',
nameConflict: 'クライアント名「{name}」が重複しています',
conflictSummary: '保存前に重複するクライアントモデル名を解消してください',
emptyAliasSummary: '有効な別名にクライアント名がないモデルを保存前に修正してください',
locateFirstInvalid: '検索をクリアして最初の問題に移動',
pricingStatus: { priced: '価格設定済み', unpriced: '未価格設定' },
drawer: {
title: 'アップストリームモデルを取得',
+3
View File
@@ -96,8 +96,11 @@ export default {
manualId: '添加模型 ID',
add: '添加模型',
empty: '尚未添加模型',
noMatches: '没有符合当前搜索条件的模型',
nameConflict: '对外名称“{name}”重复',
conflictSummary: '存在重复的对外模型名称,修正后才能保存',
emptyAliasSummary: '存在已启用但未填写对外别名的模型,修正后才能保存',
locateFirstInvalid: '清除搜索并定位首个问题',
pricingStatus: { priced: '已定价', unpriced: '未定价' },
drawer: {
title: '获取上游模型列表',