feat: 批量新增字典数据

This commit is contained in:
2026-07-14 15:58:00 +08:00
parent 11fe65a437
commit 88b6e21a8c
67 changed files with 11851 additions and 36 deletions

View File

@@ -45,6 +45,11 @@ export function createDictData(data: SystemDictDataApi.DictData) {
return requestClient.post('/system/dict-data/create', data);
}
// 批量新增字典数据
export function createDictDataList(data: SystemDictDataApi.DictData[]) {
return requestClient.post('/system/dict-data/create-list', data);
}
// 修改字典数据
export function updateDictData(data: SystemDictDataApi.DictData) {
return requestClient.put('/system/dict-data/update', data);

View File

@@ -0,0 +1,373 @@
<script lang="ts" setup>
import type { SystemDictDataApi } from '#/api/system/dict/data';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { isEmpty } from '@vben/utils';
import {
Button,
Form,
FormItem,
Input,
InputNumber,
message,
Modal as AntModal,
Select,
Space,
Table,
TextArea,
} from 'antdv-next';
import { createDictDataList } from '#/api/system/dict/data';
import { getSimpleDictTypeList } from '#/api/system/dict/type';
import { $t } from '#/locales';
import { useDataFormSchema } from '../data';
const emit = defineEmits(['success']);
type DictDataBatchItem = Omit<
SystemDictDataApi.DictData,
'createTime' | 'id'
> & {
key: number;
};
type DictTypeOption = {
name: string;
type: string;
};
const columns = [
{ title: '数据标签', dataIndex: 'label', required: true },
{ title: '数据键值', dataIndex: 'value', required: true },
{ title: '显示排序', dataIndex: 'sort', required: true, width: 120 },
{ title: '状态', dataIndex: 'status', required: true, width: 120 },
{ title: '颜色类型', dataIndex: 'colorType', width: 140 },
{ title: 'CSS Class', dataIndex: 'cssClass', width: 150 },
{ title: '备注', dataIndex: 'remark', width: 180 },
{ title: '操作', dataIndex: 'action', width: 80 },
];
const dictType = ref<string>();
const tableData = ref<DictDataBatchItem[]>([]);
const dictTypeOptions = ref<DictTypeOption[]>([]);
const rowKey = ref(0);
const statusOptions = getDictOptions(DICT_TYPE.COMMON_STATUS, 'number');
const colorOptions = useDataFormSchema()
.find((item) => item.fieldName === 'colorType')
?.componentProps?.options;
function buildDefaultRow(sort: number): DictDataBatchItem {
return {
key: rowKey.value++,
dictType: dictType.value || '',
label: '',
value: '',
sort,
status: CommonStatusEnum.ENABLE,
colorType: '',
cssClass: '',
remark: '',
};
}
async function loadDictTypeOptions() {
if (!isEmpty(dictTypeOptions.value)) {
return;
}
dictTypeOptions.value = await getSimpleDictTypeList();
}
function handleAddRow() {
tableData.value.push(buildDefaultRow(tableData.value.length + 1));
}
function handleDeleteRow(index: number) {
tableData.value.splice(index, 1);
}
/** 快速粘贴相关 */
const pasteModalVisible = ref(false);
const pasteText = ref('');
function handleOpenPasteModal() {
pasteText.value = '';
pasteModalVisible.value = true;
}
function handlePasteConfirm() {
const rawText = pasteText.value.trim();
if (!rawText) {
message.warning('请输入内容');
return;
}
const lines = rawText.split('\n').filter((line) => line.trim() !== '');
if (lines.length === 0) {
message.warning('未识别到有效数据');
return;
}
const newRows: DictDataBatchItem[] = [];
for (const [index, line] of lines.entries()) {
const trimmedLine = line.trim();
const rowNumber = index + 1;
// 检查是否包含非法分隔符(只允许空格)
if (/[\t,;|]/.test(trimmedLine)) {
message.error(`${rowNumber} 行解析失败:只能使用空格作为分隔符`);
return;
}
// 使用空格分割:第一部分为值,剩余部分为标签
const parts = trimmedLine.split(/\s+/);
if (parts.length < 2) {
message.error(
`${rowNumber} 行解析失败:格式不正确,需要"值 标签"(空格分隔)`,
);
return;
}
const value = parts[0];
const label = parts.slice(1).join(' ');
newRows.push({
key: rowKey.value++,
dictType: dictType.value || '',
label,
value,
sort: tableData.value.length + newRows.length,
status: CommonStatusEnum.ENABLE,
colorType: '',
cssClass: '',
remark: '',
});
}
if (newRows.length === 0) {
message.warning('未识别到有效数据');
return;
}
tableData.value = newRows;
pasteModalVisible.value = false;
pasteText.value = '';
message.success(`成功识别 ${newRows.length} 条数据`);
}
function handlePasteCancel() {
pasteModalVisible.value = false;
pasteText.value = '';
}
function resetRows() {
rowKey.value = 0;
tableData.value = [buildDefaultRow(1), buildDefaultRow(2), buildDefaultRow(3)];
}
function validateRows() {
if (!dictType.value) {
message.warning('请选择字典类型');
return false;
}
if (isEmpty(tableData.value)) {
message.warning('请至少添加一条字典数据');
return false;
}
const valueSet = new Set<string>();
for (const [index, row] of tableData.value.entries()) {
const rowNumber = index + 1;
if (!row.label) {
message.warning(`${rowNumber} 行数据标签不能为空`);
return false;
}
if (!row.value) {
message.warning(`${rowNumber} 行数据键值不能为空`);
return false;
}
if (row.sort === undefined || row.sort === null) {
message.warning(`${rowNumber} 行显示排序不能为空`);
return false;
}
if (row.status === undefined || row.status === null) {
message.warning(`${rowNumber} 行状态不能为空`);
return false;
}
if (valueSet.has(row.value)) {
message.warning(`${rowNumber} 行数据键值重复`);
return false;
}
valueSet.add(row.value);
}
return true;
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
if (!validateRows()) {
return;
}
modalApi.lock();
try {
const submitData = tableData.value.map((item) => ({
dictType: dictType.value,
label: item.label,
value: item.value,
sort: item.sort,
status: item.status,
colorType: item.colorType,
cssClass: item.cssClass,
remark: item.remark,
})) as SystemDictDataApi.DictData[];
await createDictDataList(submitData);
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
dictType.value = undefined;
tableData.value = [];
return;
}
const data = modalApi.getData<{ dictType?: string }>();
dictType.value = data?.dictType;
await loadDictTypeOptions();
resetRows();
},
});
</script>
<template>
<Modal class="w-2/3" title="批量新增字典数据">
<div class="mx-4 space-y-4">
<Form layout="inline">
<FormItem label="字典类型" required>
<Select
v-model:value="dictType"
allow-clear
class="w-64"
:field-names="{ label: 'name', value: 'type' }"
:options="dictTypeOptions"
placeholder="请选择字典类型"
/>
</FormItem>
<FormItem>
<Space>
<Button type="primary" @click="handleAddRow">新增一行</Button>
<Button
shape="circle"
title="快速粘贴"
@click="handleOpenPasteModal"
>
<template #icon>
<IconifyIcon
icon="ant-design:snippets-outlined"
class="size-4"
/>
</template>
</Button>
</Space>
</FormItem>
</Form>
<Table
bordered
:columns="columns"
:data-source="tableData"
:pagination="false"
row-key="key"
size="small"
>
<template #headerCell="{ column }">
<span v-if="column.required" class="text-red-500">*</span>
<span>{{ column.title }}</span>
</template>
<template #bodyCell="{ column, record, index }">
<Input
v-if="column.dataIndex === 'label'"
v-model:value="record.label"
placeholder="请输入数据标签"
/>
<Input
v-else-if="column.dataIndex === 'value'"
v-model:value="record.value"
placeholder="请输入数据键值"
/>
<InputNumber
v-else-if="column.dataIndex === 'sort'"
v-model:value="record.sort"
class="!w-full"
placeholder="排序"
/>
<Select
v-else-if="column.dataIndex === 'status'"
v-model:value="record.status"
class="w-full"
:options="statusOptions"
/>
<Select
v-else-if="column.dataIndex === 'colorType'"
v-model:value="record.colorType"
allow-clear
class="w-full"
:options="colorOptions"
placeholder="颜色类型"
/>
<Input
v-else-if="column.dataIndex === 'cssClass'"
v-model:value="record.cssClass"
placeholder="CSS Class"
/>
<Input
v-else-if="column.dataIndex === 'remark'"
v-model:value="record.remark"
placeholder="请输入备注"
/>
<Button
v-else-if="column.dataIndex === 'action'"
danger
type="link"
@click="handleDeleteRow(index)"
>
删除
</Button>
</template>
</Table>
</div>
</Modal>
<!-- 快速粘贴弹窗 -->
<AntModal
v-model:open="pasteModalVisible"
:destroy-on-close="true"
ok-text="确认添加"
title="快速粘贴"
@ok="handlePasteConfirm"
@cancel="handlePasteCancel"
>
<div class="space-y-2">
<div class="text-gray-500">
每行一条数据格式<span class="font-medium text-black"> 标签</span>空格分隔
</div>
<TextArea
v-model:value="pasteText"
:auto-size="{ minRows: 8, maxRows: 16 }"
placeholder="请粘贴数据,例如:&#10;010 生成&#10;040 待下发&#10;045 下发中&#10;050 已下发&#10;060 执行中&#10;061 已取货搬运中"
/>
<div class="text-xs text-gray-400">
只允许使用空格作为分隔符不支持 Tab逗号分号等其他分隔符
</div>
</div>
</AntModal>
</template>

