mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(frontend): defer incomplete configuration errors until submit
Avoid validating model access and plugin forms when switching modes. Keep error protection drafts quiet until input or submission, and retain condition identity when editing or removing entries. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -85,7 +85,7 @@ const schedulingMode = computed({
|
||||
width="48rem"
|
||||
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
|
||||
>
|
||||
<ZForm ref="formElement" :model="form" :rules="rules" scroll-to-error focus-on-error novalidate @submit="requestSave">
|
||||
<ZForm ref="formElement" :model="form" :rules="rules" :validate-on-rule-change="false" scroll-to-error focus-on-error novalidate @submit="requestSave">
|
||||
<AccountSettingsFields
|
||||
v-model:concurrency-limit="concurrencyLimit"
|
||||
v-model:weight="weight"
|
||||
|
||||
@@ -86,7 +86,7 @@ async function requestSave() {
|
||||
:show-close="!saving" :close-on-click-modal="!saving" :close-on-press-escape="!saving"
|
||||
@closed="emit('afterLeave')"
|
||||
>
|
||||
<ZForm v-if="account" ref="formElement" :model="form" :rules="rules" scroll-to-error focus-on-error novalidate @submit="requestSave">
|
||||
<ZForm v-if="account" ref="formElement" :model="form" :rules="rules" :validate-on-rule-change="false" scroll-to-error focus-on-error novalidate @submit="requestSave">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-z bg-z-fill-quaternary px-4 py-3.5"
|
||||
>
|
||||
|
||||
@@ -23,9 +23,7 @@ const { t } = useI18n({ useScope: 'global' })
|
||||
const model = defineModel<AccountModelAccess | undefined>({ required: true })
|
||||
const formItem = useTemplateRef<InstanceType<typeof ZFormItem>>('modelAccessItem')
|
||||
const refreshButton = useTemplateRef<InstanceType<typeof ZIconButton>>('refreshButton')
|
||||
watch(model, () => {
|
||||
void formItem.value?.validate('change').catch(() => undefined)
|
||||
})
|
||||
watch(model, () => formItem.value?.clearValidate())
|
||||
// 未取得目录时不能判断缺失,成功返回空目录则可以
|
||||
const catalog = ref<Array<{ id: string, label: string }>>()
|
||||
const catalogObservedAtDisplay = ref<string>()
|
||||
|
||||
@@ -159,7 +159,7 @@ async function requestCreate() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ZForm v-if="view.configuring" ref="formElement" :model="form" :rules="settingsRules" scroll-to-error focus-on-error novalidate @submit="continueToImport">
|
||||
<ZForm v-if="view.configuring" ref="formElement" :model="form" :rules="settingsRules" :validate-on-rule-change="false" scroll-to-error focus-on-error novalidate @submit="continueToImport">
|
||||
<AccountSetupFields v-model="form" :disabled="busy" :groups="groups" :groups-loading="groupsLoading" />
|
||||
</ZForm>
|
||||
<template v-if="!view.configuring">
|
||||
|
||||
@@ -50,7 +50,8 @@ export function useAccountSettingsFormRules(
|
||||
message: t('accounts.scheduling.weightMustBeIntegerFromTo'),
|
||||
},
|
||||
modelAccess: {
|
||||
trigger: 'change',
|
||||
// 模式切换允许暂时为空,完整配置在提交时校验
|
||||
trigger: [],
|
||||
validator: () => {
|
||||
const error = accountModelAccessError(form().modelAccess)
|
||||
return error
|
||||
|
||||
@@ -458,6 +458,7 @@ watch(() => props.mode, () => {
|
||||
pendingAuthentication.value = false
|
||||
credentialDraft.value = null
|
||||
})
|
||||
watch(() => githubForm.allowPrerelease, () => formRef.value?.clearValidate('tag'))
|
||||
watch([pendingAuthentication, credentialDraft], () => {
|
||||
emit('resetVerification')
|
||||
if (props.mode === 'github') {
|
||||
@@ -507,7 +508,7 @@ watch(
|
||||
class="mb-5 w-full"
|
||||
@update:model-value="changeMode"
|
||||
/>
|
||||
<ZForm v-if="!verified" ref="formElement" :model="connectionForm" :rules="rules" scroll-to-error focus-on-error novalidate @submit="mode === 'github' && !release ? queryRelease() : verifySelected()">
|
||||
<ZForm v-if="!verified" ref="formElement" :model="connectionForm" :rules="rules" :validate-on-rule-change="false" scroll-to-error focus-on-error novalidate @submit="mode === 'github' && !release ? queryRelease() : verifySelected()">
|
||||
<div v-if="mode === 'upload'">
|
||||
<ZFormItem :label="t('plugins.assets.packageLabel')" required>
|
||||
<!-- 原生文件输入放在样式化上传区内,保留键盘与读屏语义。 -->
|
||||
@@ -552,7 +553,7 @@ watch(
|
||||
</template>
|
||||
</ZInput>
|
||||
</ZFormItem>
|
||||
<ZFormItem prop="tag" :show-message="false" :label="t('plugins.installation.tagLabel')">
|
||||
<ZFormItem prop="tag" :label="t('plugins.installation.tagLabel')">
|
||||
<template #label-extra>
|
||||
<PluginHelpPopover :label="t('plugins.installation.tagHelpTitle')">
|
||||
{{ t('plugins.installation.tagHelp') }}
|
||||
|
||||
+6
-2
@@ -5,7 +5,7 @@ import { ZButton, ZCard, ZCheckbox, ZFormItem, ZIconButton, ZInput, ZPopover, ZS
|
||||
|
||||
import { Activity, CircleAlert, Gauge, Plus, Snowflake, Timer } from '@lucide/vue'
|
||||
|
||||
import { computed, shallowRef, useId } from 'vue'
|
||||
import { computed, shallowRef, useId, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { generateRequestId } from '@/utils/operation'
|
||||
import { createProtectionCondition, protectionLimits } from './form'
|
||||
@@ -16,6 +16,9 @@ const props = defineProps<{ disabled: boolean }>()
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
|
||||
const policy = defineModel<ProtectionDraft>({ required: true })
|
||||
const rulesItem = useTemplateRef<InstanceType<typeof ZFormItem>>('rulesItem')
|
||||
const showErrors = computed(() => Boolean(rulesItem.value?.validateState))
|
||||
watch(() => policy.value.rules, () => rulesItem.value?.clearValidate())
|
||||
const expandedId = shallowRef<string | null>(null)
|
||||
const probeModelId = useId()
|
||||
const helpId = useId()
|
||||
@@ -91,7 +94,7 @@ function removeRule(id: string) {
|
||||
@update:model-value="update({ enabled: $event })"
|
||||
/>
|
||||
|
||||
<ZFormItem prop="accountErrorProtection.rules" class="col-span-full">
|
||||
<ZFormItem ref="rulesItem" prop="accountErrorProtection.rules" class="col-span-full">
|
||||
<section class="grid min-w-0 gap-3" :aria-label="t('settings.errorProtection.rules')">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
@@ -138,6 +141,7 @@ function removeRule(id: string) {
|
||||
<ProtectionRuleRow
|
||||
:rule="rule"
|
||||
:expanded="expandedId === rule.id"
|
||||
:show-errors="showErrors"
|
||||
:disabled="disabled"
|
||||
@toggle="expandedId = expandedId === rule.id ? null : rule.id"
|
||||
@remove="removeRule(rule.id)"
|
||||
|
||||
+22
-9
@@ -2,17 +2,20 @@
|
||||
import type { ProtectionCondition, ProtectionField, ProtectionOperator } from '@/api/modules/settings/protection'
|
||||
import { ZButton, ZCheckbox, ZIconButton, ZInput, ZPopover, ZSelect } from '@codex-proxy/ui'
|
||||
import { ChevronDown, Plus, Trash2, X } from '@lucide/vue'
|
||||
import { computed, useId } from 'vue'
|
||||
import { computed, shallowRef, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { protectionConditionError, protectionFieldLabels, protectionLimits, protectionOperatorLabels } from './form'
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>()
|
||||
const props = defineProps<{ disabled: boolean, showErrors: boolean }>()
|
||||
const emit = defineEmits<{ remove: [] }>()
|
||||
const condition = defineModel<ProtectionCondition>({ required: true })
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const optionsId = useId()
|
||||
const errorId = useId()
|
||||
const error = computed(() => protectionConditionError(condition.value))
|
||||
const inputEdited = shallowRef(false)
|
||||
const error = computed(() => props.showErrors || inputEdited.value
|
||||
? protectionConditionError(condition.value)
|
||||
: '')
|
||||
const hasMissing = computed(() => condition.value.values.includes(null))
|
||||
const fields = computed(() => ([
|
||||
'provider',
|
||||
@@ -41,7 +44,7 @@ function update(patch: Partial<ProtectionCondition>) {
|
||||
}
|
||||
|
||||
function changeField(field: ProtectionField) {
|
||||
update({
|
||||
configure({
|
||||
field,
|
||||
operator: field === 'message'
|
||||
? condition.value.operator
|
||||
@@ -50,10 +53,20 @@ function changeField(field: ProtectionField) {
|
||||
}
|
||||
|
||||
function changeOperator(operator: ProtectionOperator) {
|
||||
update({ operator })
|
||||
configure({ operator })
|
||||
}
|
||||
|
||||
function configure(patch: Partial<ProtectionCondition>) {
|
||||
if (!props.disabled) {
|
||||
inputEdited.value = false
|
||||
update(patch)
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(index: number, value: string) {
|
||||
if (props.disabled)
|
||||
return
|
||||
inputEdited.value = true
|
||||
update({
|
||||
values: condition.value.values.map((previous, i) => i === index
|
||||
? value
|
||||
@@ -63,7 +76,7 @@ function updateValue(index: number, value: string) {
|
||||
|
||||
function removeValue(index: number) {
|
||||
const values = condition.value.values.filter((_, i) => i !== index)
|
||||
update({
|
||||
configure({
|
||||
values: values.length
|
||||
? values
|
||||
: [''],
|
||||
@@ -73,7 +86,7 @@ function removeValue(index: number) {
|
||||
function toggleMissing(missing: boolean) {
|
||||
if (missing) {
|
||||
if (!hasMissing.value && condition.value.values.length < protectionLimits.values) {
|
||||
update({
|
||||
configure({
|
||||
values: [...condition.value.values, null],
|
||||
})
|
||||
}
|
||||
@@ -81,7 +94,7 @@ function toggleMissing(missing: boolean) {
|
||||
else {
|
||||
// 空文本与缺失分别匹配,只移除缺失候选,保留用户输入的空文本
|
||||
const values = condition.value.values.filter(value => value !== null)
|
||||
update({
|
||||
configure({
|
||||
values: values.length
|
||||
? values
|
||||
: [''],
|
||||
@@ -135,7 +148,7 @@ function toggleMissing(missing: boolean) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-1" :class="condition.values.length > 1 ? 'pl-6' : undefined">
|
||||
<ZButton size="small" variant="text" :disabled="disabled || condition.values.length >= protectionLimits.values" @click="update({ values: [...condition.values, ''] })">
|
||||
<ZButton size="small" variant="text" :disabled="disabled || condition.values.length >= protectionLimits.values" @click="configure({ values: [...condition.values, ''] })">
|
||||
<template #icon>
|
||||
<Plus class="size-3.5" />
|
||||
</template>
|
||||
|
||||
@@ -2,15 +2,26 @@
|
||||
import type { ProtectionCondition, ProtectionRule } from '@/api/modules/settings/protection'
|
||||
import { ZButton } from '@codex-proxy/ui'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, toRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { createProtectionCondition, protectionLimits, protectionRuleStructureError } from './form'
|
||||
import { createProtectionCondition, protectionLimits, protectionRuleConditionsError } from './form'
|
||||
import ProtectionConditionEditor from './ProtectionConditionEditor.vue'
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>()
|
||||
const props = defineProps<{ disabled: boolean, showErrors: boolean }>()
|
||||
const rule = defineModel<ProtectionRule>({ required: true })
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const error = computed(() => protectionRuleStructureError(rule.value))
|
||||
const error = computed(() => props.showErrors
|
||||
? protectionRuleConditionsError(rule.value.conditions)
|
||||
: '')
|
||||
const conditionKeys = new WeakMap<ProtectionCondition, number>()
|
||||
let nextConditionKey = 0
|
||||
|
||||
function conditionKey(condition: ProtectionCondition) {
|
||||
const value = toRaw(condition)
|
||||
if (!conditionKeys.has(value))
|
||||
conditionKeys.set(value, nextConditionKey++)
|
||||
return conditionKeys.get(value)!
|
||||
}
|
||||
|
||||
function update(patch: Partial<ProtectionRule>) {
|
||||
if (!props.disabled) {
|
||||
@@ -22,6 +33,8 @@ function update(patch: Partial<ProtectionRule>) {
|
||||
}
|
||||
|
||||
function updateCondition(index: number, condition: ProtectionCondition) {
|
||||
// 对象更新沿用编辑身份,删除前面的条件时也不串用后续条件的输入状态
|
||||
conditionKeys.set(toRaw(condition), conditionKey(rule.value.conditions[index]!))
|
||||
update({
|
||||
conditions: rule.value.conditions.map((previous, i) => i === index
|
||||
? condition
|
||||
@@ -34,11 +47,12 @@ function updateCondition(index: number, condition: ProtectionCondition) {
|
||||
<div class="grid gap-4 px-3 pb-4 sm:px-4">
|
||||
<div class="grid gap-2">
|
||||
<span class="text-z-sm font-emphasis text-z-text-secondary">{{ t('settings.errorProtection.allConditions') }}</span>
|
||||
<template v-for="(condition, index) in rule.conditions" :key="index">
|
||||
<template v-for="(condition, index) in rule.conditions" :key="conditionKey(condition)">
|
||||
<span v-if="index > 0" class="pl-3 text-z-xs font-emphasis text-z-text-secondary">{{ t('settings.errorProtection.and') }}</span>
|
||||
<ProtectionConditionEditor
|
||||
:model-value="condition"
|
||||
:disabled="disabled"
|
||||
:show-errors="showErrors"
|
||||
@update:model-value="updateCondition(index, $event)"
|
||||
@remove="update({ conditions: rule.conditions.filter((_, i) => i !== index) })"
|
||||
/>
|
||||
|
||||
@@ -2,26 +2,33 @@
|
||||
import type { ProtectionRule } from '@/api/modules/settings/protection'
|
||||
import { ZIconButton, ZInput } from '@codex-proxy/ui'
|
||||
import { ChevronDown, Trash2 } from '@lucide/vue'
|
||||
import { computed, useId } from 'vue'
|
||||
import { computed, shallowRef, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { protectionRuleError, protectionRuleSummary } from './form'
|
||||
import { protectionRuleError, protectionRuleNameError, protectionRuleSummary } from './form'
|
||||
import ProtectionRuleEditor from './ProtectionRuleEditor.vue'
|
||||
|
||||
const props = defineProps<{ rule: ProtectionRule, expanded: boolean, disabled: boolean }>()
|
||||
const props = defineProps<{ rule: ProtectionRule, expanded: boolean, disabled: boolean, showErrors: boolean }>()
|
||||
const emit = defineEmits<{ toggle: [], remove: [], update: [rule: ProtectionRule] }>()
|
||||
const { t } = useI18n({ useScope: 'global' })
|
||||
const editorId = useId()
|
||||
const errorId = useId()
|
||||
const nameEdited = shallowRef(false)
|
||||
const nameError = computed(() => props.showErrors || nameEdited.value
|
||||
? protectionRuleNameError(props.rule.name)
|
||||
: '')
|
||||
// 折叠行独立缓存摘要,输入时只更新当前规则的条件
|
||||
const summary = computed(() => props.expanded
|
||||
? ''
|
||||
: protectionRuleSummary(props.rule))
|
||||
const error = computed(() => props.expanded
|
||||
? ''
|
||||
: protectionRuleError(props.rule))
|
||||
const error = computed(() => !props.expanded && props.showErrors
|
||||
? protectionRuleError(props.rule)
|
||||
: nameError.value)
|
||||
|
||||
function updateName(name: string) {
|
||||
if (!props.disabled)
|
||||
if (!props.disabled) {
|
||||
nameEdited.value = true
|
||||
emit('update', { ...props.rule, name })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,18 +49,20 @@ function updateName(name: string) {
|
||||
:model-value="rule.name"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('settings.errorProtection.ruleName')"
|
||||
:aria-invalid="Boolean(nameError)"
|
||||
:aria-describedby="error ? errorId : undefined"
|
||||
:placeholder="t('settings.errorProtection.ruleNamePlaceholder')"
|
||||
class="max-w-lg"
|
||||
@update:model-value="updateName"
|
||||
/>
|
||||
<span v-else class="truncate text-z-sm font-emphasis text-z-text">{{ rule.name || t('settings.errorProtection.unnamedRule') }}</span>
|
||||
<span v-if="!expanded" class="truncate text-z-xs text-z-text-tertiary" :title="summary">{{ summary }}</span>
|
||||
<span v-if="error" role="alert" class="text-z-sm text-z-error-text">{{ error }}</span>
|
||||
<span v-if="error" :id="errorId" role="alert" class="text-z-sm text-z-error-text">{{ error }}</span>
|
||||
</div>
|
||||
<ZIconButton :disabled="disabled" :aria-label="t('settings.errorProtection.removeRule', { name: rule.name || t('settings.errorProtection.unnamedRule') })" @click="emit('remove')">
|
||||
<Trash2 class="size-4" />
|
||||
</ZIconButton>
|
||||
</div>
|
||||
<ProtectionRuleEditor v-if="expanded" :id="editorId" :model-value="rule" :disabled="disabled" @update:model-value="emit('update', $event)" />
|
||||
<ProtectionRuleEditor v-if="expanded" :id="editorId" :model-value="rule" :disabled="disabled" :show-errors="showErrors" @update:model-value="emit('update', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -68,18 +68,22 @@ export function protectionConditionError(condition: ProtectionCondition): string
|
||||
return ''
|
||||
}
|
||||
|
||||
export function protectionRuleStructureError(rule: ProtectionRule): string {
|
||||
if (!trimProtectionText(rule.name) || rule.name !== trimProtectionText(rule.name) || /\p{Cc}/u.test(rule.name) || exceedsCharacters(rule.name, protectionLimits.name))
|
||||
export function protectionRuleNameError(name: string): string {
|
||||
if (!trimProtectionText(name) || name !== trimProtectionText(name) || /\p{Cc}/u.test(name) || exceedsCharacters(name, protectionLimits.name))
|
||||
return t('settings.errorProtection.validation.name', { max: protectionLimits.name })
|
||||
if (!rule.conditions.length || rule.conditions.length > protectionLimits.conditions)
|
||||
return ''
|
||||
}
|
||||
|
||||
export function protectionRuleConditionsError(conditions: ProtectionCondition[]): string {
|
||||
if (!conditions.length || conditions.length > protectionLimits.conditions)
|
||||
return t('settings.errorProtection.validation.conditions', { max: protectionLimits.conditions })
|
||||
if (rule.conditions.every(condition => condition.field === 'provider'))
|
||||
if (conditions.every(condition => condition.field === 'provider'))
|
||||
return t('settings.errorProtection.validation.errorCondition')
|
||||
return ''
|
||||
}
|
||||
|
||||
export function protectionRuleError(rule: ProtectionRule): string {
|
||||
const error = protectionRuleStructureError(rule)
|
||||
const error = protectionRuleNameError(rule.name) || protectionRuleConditionsError(rule.conditions)
|
||||
if (error)
|
||||
return error
|
||||
for (const condition of rule.conditions) {
|
||||
|
||||
@@ -87,7 +87,8 @@ export function useSettingsFormRules(form: () => SettingsForm) {
|
||||
},
|
||||
],
|
||||
'accountErrorProtection.rules': {
|
||||
trigger: 'change',
|
||||
// 子字段保留输入校验,整组完整性只在提交时检查
|
||||
trigger: [],
|
||||
validator: () => {
|
||||
const error = protectionPolicyError(form().accountErrorProtection)
|
||||
return error
|
||||
|
||||
Reference in New Issue
Block a user