<script setup lang="ts">
|
import type { Dayjs } from 'dayjs';
|
import { computed, h, nextTick, ref, type HTMLAttributes } from 'vue';
|
|
import { buildTag, generateRowId, normalizeId } from '../domain/tags';
|
import type {
|
ControlType,
|
DropdownOption,
|
FieldDefinition,
|
FieldDefinitionInput,
|
FieldDefinitionValidationField,
|
FieldMode,
|
RawTypeConfig,
|
} from '../domain/types';
|
import { createFieldDefinition } from '../domain/validation';
|
import DropdownOptions from './DropdownOptions.vue';
|
|
const props = withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });
|
const emit = defineEmits<{ submit: [definition: FieldDefinition] }>();
|
|
const rootElement = ref<HTMLElement | null>(null);
|
const mode = ref<FieldMode>('single');
|
const groupCode = ref('test_results');
|
const rowId = ref(generateRowId());
|
const fieldCode = ref('sample_id');
|
const fieldAlias = ref('样品编号');
|
const controlType = ref<ControlType>('text');
|
const textPlaceholder = ref('请输入样品编号');
|
const textDefault = ref('');
|
const dropdownPlaceholder = ref('请选择');
|
const dropdownOptions = ref<DropdownOption[]>([
|
{ display: '', value: '' },
|
{ display: '', value: '' },
|
]);
|
const dropdownDefault = ref('');
|
const checkboxDefault = ref(false);
|
const datePlaceholder = ref('请选择日期');
|
const dateDefault = ref<Dayjs | null>(null);
|
const multilinePlaceholder = ref('请输入内容');
|
const multilineDefault = ref('');
|
const errors = ref<Partial<Record<FieldDefinitionValidationField, string>>>({});
|
|
const modeOptions = [
|
{ label: h('span', { 'data-test': 'mode-single' }, '普通字段'), value: 'single' },
|
{ label: h('span', { 'data-test': 'mode-repeat' }, '重复表格'), value: 'repeat' },
|
];
|
const controlTypeOptions = [
|
{ label: '单行文本', value: 'text' },
|
{ label: '下拉选择', value: 'dropdown' },
|
{ label: '复选框', value: 'checkbox' },
|
{ label: '日期', value: 'date' },
|
{ label: '多行文本', value: 'multiline' },
|
] satisfies Array<{ label: string; value: ControlType }>;
|
|
const tagPreview = computed(() =>
|
buildTag(mode.value, normalizeId(fieldCode.value) || 'field_id', normalizeId(groupCode.value) || 'group_id', normalizeId(rowId.value) || 'row_id'),
|
);
|
|
function getPopupContainer(): HTMLElement {
|
return rootElement.value ?? document.body;
|
}
|
|
function generateNewRowId(): void {
|
if (!props.disabled) rowId.value = generateRowId();
|
}
|
|
function updateMode(value: string | number): void {
|
if (!props.disabled && (value === 'single' || value === 'repeat')) mode.value = value;
|
}
|
|
function updateControlType(value: ControlType): void {
|
if (!props.disabled) controlType.value = value;
|
}
|
|
function updateDateDefault(value: Dayjs | null): void {
|
if (!props.disabled) dateDefault.value = value;
|
}
|
|
function renderDateInput(inputProps: HTMLAttributes) {
|
return h('input', {
|
...inputProps,
|
'aria-describedby': errors.value['date-default'] ? 'date-default-error' : undefined,
|
'aria-invalid': String(Boolean(errors.value['date-default'])),
|
'aria-label': '默认日期',
|
id: 'date-default',
|
});
|
}
|
|
function getTypeConfig(): RawTypeConfig {
|
if (controlType.value === 'dropdown') {
|
return {
|
controlType: 'dropdown',
|
defaultOptionValue: dropdownDefault.value,
|
options: dropdownOptions.value,
|
placeholder: dropdownPlaceholder.value,
|
};
|
}
|
|
if (controlType.value === 'checkbox') {
|
return { controlType: 'checkbox', defaultChecked: checkboxDefault.value };
|
}
|
|
if (controlType.value === 'date') {
|
return {
|
controlType: 'date',
|
defaultValue: dateDefault.value?.format('YYYY-MM-DD') ?? '',
|
placeholder: datePlaceholder.value,
|
};
|
}
|
|
if (controlType.value === 'multiline') {
|
return {
|
controlType: 'multiline',
|
defaultValue: multilineDefault.value,
|
placeholder: multilinePlaceholder.value,
|
};
|
}
|
|
return {
|
controlType: 'text',
|
defaultValue: textDefault.value,
|
placeholder: textPlaceholder.value,
|
};
|
}
|
|
const focusSelectors: Record<FieldDefinitionValidationField, string> = {
|
'control-type': '#control-type',
|
'date-default': '#date-default',
|
'dropdown-default': '#dropdown-default',
|
'dropdown-options': '[data-test="option-display-0"]',
|
'field-alias': '[data-test="field-alias"]',
|
'field-code': '[data-test="field-code"]',
|
'group-code': '[data-test="group-code"]',
|
'row-id': '[data-test="row-id"]',
|
'text-default': '[data-test="text-default"]',
|
};
|
|
function focusError(field: FieldDefinitionValidationField): void {
|
nextTick(() => {
|
const element = rootElement.value?.querySelector<HTMLElement>(focusSelectors[field]);
|
element?.focus();
|
});
|
}
|
|
function handleSubmit(): void {
|
if (props.disabled) return;
|
|
const input: FieldDefinitionInput = {
|
alias: fieldAlias.value,
|
fieldCode: fieldCode.value,
|
groupCode: groupCode.value,
|
mode: mode.value,
|
rowId: rowId.value,
|
typeConfig: getTypeConfig(),
|
};
|
const result = createFieldDefinition(input);
|
|
if (!result.ok) {
|
errors.value = { [result.field]: result.message };
|
focusError(result.field);
|
return;
|
}
|
|
errors.value = {};
|
emit('submit', result.value);
|
}
|
</script>
|
|
<template>
|
<section ref="rootElement" class="field-editor" data-test="field-editor">
|
<a-form layout="vertical">
|
<a-form-item label="字段模式">
|
<a-segmented aria-label="字段模式" data-test="mode-editor" :disabled="disabled" :options="modeOptions" :value="mode" @update:value="updateMode" />
|
</a-form-item>
|
|
<div v-if="mode === 'repeat'" data-test="repeat-fields">
|
<a-form-item
|
data-test="group-code-item"
|
:has-feedback="Boolean(errors['group-code'])"
|
html-for="group-code"
|
label="重复组编码"
|
:validate-status="errors['group-code'] ? 'error' : undefined">
|
<a-input
|
v-model:value="groupCode"
|
:aria-describedby="errors['group-code'] ? 'group-code-error' : undefined"
|
:aria-invalid="Boolean(errors['group-code'])"
|
aria-label="重复组编码"
|
autocomplete="off"
|
data-test="group-code"
|
:disabled="disabled"
|
id="group-code" />
|
<template v-if="errors['group-code']" #help>
|
<span id="group-code-error" aria-live="polite" role="alert">{{ errors['group-code'] }}</span>
|
</template>
|
</a-form-item>
|
<a-form-item
|
data-test="row-id-item"
|
:has-feedback="Boolean(errors['row-id'])"
|
html-for="row-id"
|
label="行 ID"
|
:validate-status="errors['row-id'] ? 'error' : undefined">
|
<div class="input-action">
|
<a-input
|
v-model:value="rowId"
|
:aria-describedby="errors['row-id'] ? 'row-id-error' : undefined"
|
:aria-invalid="Boolean(errors['row-id'])"
|
aria-label="行 ID"
|
autocomplete="off"
|
data-test="row-id"
|
:disabled="disabled"
|
id="row-id" />
|
<a-button aria-label="生成行 ID" data-test="generate-row-id" :disabled="disabled" title="生成行 ID" @click="generateNewRowId"> 生成 </a-button>
|
</div>
|
<template v-if="errors['row-id']" #help>
|
<span id="row-id-error" aria-live="polite" role="alert">{{ errors['row-id'] }}</span>
|
</template>
|
</a-form-item>
|
</div>
|
|
<a-form-item
|
data-test="field-code-item"
|
:has-feedback="Boolean(errors['field-code'])"
|
html-for="field-code"
|
:validate-status="errors['field-code'] ? 'error' : undefined">
|
<template #label
|
><span data-test="field-code-label">{{ mode === 'repeat' ? '列字段编码' : '字段编码' }}</span></template
|
>
|
<a-input
|
v-model:value="fieldCode"
|
:aria-describedby="errors['field-code'] ? 'field-code-error' : undefined"
|
:aria-invalid="Boolean(errors['field-code'])"
|
:aria-label="mode === 'repeat' ? '列字段编码' : '字段编码'"
|
autocomplete="off"
|
data-test="field-code"
|
:disabled="disabled"
|
id="field-code" />
|
<template v-if="errors['field-code']" #help>
|
<span id="field-code-error" aria-live="polite" role="alert">{{ errors['field-code'] }}</span>
|
</template>
|
</a-form-item>
|
|
<a-form-item
|
data-test="field-alias-item"
|
:has-feedback="Boolean(errors['field-alias'])"
|
html-for="field-alias"
|
label="字段名称"
|
:validate-status="errors['field-alias'] ? 'error' : undefined">
|
<a-input
|
v-model:value="fieldAlias"
|
:aria-describedby="errors['field-alias'] ? 'field-alias-error' : undefined"
|
:aria-invalid="Boolean(errors['field-alias'])"
|
aria-label="字段名称"
|
autocomplete="off"
|
data-test="field-alias"
|
:disabled="disabled"
|
id="field-alias" />
|
<template v-if="errors['field-alias']" #help>
|
<span id="field-alias-error" aria-live="polite" role="alert">{{ errors['field-alias'] }}</span>
|
</template>
|
</a-form-item>
|
|
<a-form-item
|
data-test="control-type-item"
|
:has-feedback="Boolean(errors['control-type'])"
|
html-for="control-type"
|
label="字段类型"
|
:validate-status="errors['control-type'] ? 'error' : undefined">
|
<a-select
|
:aria-describedby="errors['control-type'] ? 'control-type-error' : undefined"
|
:aria-invalid="Boolean(errors['control-type'])"
|
aria-label="字段类型"
|
data-test="control-type"
|
:disabled="disabled"
|
:get-popup-container="getPopupContainer"
|
id="control-type"
|
:options="controlTypeOptions"
|
:value="controlType"
|
@update:value="updateControlType" />
|
<template v-if="errors['control-type']" #help>
|
<span id="control-type-error" aria-live="polite" role="alert">{{ errors['control-type'] }}</span>
|
</template>
|
</a-form-item>
|
|
<div v-if="controlType === 'text'" data-test="text-fields">
|
<a-form-item html-for="text-placeholder" label="占位文本">
|
<a-input
|
v-model:value="textPlaceholder"
|
aria-label="单行文本占位文本"
|
autocomplete="off"
|
data-test="text-placeholder"
|
:disabled="disabled"
|
id="text-placeholder" />
|
</a-form-item>
|
<a-form-item
|
data-test="text-default-item"
|
:has-feedback="Boolean(errors['text-default'])"
|
html-for="text-default"
|
label="默认值"
|
:validate-status="errors['text-default'] ? 'error' : undefined">
|
<a-input
|
v-model:value="textDefault"
|
:aria-describedby="errors['text-default'] ? 'text-default-error' : undefined"
|
:aria-invalid="Boolean(errors['text-default'])"
|
aria-label="单行文本默认值"
|
autocomplete="off"
|
data-test="text-default"
|
:disabled="disabled"
|
id="text-default" />
|
<template v-if="errors['text-default']" #help>
|
<span id="text-default-error" aria-live="polite" role="alert">{{ errors['text-default'] }}</span>
|
</template>
|
</a-form-item>
|
</div>
|
|
<div v-else-if="controlType === 'dropdown'" data-test="dropdown-fields">
|
<a-form-item html-for="dropdown-placeholder" label="占位文本">
|
<a-input
|
v-model:value="dropdownPlaceholder"
|
aria-label="下拉选择占位文本"
|
autocomplete="off"
|
data-test="dropdown-placeholder"
|
:disabled="disabled"
|
id="dropdown-placeholder" />
|
</a-form-item>
|
<a-form-item
|
data-test="dropdown-options-item"
|
:has-feedback="Boolean(errors['dropdown-options'] || errors['dropdown-default'])"
|
:html-for="errors['dropdown-default'] ? 'dropdown-default' : 'dropdown-option-display-0'"
|
:validate-status="errors['dropdown-options'] || errors['dropdown-default'] ? 'error' : undefined">
|
<DropdownOptions
|
v-model:default-value="dropdownDefault"
|
v-model:options="dropdownOptions"
|
:disabled="disabled"
|
:error-field="errors['dropdown-options'] ? 'dropdown-options' : errors['dropdown-default'] ? 'dropdown-default' : undefined"
|
:error-id="errors['dropdown-options'] ? 'dropdown-options-error' : errors['dropdown-default'] ? 'dropdown-default-error' : undefined" />
|
<template v-if="errors['dropdown-options'] || errors['dropdown-default']" #help>
|
<span :id="errors['dropdown-options'] ? 'dropdown-options-error' : 'dropdown-default-error'" aria-live="polite" role="alert">
|
{{ errors['dropdown-options'] || errors['dropdown-default'] }}
|
</span>
|
</template>
|
</a-form-item>
|
</div>
|
|
<div v-else-if="controlType === 'checkbox'" data-test="checkbox-fields">
|
<a-form-item>
|
<a-checkbox v-model:checked="checkboxDefault" data-test="checkbox-default" :disabled="disabled" id="checkbox-default">默认勾选</a-checkbox>
|
</a-form-item>
|
</div>
|
|
<div v-else-if="controlType === 'date'" data-test="date-fields">
|
<a-form-item html-for="date-placeholder" label="占位文本">
|
<a-input
|
v-model:value="datePlaceholder"
|
aria-label="日期占位文本"
|
autocomplete="off"
|
data-test="date-placeholder"
|
:disabled="disabled"
|
id="date-placeholder" />
|
</a-form-item>
|
<a-form-item
|
data-test="date-default-item"
|
:has-feedback="Boolean(errors['date-default'])"
|
html-for="date-default"
|
label="默认日期"
|
:validate-status="errors['date-default'] ? 'error' : undefined">
|
<a-date-picker
|
:aria-describedby="errors['date-default'] ? 'date-default-error' : undefined"
|
:aria-invalid="Boolean(errors['date-default'])"
|
aria-label="默认日期"
|
data-test="date-default"
|
:disabled="disabled"
|
format="YYYY-MM-DD"
|
:get-popup-container="getPopupContainer"
|
id="date-default"
|
:input-render="renderDateInput"
|
:value="dateDefault"
|
@update:value="updateDateDefault" />
|
<template v-if="errors['date-default']" #help>
|
<span id="date-default-error" aria-live="polite" role="alert">{{ errors['date-default'] }}</span>
|
</template>
|
</a-form-item>
|
</div>
|
|
<div v-else data-test="multiline-fields">
|
<a-form-item html-for="multiline-placeholder" label="占位文本">
|
<a-input
|
v-model:value="multilinePlaceholder"
|
aria-label="多行文本占位文本"
|
autocomplete="off"
|
data-test="multiline-placeholder"
|
:disabled="disabled"
|
id="multiline-placeholder" />
|
</a-form-item>
|
<a-form-item html-for="multiline-default" label="默认值">
|
<a-textarea
|
v-model:value="multilineDefault"
|
aria-label="多行文本默认值"
|
data-test="multiline-default"
|
:disabled="disabled"
|
id="multiline-default"
|
:rows="3" />
|
</a-form-item>
|
</div>
|
|
<div class="tag-preview">
|
<span>Tag</span>
|
<code data-test="tag-preview">{{ tagPreview }}</code>
|
</div>
|
|
<a-button block data-test="submit-field" :disabled="disabled" type="primary" @click="handleSubmit">
|
{{ mode === 'repeat' ? '标注当前单元格字段' : '标注当前位置' }}
|
</a-button>
|
</a-form>
|
</section>
|
</template>
|
|
<style scoped>
|
.field-editor {
|
width: 100%;
|
min-width: 0;
|
}
|
|
.field-editor :deep(.ant-form-item) {
|
margin-bottom: 12px;
|
}
|
|
.field-editor :deep(.ant-select),
|
.field-editor :deep(.ant-picker) {
|
width: 100%;
|
}
|
|
.input-action {
|
display: grid;
|
grid-template-columns: minmax(0, 1fr) auto;
|
gap: 8px;
|
}
|
|
.tag-preview {
|
display: grid;
|
min-width: 0;
|
margin-bottom: 12px;
|
gap: 4px;
|
}
|
|
.tag-preview code {
|
overflow-wrap: anywhere;
|
}
|
</style>
|