View File

@@ -19,6 +19,7 @@ import {
import { $t } from '#/locales';
import { useDataGridColumns, useDataGridFormSchema } from '../data';
import DataBatchForm from './data-batch-form.vue';
import DataForm from './data-form.vue';
const props = defineProps({
@@ -33,6 +34,11 @@ const [DataFormModal, dataFormModalApi] = useVbenModal({
destroyOnClose: true,
});
const [DataBatchFormModal, dataBatchFormModalApi] = useVbenModal({
connectedComponent: DataBatchForm,
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query();
@@ -49,6 +55,11 @@ function handleCreate() {
dataFormModalApi.setData({ dictType: props.dictType }).open();
}
/** 批量创建字典数据 */
function handleCreateBatch() {
dataBatchFormModalApi.setData({ dictType: props.dictType }).open();
}
/** 编辑字典数据 */
function handleEdit(row: SystemDictDataApi.DictData) {
dataFormModalApi.setData(row).open();
@@ -144,18 +155,26 @@ watch(
<template>
<div class="flex h-full flex-col">
<DataFormModal @success="handleRefresh" />
<DataBatchFormModal @success="handleRefresh" />
<Grid table-title="字典数据列表">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.create', ['字典数据']),
label: $t('ui.actionTitle.create', ['数据']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:dict:create'],
onClick: handleCreate,
},
{
label: '批量新增',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:dict:create'],
onClick: handleCreateBatch,
},
{
label: $t('ui.actionTitle.export'),
type: 'primary',