front/src/views/system/dict/index.vue
zzh d4447cc7d4 feat: 数据字典左右分栏并拆分类型/明细分页接口
左侧仅拉取类型列表与项数,右侧按选中类型服务端分页;修复批量导入重复项识别、字段兼容及类型数量统计。
2026-07-22 09:25:00 +08:00

747 lines
23 KiB
Vue

<template>
<div class="rbac-split-layout dict-split-layout">
<CustomLeftMenu
class="left-sidebar dict-left-menu"
:tree-data="dictTypeTreeData"
:selected-keys="selectedTypeKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="typeSearchKeyword"
:loading="typeLoading"
:search-placeholder="t('system.dict.left.searchPlaceholder')"
:refresh-title="t('system.dict.left.refresh')"
:add-title="t('system.dict.left.addType')"
:delete-label="t('system.dict.left.deleteType')"
:loading-text="t('system.dict.left.loading')"
:show-method="false"
:show-node-index="false"
:show-map-count="true"
:show-node-code="true"
:show-node-actions="true"
:show-pagination="true"
:page-size="30"
@search="handleTypeSearch"
@refresh="() => loadDictTypes(undefined, { loadDetails: false })"
@add="handleAddType"
@select="handleTypeSelect"
@node-click="handleTypeNodeClick"
>
<template #node-menu="{ nodeData }">
<a-doption @click="handleEditType(nodeData)">
<icon-edit />
{{ t('system.dict.left.editType') }}
</a-doption>
<a-doption
v-if="nodeData?.id"
@click="handleDeleteType(nodeData)"
>
<icon-delete />
{{ t('system.dict.left.deleteType') }}
</a-doption>
</template>
</CustomLeftMenu>
<div class="right-panel">
<div class="dict-page-panel">
<div v-if="!selectedDictType" class="panel-empty">
<a-empty :description="t('system.dict.left.emptySelect')">
<template #image>
<icon-book class="panel-empty-icon" />
</template>
<p class="panel-empty-desc">{{ t('system.dict.left.emptySelectDesc') }}</p>
</a-empty>
</div>
<template v-else>
<div class="dict-type-header">
<div class="dict-type-header-main">
<h3 class="panel-title">{{ displayTypeName }}</h3>
<a-tag size="small" color="arcoblue" class="type-code-tag">{{ selectedDictType }}</a-tag>
<span class="type-item-count">
{{ t('system.dict.panel.itemCount', { count: selectedTypeMeta?.itemCount ?? pagination.total }) }}
</span>
</div>
<a-button type="text" size="small" class="edit-type-btn" @click="handleEditSelectedType">
<template #icon><icon-edit /></template>
{{ t('system.dict.panel.editType') }}
</a-button>
</div>
<div class="table-toolbar">
<div class="toolbar-left">
<a-input-search
v-model="query.keyword"
allow-clear
:placeholder="t('system.dict.search.placeholder')"
style="width: 240px"
@search="handleSearch"
@clear="handleSearch"
/>
</div>
<div class="toolbar-right">
<a-space wrap :size="8">
<a-button type="primary" @click="handleAdd">
<template #icon><icon-plus /></template>
{{ t('system.dict.actions.add') }}
</a-button>
<a-button @click="openBatchImportModal">
<template #icon><icon-upload /></template>
{{ t('system.dict.actions.batchImport') }}
</a-button>
<a-button :loading="exportLoading" @click="handleBatchExport">
<template #icon><icon-download /></template>
{{ t('system.dict.actions.batchExport') }}
</a-button>
<a-button @click="loadData">
<template #icon><icon-refresh /></template>
</a-button>
<PaginationPro
:total-count="pagination.total"
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</a-space>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<a-spin :loading="listLoading" class="list-spin">
<HandsontablePro
:columns="hotColumns"
:data="dataSource"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.pageSize"
:show-action-column="true"
:action-column-position="'end'"
:action-column-width="100"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
@action-click="handleActionClick"
/>
</a-spin>
</div>
</template>
</div>
</div>
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? t('system.dict.modal.editTitle') : t('system.dict.modal.addTitle')"
:confirm-loading="modalLoading"
width="560px"
@ok="handleModalOk"
>
<a-form ref="formRef" :model="formState" :rules="formRules" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form-item :label="t('system.dict.form.dictType')" field="dictType">
<a-input v-model="formState.dictType" disabled :placeholder="t('system.dict.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('system.dict.form.dictSort')" field="dictSort">
<a-input-number v-model="formState.dictSort" :placeholder="t('system.dict.placeholder.input')" :min="0" :precision="0" style="width: 100%" />
</a-form-item>
<a-form-item :label="t('system.dict.form.dictCode')" field="dictCode">
<a-input v-model="formState.dictCode" :placeholder="t('system.dict.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('system.dict.form.dictName')" field="dictName">
<a-input v-model="formState.dictName" :placeholder="t('system.dict.placeholder.input')" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:visible="typeModalVisible"
:title="typeIsEdit ? t('system.dict.typeModal.editTitle') : t('system.dict.typeModal.addTitle')"
:confirm-loading="typeModalLoading"
width="520px"
@ok="handleTypeModalOk"
>
<a-form ref="typeFormRef" :model="typeFormState" :rules="typeFormRules" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form-item :label="t('system.dict.form.dictType')" field="dictType">
<a-input
v-model="typeFormState.dictType"
:disabled="typeIsEdit"
:placeholder="t('system.dict.placeholder.input')"
/>
</a-form-item>
<a-form-item :label="t('system.dict.form.typeRemark')" field="remark">
<a-input v-model="typeFormState.remark" allow-clear :placeholder="t('system.dict.placeholder.typeRemark')" />
</a-form-item>
<a-form-item :label="t('system.dict.form.typeSort')" field="typeSort">
<a-input-number v-model="typeFormState.typeSort" :min="0" :precision="0" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<DictBatchImportModal ref="batchImportModalRef" @success="handleImportSuccess" />
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconPlus,
IconRefresh,
IconUpload,
IconDownload,
IconEdit,
IconDelete,
IconBook,
} from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
createDict,
deleteDict,
deleteDictType,
getDictList,
getDictListAll,
getDictTypeList,
saveDictType,
updateDict,
type DictDto,
type DictTypeDto,
} from '@/api/system/dict'
import { exportDictRowsToExcel } from './dict-batch-import'
import DictBatchImportModal from './DictBatchImportModal.vue'
defineOptions({ name: 'SystemDict' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const typeLoading = ref(false)
const listLoading = ref(false)
const dictTypeList = ref<DictTypeDto[]>([])
const typeSearchKeyword = ref('')
const selectedDictType = ref('')
const expandedKeys = ref<string[]>([])
const dataSource = ref<DictDto[]>([])
const batchImportModalRef = ref<InstanceType<typeof DictBatchImportModal> | null>(null)
const exportLoading = ref(false)
const modalVisible = ref(false)
const modalLoading = ref(false)
const isEdit = ref(false)
const editingId = ref<number | string>()
const formRef = ref()
const typeModalVisible = ref(false)
const typeModalLoading = ref(false)
const typeIsEdit = ref(false)
const typeFormRef = ref()
const query = reactive({
keyword: '',
})
const pagination = reactive({
current: 1,
pageSize: 30,
total: 0,
})
const formState = reactive({
dictType: '',
dictSort: 1,
dictCode: '',
dictName: '',
})
const typeFormState = reactive({
id: undefined as number | string | undefined,
dictType: '',
typeSort: 0,
remark: '',
})
const formRules = computed(() => ({
dictType: [{ required: true, message: t('system.dict.rule.dictTypeRequired') }],
dictSort: [{ required: true, message: t('system.dict.rule.dictSortRequired') }],
dictCode: [{ required: true, message: t('system.dict.rule.dictCodeRequired') }],
dictName: [{ required: true, message: t('system.dict.rule.dictNameRequired') }],
}))
const typeFormRules = computed(() => ({
dictType: [{ required: true, message: t('system.dict.rule.dictTypeRequired') }],
}))
const selectedTypeMeta = computed(() =>
dictTypeList.value.find((item) => item.dictType === selectedDictType.value),
)
const displayTypeName = computed(
() => selectedTypeMeta.value?.remark?.trim() || selectedDictType.value,
)
const filteredTypeList = computed(() => {
const kw = typeSearchKeyword.value.trim().toLowerCase()
if (!kw) return dictTypeList.value
return dictTypeList.value.filter((item) => {
const type = String(item.dictType ?? '').toLowerCase()
const remark = String(item.remark ?? '').toLowerCase()
return type.includes(kw) || remark.includes(kw)
})
})
function buildTypeTreeNode(item: DictTypeDto): CustomLeftMenuTreeNode {
const dictType = String(item.dictType ?? '').trim()
const remark = String(item.remark ?? '').trim()
return {
key: `type-${dictType}`,
title: remark || dictType,
code: remark ? dictType : undefined,
mapCount: item.itemCount ?? 0,
isLeaf: true,
id: item.id,
dictType,
remark,
typeSort: item.typeSort,
}
}
const dictTypeTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
filteredTypeList.value.map((item) => buildTypeTreeNode(item)),
)
const selectedTypeKeys = computed(() =>
selectedDictType.value ? [`type-${selectedDictType.value}`] : [],
)
const hotColumns = computed(() => [
{ key: 'dictSort', label: t('system.dict.table.dictSort'), width: 100 },
{ key: 'dictCode', label: t('system.dict.table.dictCode'), width: 180 },
{ key: 'dictName', label: t('system.dict.table.dictName'), width: 200 },
{ key: 'createTime', label: t('system.dict.table.createTime'), width: 170 },
])
function syncSelectedTypeItemCount(count: number) {
if (!selectedDictType.value) return
const idx = dictTypeList.value.findIndex((item) => item.dictType === selectedDictType.value)
if (idx < 0) return
dictTypeList.value[idx] = { ...dictTypeList.value[idx], itemCount: count }
}
function handlePageChange(page: number) {
pagination.current = page
loadData()
}
function handlePageSizeChange(size: number) {
pagination.pageSize = size
pagination.current = 1
loadData()
}
const actionEditIcon = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="m30.48 19.038 5.733-5.734a1 1 0 0 0 0-1.414l-5.586-5.586a1 1 0 0 0-1.414 0l-5.734 5.734m7 7L15.763 33.754a1 1 0 0 1-.59.286l-6.048.708a1 1 0 0 1-1.113-1.069l.477-6.31a1 1 0 0 1 .29-.631l14.7-14.7m7 7-7-7M6 42h36"/></svg>'
const actionDeleteIcon = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M5 11h5.5m0 0v29a1 1 0 0 0 1 1h25a1 1 0 0 0 1-1V11m-27 0H16m21.5 0H43m-5.5 0H32m-16 0V7h16v4m-16 0h16M20 18v15m8-15v15"/></svg>'
function customCellRenderer(_row: number, col: number, _value: any, rowData: DictDto): string | null {
const dataColIndex = col - 1
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const key = hotColumns.value[dataColIndex].key
if (key === 'createTime') return rowData.createTime ? dayjs(rowData.createTime).format('YYYY-MM-DD HH:mm:ss') : '--'
return null
}
function actionRenderer(_row: DictDto, rowIndex: number): string {
const actions = [
`<span data-action="edit" data-row-index="${rowIndex}" style="cursor:pointer;color:var(--app-primary-color,#165dff);display:inline-flex;" title="${t('system.dict.actions.edit')}">${actionEditIcon}</span>`,
`<span data-action="delete" data-row-index="${rowIndex}" style="cursor:pointer;color:#f53f3f;display:inline-flex;" title="${t('system.dict.actions.delete')}">${actionDeleteIcon}</span>`,
]
return `<div style="display:flex;align-items:center;justify-content:center;gap:20px;width:100%;">${actions.join('')}</div>`
}
function handleActionClick(row: DictDto, _rowIndex: number, action?: string) {
if (action === 'edit') {
handleEdit(row)
return
}
if (action === 'delete') {
Modal.confirm({
content: t('system.dict.modal.deleteConfirmContentSingle', { dictName: row.dictName || '' }),
onOk: () => handleDelete(row.id),
})
}
}
function selectDictTypeFromKey(key?: string) {
if (!key || !key.startsWith('type-')) return
const dictType = key.slice(5)
if (!dictType) return
if (selectedDictType.value === dictType) return
selectedDictType.value = dictType
pagination.current = 1
query.keyword = ''
loadData()
}
function handleTypeSelect(keys: string[]) {
selectDictTypeFromKey(keys[0])
}
function handleTypeNodeClick(key: string) {
selectDictTypeFromKey(key)
}
function handleTypeSearch() {
// filteredTypeList 客户端过滤
}
function handleSearch() {
pagination.current = 1
loadData()
}
function openBatchImportModal() {
batchImportModalRef.value?.open()
}
async function handleBatchExport() {
if (!selectedDictType.value) {
Message.warning(t('system.dict.left.emptySelect'))
return
}
exportLoading.value = true
try {
const rows = await getDictListAll({
dictType: selectedDictType.value,
keyword: query.keyword || undefined,
})
if (!rows.length) {
Message.warning(t('system.dict.export.empty'))
return
}
const suffix = selectedDictType.value ? `_${selectedDictType.value}` : ''
exportDictRowsToExcel(rows, `数据字典${suffix}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`)
Message.success(t('system.dict.export.success'))
} catch (e: any) {
Message.error(e?.message || t('system.dict.export.failed'))
} finally {
exportLoading.value = false
}
}
function handleAdd() {
if (!selectedDictType.value) {
Message.warning(t('system.dict.left.emptySelect'))
return
}
isEdit.value = false
editingId.value = undefined
Object.assign(formState, {
dictType: selectedDictType.value,
dictSort: (selectedTypeMeta.value?.itemCount ?? 0) + 1,
dictCode: '',
dictName: '',
})
modalVisible.value = true
}
function handleEdit(row: DictDto) {
isEdit.value = true
editingId.value = row.id
Object.assign(formState, {
dictType: row.dictType || selectedDictType.value,
dictSort: row.dictSort ?? 1,
dictCode: row.dictCode || '',
dictName: row.dictName || '',
})
modalVisible.value = true
}
function handleAddType() {
typeIsEdit.value = false
Object.assign(typeFormState, {
id: undefined,
dictType: '',
typeSort: dictTypeList.value.length + 1,
remark: '',
})
typeModalVisible.value = true
}
function handleEditType(nodeData: CustomLeftMenuTreeNode | null) {
if (!nodeData?.dictType) return
typeIsEdit.value = true
Object.assign(typeFormState, {
id: nodeData.id,
dictType: nodeData.dictType,
typeSort: nodeData.typeSort ?? 0,
remark: nodeData.remark ?? '',
})
typeModalVisible.value = true
}
function handleEditSelectedType() {
handleEditType(buildTypeTreeNode(selectedTypeMeta.value || { dictType: selectedDictType.value }))
}
async function handleDeleteType(nodeData: CustomLeftMenuTreeNode | null) {
if (!nodeData?.id) return
Modal.confirm({
content: t('system.dict.typeModal.deleteConfirm', { dictType: nodeData.dictType || '' }),
onOk: async () => {
await deleteDictType(nodeData.id!)
Message.success(t('system.dict.typeModal.deleteSuccess'))
if (selectedDictType.value === nodeData.dictType) {
selectedDictType.value = ''
dataSource.value = []
pagination.total = 0
}
await loadDictTypes(undefined, { loadDetails: false })
},
})
}
async function handleDelete(id?: number | string) {
if (!id) return
await deleteDict(id)
Message.success(t('system.dict.message.deleteSuccess'))
await Promise.all([loadData(), loadDictTypes(undefined, { loadDetails: false })])
}
async function handleModalOk() {
try {
await formRef.value.validate()
modalLoading.value = true
const payload = {
dictType: formState.dictType.trim(),
dictSort: Number(formState.dictSort),
dictCode: formState.dictCode.trim(),
dictName: formState.dictName.trim(),
}
if (isEdit.value && editingId.value) {
await updateDict({ id: editingId.value, ...payload })
Message.success(t('system.dict.message.editSuccess'))
} else {
await createDict(payload)
Message.success(t('system.dict.message.addSuccess'))
}
modalVisible.value = false
await Promise.all([loadData(), loadDictTypes(undefined, { loadDetails: false })])
} finally {
modalLoading.value = false
}
}
async function handleTypeModalOk() {
try {
await typeFormRef.value.validate()
typeModalLoading.value = true
await saveDictType({
id: typeFormState.id,
dictType: typeFormState.dictType.trim(),
remark: typeFormState.remark.trim() || undefined,
typeSort: Number(typeFormState.typeSort) || 0,
})
Message.success(typeIsEdit.value ? t('system.dict.typeModal.editSuccess') : t('system.dict.typeModal.addSuccess'))
typeModalVisible.value = false
const savedType = typeFormState.dictType.trim()
await loadDictTypes(savedType, { loadDetails: false })
} finally {
typeModalLoading.value = false
}
}
async function loadDictTypes(
preferSelectType?: string,
options: { loadDetails?: boolean } = {},
) {
const { loadDetails = false } = options
typeLoading.value = true
try {
dictTypeList.value = await getDictTypeList()
const types = dictTypeList.value.map((item) => item.dictType).filter(Boolean) as string[]
if (preferSelectType && types.includes(preferSelectType)) {
selectedDictType.value = preferSelectType
} else if (!selectedDictType.value || !types.includes(selectedDictType.value)) {
selectedDictType.value = types[0] || ''
}
if (loadDetails && selectedDictType.value) {
await loadData()
} else if (!selectedDictType.value) {
dataSource.value = []
pagination.total = 0
}
} catch (e: any) {
dictTypeList.value = []
Message.error(e?.message || t('system.dict.message.loadTypeFailed'))
} finally {
typeLoading.value = false
}
}
async function loadData() {
if (!selectedDictType.value) {
dataSource.value = []
pagination.total = 0
return
}
listLoading.value = true
try {
const { items, total } = await getDictList({
page: pagination.current,
pageSize: pagination.pageSize,
dictType: selectedDictType.value,
keyword: query.keyword.trim() || undefined,
})
dataSource.value = items
pagination.total = total
if (!query.keyword.trim()) {
syncSelectedTypeItemCount(total)
}
} catch (e: any) {
dataSource.value = []
pagination.total = 0
Message.error(e?.message || t('system.dict.message.loadDataFailed', { error: '' }))
} finally {
listLoading.value = false
}
}
async function handleImportSuccess() {
await loadDictTypes(undefined, { loadDetails: false })
if (selectedDictType.value) {
await loadData()
}
}
onMounted(async () => {
await loadDictTypes(undefined, { loadDetails: true })
})
</script>
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
.dict-split-layout {
box-sizing: border-box;
}
.dict-page-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #fff;
padding: 16px;
box-sizing: border-box;
}
.dict-type-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.dict-type-header-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.4;
}
.type-code-tag {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.type-item-count {
font-size: 13px;
color: var(--color-text-3);
}
.edit-type-btn {
flex-shrink: 0;
color: var(--app-primary-color);
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
}
.toolbar-left {
flex: 1;
min-width: 200px;
}
.panel-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
}
.panel-empty-icon {
font-size: 56px;
color: var(--color-text-4);
}
.panel-empty-desc {
margin: 8px 0 0;
font-size: 13px;
color: var(--color-text-3);
text-align: center;
}
.list-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.list-spin {
height: 100%;
display: flex;
flex-direction: column;
}
.list-spin :deep(.arco-spin-container) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.list-spin :deep(.handsontable-pro-container) {
flex: 1;
min-height: 0;
}
</style>