fix: prevent double-submit race and add inline param name validation

- Move `submitting = true` before `await validate()` in CommandEditForm,
  EventEditForm, ProviderEditForm, and ModelConfigEditForm to close the
  race window where two rapid clicks both pass validation and cause
  duplicate-entry R500 errors.
- Add per-row inline validation for paramName (regex: 2-32 chars) and
  paramCode (required) on command/event param tables, with red border
  and tooltip error instead of only a console toast.
This commit is contained in:
pnoker
2026-05-26 17:07:00 +08:00
parent 759d17acc9
commit 435a1a38b8
6 changed files with 182 additions and 22 deletions
+4
View File
@@ -490,6 +490,8 @@ export default {
nameRequired: 'Command name is required',
codeRequired: 'Command code is required',
paramRequired: 'Command param name, code, direction, and type are required.',
paramNamePattern:
'Param name must be 2-32 chars, start with a letter/digit/CJK, and only contain letters, digits, CJK, and -_#@/.|.',
paramCodeUnique: 'Command param code must be unique.',
},
detail: {
@@ -554,6 +556,8 @@ export default {
nameRequired: 'Event name is required',
codeRequired: 'Event code is required',
paramRequired: 'Event param name, code, and type are required.',
paramNamePattern:
'Param name must be 2-32 chars, start with a letter/digit/CJK, and only contain letters, digits, CJK, and -_#@/.|.',
paramCodeUnique: 'Event param code must be unique.',
},
detail: {
+2
View File
@@ -489,6 +489,7 @@ export default {
nameRequired: '指令名称不能为空',
codeRequired: '指令标识不能为空',
paramRequired: '指令参数名称、标识、方向和类型不能为空。',
paramNamePattern: '指令参数名称需2-32位,以字母、数字或中文开头,仅支持字母、数字、中文及-_#@/.|。',
paramCodeUnique: '指令参数标识不能重复。',
},
detail: {
@@ -553,6 +554,7 @@ export default {
nameRequired: '事件名称不能为空',
codeRequired: '事件标识不能为空',
paramRequired: '事件参数名称、标识和类型不能为空。',
paramNamePattern: '事件参数名称需2-32位,以字母、数字或中文开头,仅支持字母、数字、中文及-_#@/.|。',
paramCodeUnique: '事件参数标识不能重复。',
},
detail: {
@@ -153,8 +153,14 @@
};
const onSubmit = async () => {
await formRef.value?.validate();
if (submitting.value) return;
submitting.value = true;
try {
await formRef.value?.validate();
} catch {
submitting.value = false;
return;
}
emit('save', { ...form }, () => {
submitting.value = false;
visible.value = false;
@@ -142,8 +142,14 @@
};
const onSubmit = async () => {
await formRef.value?.validate();
if (submitting.value) return;
submitting.value = true;
try {
await formRef.value?.validate();
} catch {
submitting.value = false;
return;
}
emit('save', { ...form }, () => {
submitting.value = false;
visible.value = false;
@@ -59,13 +59,35 @@
</div>
<el-table :data="reactiveData.params" border max-height="260" size="small">
<el-table-column :label="$t('common.name')" min-width="150">
<template #default="{ row }">
<el-input v-model="row.paramName" clearable />
<template #default="{ row, $index }">
<el-tooltip
:content="paramErrors[$index]?.paramName || ''"
:visible="!!paramErrors[$index]?.paramName"
placement="top"
>
<el-input
v-model="row.paramName"
:class="{ 'is-error': !!paramErrors[$index]?.paramName }"
clearable
@blur="validateRow($index)"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('command.form.code')" min-width="150">
<template #default="{ row }">
<el-input v-model="row.paramCode" clearable />
<template #default="{ row, $index }">
<el-tooltip
:content="paramErrors[$index]?.paramCode || ''"
:visible="!!paramErrors[$index]?.paramCode"
placement="top"
>
<el-input
v-model="row.paramCode"
:class="{ 'is-error': !!paramErrors[$index]?.paramCode }"
clearable
@blur="validateRow($index)"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('command.form.direction')" min-width="130">
@@ -188,15 +210,42 @@
paramLoading: false,
});
const PARAM_NAME_RE = /^[A-Za-z0-9一-龥][A-Za-z0-9一-龥\-_#@/.|]{1,31}$/;
const rules: FormRules = {
commandName: [{ required: true, message: t('command.form.nameRequired'), trigger: 'blur' }],
};
type RowErrors = { paramName?: string; paramCode?: string };
const paramErrors = reactive<RowErrors[]>([]);
const validateRow = (index: number) => {
const row = reactiveData.params[index];
if (!row) return;
const errors: RowErrors = {};
const name = String(row.paramName || '').trim();
const code = String(row.paramCode || '').trim();
if (!name) {
errors.paramName = t('command.form.paramRequired');
} else if (!PARAM_NAME_RE.test(name)) {
errors.paramName = t('command.form.paramNamePattern');
}
if (!code) {
errors.paramCode = t('command.form.paramRequired');
}
paramErrors[index] = errors;
};
const clearParamErrors = () => {
paramErrors.splice(0, paramErrors.length);
};
const reset = () => {
reactiveData.form = { ...reactiveData.originalForm };
reactiveData.params = cloneParams(reactiveData.originalParams);
reactiveData.submitting = false;
formRef.value?.clearValidate();
clearParamErrors();
};
const rowKey = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -239,21 +288,30 @@
};
});
const validateParams = (params: CommandParamRecord[]) => {
const validateParams = (params: CommandParamRecord[]): boolean => {
clearParamErrors();
let valid = true;
const codes = new Set<string>();
for (let i = 0; i < reactiveData.params.length; i++) {
validateRow(i);
}
for (const item of params) {
const code = String(item.paramCode || '').trim();
if (!item.paramName || !code || !item.paramDirectionFlag || !item.paramTypeFlag) {
failMessage(t('command.form.paramRequired'));
return false;
valid = false;
}
if (item.paramName && !PARAM_NAME_RE.test(item.paramName)) {
failMessage(t('command.form.paramNamePattern'));
valid = false;
}
if (codes.has(code)) {
failMessage(t('command.form.paramCodeUnique'));
return false;
valid = false;
}
codes.add(code);
}
return true;
return valid;
};
const addParamRow = () => {
@@ -262,6 +320,7 @@
const removeParamRow = (index: number) => {
reactiveData.params.splice(index, 1);
paramErrors.splice(index, 1);
};
const show = (profileId = '') => {
@@ -271,6 +330,7 @@
reactiveData.form = { ...emptyForm };
reactiveData.originalParams = [];
reactiveData.params = [];
clearParamErrors();
reactiveData.visible = true;
};
@@ -290,6 +350,7 @@
reactiveData.form = { ...initial };
reactiveData.originalParams = [];
reactiveData.params = [];
clearParamErrors();
reactiveData.visible = true;
if (row.id) {
reactiveData.paramLoading = true;
@@ -312,9 +373,13 @@
};
const submit = async () => {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (reactiveData.submitting) return;
reactiveData.submitting = true;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) {
reactiveData.submitting = false;
return;
}
const payload = {
...reactiveData.form,
timeout: normalizeCommandTimeoutSeconds(reactiveData.form.timeout) ?? 30,
@@ -333,3 +398,9 @@
defineExpose({ show, showEdit });
</script>
<style>
.is-error .el-input__wrapper {
box-shadow: 0 0 0 1px var(--el-color-danger) inset !important;
}
</style>
@@ -56,13 +56,35 @@
</div>
<el-table :data="reactiveData.params" border max-height="260" size="small">
<el-table-column :label="$t('common.name')" min-width="170">
<template #default="{ row }">
<el-input v-model="row.paramName" clearable />
<template #default="{ row, $index }">
<el-tooltip
:content="paramErrors[$index]?.paramName || ''"
:visible="!!paramErrors[$index]?.paramName"
placement="top"
>
<el-input
v-model="row.paramName"
:class="{ 'is-error': !!paramErrors[$index]?.paramName }"
clearable
@blur="validateRow($index)"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('eventDefinition.form.code')" min-width="170">
<template #default="{ row }">
<el-input v-model="row.paramCode" clearable />
<template #default="{ row, $index }">
<el-tooltip
:content="paramErrors[$index]?.paramCode || ''"
:visible="!!paramErrors[$index]?.paramCode"
placement="top"
>
<el-input
v-model="row.paramCode"
:class="{ 'is-error': !!paramErrors[$index]?.paramCode }"
clearable
@blur="validateRow($index)"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('eventDefinition.form.type')" min-width="140">
@@ -150,15 +172,42 @@
paramLoading: false,
});
const PARAM_NAME_RE = /^[A-Za-z0-9一-龥][A-Za-z0-9一-龥\-_#@/.|]{1,31}$/;
const rules: FormRules = {
eventName: [{ required: true, message: t('eventDefinition.form.nameRequired'), trigger: 'blur' }],
};
type RowErrors = { paramName?: string; paramCode?: string };
const paramErrors = reactive<RowErrors[]>([]);
const validateRow = (index: number) => {
const row = reactiveData.params[index];
if (!row) return;
const errors: RowErrors = {};
const name = String(row.paramName || '').trim();
const code = String(row.paramCode || '').trim();
if (!name) {
errors.paramName = t('eventDefinition.form.paramRequired');
} else if (!PARAM_NAME_RE.test(name)) {
errors.paramName = t('eventDefinition.form.paramNamePattern');
}
if (!code) {
errors.paramCode = t('eventDefinition.form.paramRequired');
}
paramErrors[index] = errors;
};
const clearParamErrors = () => {
paramErrors.splice(0, paramErrors.length);
};
const reset = () => {
reactiveData.form = { ...reactiveData.originalForm };
reactiveData.params = cloneParams(reactiveData.originalParams);
reactiveData.submitting = false;
formRef.value?.clearValidate();
clearParamErrors();
};
const rowKey = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -194,21 +243,30 @@
};
});
const validateParams = (params: EventParamRecord[]) => {
const validateParams = (params: EventParamRecord[]): boolean => {
clearParamErrors();
let valid = true;
const codes = new Set<string>();
for (let i = 0; i < reactiveData.params.length; i++) {
validateRow(i);
}
for (const item of params) {
const code = String(item.paramCode || '').trim();
if (!item.paramName || !code || !item.paramTypeFlag) {
failMessage(t('eventDefinition.form.paramRequired'));
return false;
valid = false;
}
if (item.paramName && !PARAM_NAME_RE.test(item.paramName)) {
failMessage(t('eventDefinition.form.paramNamePattern'));
valid = false;
}
if (codes.has(code)) {
failMessage(t('eventDefinition.form.paramCodeUnique'));
return false;
valid = false;
}
codes.add(code);
}
return true;
return valid;
};
const addParamRow = () => {
@@ -217,6 +275,7 @@
const removeParamRow = (index: number) => {
reactiveData.params.splice(index, 1);
paramErrors.splice(index, 1);
};
const show = (profileId = '') => {
@@ -226,6 +285,7 @@
reactiveData.form = { ...emptyForm };
reactiveData.originalParams = [];
reactiveData.params = [];
clearParamErrors();
reactiveData.visible = true;
};
@@ -244,6 +304,7 @@
reactiveData.form = { ...initial };
reactiveData.originalParams = [];
reactiveData.params = [];
clearParamErrors();
reactiveData.visible = true;
if (row.id) {
reactiveData.paramLoading = true;
@@ -266,9 +327,13 @@
};
const submit = async () => {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (reactiveData.submitting) return;
reactiveData.submitting = true;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) {
reactiveData.submitting = false;
return;
}
const payload = { ...reactiveData.form };
const params = normalizeParams();
if (!validateParams(params)) {
@@ -284,3 +349,9 @@
defineExpose({ show, showEdit });
</script>
<style>
.is-error .el-input__wrapper {
box-shadow: 0 0 0 1px var(--el-color-danger) inset !important;
}
</style>