feat: 增加字典的json导出和导入

This commit is contained in:
piexlMax(奇淼
2025-11-26 16:40:48 +08:00
parent a1f18e1386
commit 08ae44158f
4 changed files with 259 additions and 128 deletions

View File

@@ -171,17 +171,17 @@ func (s *DictionaryApi) ExportSysDictionary(c *gin.Context) {
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body map[string]interface{} true "字典JSON数据"
// @Param data body request.ImportSysDictionaryRequest true "字典JSON数据"
// @Success 200 {object} response.Response{msg=string} "导入字典"
// @Router /sysDictionary/importSysDictionary [post]
func (s *DictionaryApi) ImportSysDictionary(c *gin.Context) {
var importData map[string]interface{}
err := c.ShouldBindJSON(&importData)
var req request.ImportSysDictionaryRequest
err := c.ShouldBindJSON(&req)
if err != nil {
response.FailWithMessage(err.Error(), c)
return
}
err = dictionaryService.ImportSysDictionary(importData)
err = dictionaryService.ImportSysDictionary(req.Json)
if err != nil {
global.GVA_LOG.Error("导入失败!", zap.Error(err))
response.FailWithMessage("导入失败: "+err.Error(), c)

View File

@@ -3,3 +3,7 @@ package request
type SysDictionarySearch struct {
Name string `json:"name" form:"name" gorm:"column:name;comment:字典名(中)"` // 字典名(中)
}
type ImportSysDictionaryRequest struct {
Json string `json:"json" binding:"required"` // JSON字符串
}

View File

@@ -1,6 +1,7 @@
package system
import (
"encoding/json"
"errors"
"github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
@@ -153,7 +154,7 @@ func (dictionaryService *DictionaryService) checkCircularReference(currentID uin
return nil
}
//@author: [yourname]
//@author: [pixelMax]
//@function: ExportSysDictionary
//@description: 导出字典JSON包含字典详情
//@param: id uint
@@ -169,55 +170,65 @@ func (dictionaryService *DictionaryService) ExportSysDictionary(id uint) (export
return nil, err
}
// 清空字典详情中的ID、创建时间、更新时间等字段
var cleanDetails []map[string]interface{}
for _, detail := range dictionary.SysDictionaryDetails {
cleanDetail := map[string]interface{}{
"label": detail.Label,
"value": detail.Value,
"extend": detail.Extend,
"status": detail.Status,
"sort": detail.Sort,
"level": detail.Level,
"path": detail.Path,
}
cleanDetails = append(cleanDetails, cleanDetail)
}
// 构造导出数据
exportData = map[string]interface{}{
"name": dictionary.Name,
"type": dictionary.Type,
"status": dictionary.Status,
"desc": dictionary.Desc,
"details": dictionary.SysDictionaryDetails,
"name": dictionary.Name,
"type": dictionary.Type,
"status": dictionary.Status,
"desc": dictionary.Desc,
"sysDictionaryDetails": cleanDetails,
}
return exportData, nil
}
//@author: [yourname]
//@author: [pixelMax]
//@function: ImportSysDictionary
//@description: 导入字典JSON包含字典详情
//@param: importData map[string]interface{}
//@param: jsonStr string
//@return: err error
func (dictionaryService *DictionaryService) ImportSysDictionary(importData map[string]interface{}) error {
// 解析基本字典信息
name, ok := importData["name"].(string)
if !ok || name == "" {
return errors.New("字典名称不能为空")
func (dictionaryService *DictionaryService) ImportSysDictionary(jsonStr string) error {
// 直接解析到 SysDictionary 结构体
var importData system.SysDictionary
if err := json.Unmarshal([]byte(jsonStr), &importData); err != nil {
return errors.New("JSON 格式错误: " + err.Error())
}
dictType, ok := importData["type"].(string)
if !ok || dictType == "" {
// 验证必填字段
if importData.Name == "" {
return errors.New("字典名称不能为空")
}
if importData.Type == "" {
return errors.New("字典类型不能为空")
}
// 检查字典类型是否已存在
if !errors.Is(global.GVA_DB.First(&system.SysDictionary{}, "type = ?", dictType).Error, gorm.ErrRecordNotFound) {
if !errors.Is(global.GVA_DB.First(&system.SysDictionary{}, "type = ?", importData.Type).Error, gorm.ErrRecordNotFound) {
return errors.New("存在相同的type不允许导入")
}
// 创建字典
// 创建字典清空导入数据的ID和时间戳
dictionary := system.SysDictionary{
Name: name,
Type: dictType,
}
// 处理status字段
if status, ok := importData["status"].(bool); ok {
dictionary.Status = &status
}
// 处理desc字段
if desc, ok := importData["desc"].(string); ok {
dictionary.Desc = desc
Name: importData.Name,
Type: importData.Type,
Status: importData.Status,
Desc: importData.Desc,
}
// 开启事务
@@ -228,84 +239,53 @@ func (dictionaryService *DictionaryService) ImportSysDictionary(importData map[s
}
// 处理字典详情
if details, ok := importData["details"].([]interface{}); ok && len(details) > 0 {
if len(importData.SysDictionaryDetails) > 0 {
// 创建一个映射来跟踪旧ID到新ID的对应关系
idMap := make(map[uint]uint)
// 第一遍:创建所有详情记录不设置parent_id
for _, detail := range details {
detailMap, ok := detail.(map[string]interface{})
if !ok {
// 第一遍:创建所有详情记录
for _, detail := range importData.SysDictionaryDetails {
// 验证必填字段
if detail.Label == "" || detail.Value == "" {
continue
}
label, _ := detailMap["label"].(string)
value, _ := detailMap["value"].(string)
if label == "" || value == "" {
continue
}
// 记录旧ID
oldID := detail.ID
// 创建新的详情记录ID会被GORM自动设置
detailRecord := system.SysDictionaryDetail{
Label: label,
Value: value,
Label: detail.Label,
Value: detail.Value,
Extend: detail.Extend,
Status: detail.Status,
Sort: detail.Sort,
Level: detail.Level,
Path: detail.Path,
SysDictionaryID: int(dictionary.ID),
}
// 处理extend字段
if extend, ok := detailMap["extend"].(string); ok {
detailRecord.Extend = extend
}
// 处理status字段
if status, ok := detailMap["status"].(bool); ok {
detailRecord.Status = &status
}
// 处理sort字段
if sort, ok := detailMap["sort"].(float64); ok {
detailRecord.Sort = int(sort)
}
// 创建详情记录
if err := tx.Create(&detailRecord).Error; err != nil {
return err
}
// 记录ID映射如果有原始ID
if oldID, ok := detailMap["ID"].(float64); ok {
idMap[uint(oldID)] = detailRecord.ID
// 记录ID到新ID的映射
if oldID > 0 {
idMap[oldID] = detailRecord.ID
}
}
// 第二遍更新parent_id关系
for i, detail := range details {
detailMap, ok := detail.(map[string]interface{})
if !ok {
continue
}
// 如果有parentID更新它
if oldParentID, ok := detailMap["parentID"].(float64); ok && oldParentID > 0 {
if newParentID, exists := idMap[uint(oldParentID)]; exists {
// 获取新创建的记录ID按顺序
if oldID, ok := detailMap["ID"].(float64); ok {
if newID, exists := idMap[uint(oldID)]; exists {
if err := tx.Model(&system.SysDictionaryDetail{}).Where("id = ?", newID).Update("parent_id", newParentID).Error; err != nil {
return err
}
}
} else {
// 如果没有ID使用索引来查找
var allDetails []system.SysDictionaryDetail
if err := tx.Where("sys_dictionary_id = ?", dictionary.ID).Order("id").Find(&allDetails).Error; err != nil {
for _, detail := range importData.SysDictionaryDetails {
if detail.ParentID != nil && *detail.ParentID > 0 && detail.ID > 0 {
if newID, exists := idMap[detail.ID]; exists {
if newParentID, parentExists := idMap[*detail.ParentID]; parentExists {
if err := tx.Model(&system.SysDictionaryDetail{}).
Where("id = ?", newID).
Update("parent_id", newParentID).Error; err != nil {
return err
}
if i < len(allDetails) {
if err := tx.Model(&system.SysDictionaryDetail{}).Where("id = ?", allDetails[i].ID).Update("parent_id", newParentID).Error; err != nil {
return err
}
}
}
}
}

View File

@@ -4,7 +4,7 @@
title="获取字典且缓存方法已在前端utils/dictionary 已经封装完成 不必自己书写 使用方法查看文件内注释"
/>
<el-splitter class="h-full">
<el-splitter-panel size="400px" min="200px" max="800px" collapsible>
<el-splitter-panel size="300px" min="200px" max="800px" collapsible>
<div
class="flex-none bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded p-4"
>
@@ -170,34 +170,70 @@
</el-form>
</el-drawer>
<!-- 导入字典对话框 -->
<el-dialog
v-model="importDialogVisible"
title="导入字典JSON"
width="70%"
:close-on-click-modal="false"
<!-- 导入字典抽屉 -->
<el-drawer
v-model="importDrawerVisible"
:size="appStore.drawerSize"
:show-close="false"
:before-close="closeImportDrawer"
>
<div class="import-dialog-content">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">导入字典JSON</span>
<div>
<el-button @click="closeImportDrawer"> </el-button>
<el-button type="primary" @click="handleImport" :loading="importing">
确认导入
</el-button>
</div>
</div>
</template>
<div class="import-drawer-content">
<div class="mb-4">
<el-alert
title="请粘贴编辑字典JSON数据,支持包含字典详情的完整数据结构"
title="请粘贴编辑或拖拽JSON文件到下方区域"
type="info"
:closable="false"
show-icon
/>
</div>
<div class="json-editor-container">
<!-- 拖拽上传区域 -->
<div
class="drag-upload-area"
:class="{ 'is-dragging': isDragging }"
@drop.prevent="handleDrop"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@click="triggerFileInput"
>
<el-icon class="upload-icon"><Upload /></el-icon>
<div class="upload-text">
<p> JSON 文件拖到此处或点击选择文件</p>
<p class="upload-hint">也可以在下方文本框直接编辑</p>
</div>
<input
ref="fileInputRef"
type="file"
accept=".json,application/json"
style="display: none"
@change="handleFileSelect"
/>
</div>
<div class="json-editor-container mt-4">
<el-input
v-model="importJsonText"
type="textarea"
:rows="20"
:rows="15"
placeholder='请输入JSON数据例如
{
"name": "性别",
"type": "gender",
"status": true,
"desc": "性别字典",
"details": [
"sysDictionaryDetails": [
{
"label": "男",
"value": "1",
@@ -215,6 +251,7 @@
class="json-textarea"
/>
</div>
<div class="mt-4" v-if="jsonPreviewError">
<el-alert
:title="jsonPreviewError"
@@ -223,22 +260,10 @@
show-icon
/>
</div>
<div class="mt-4" v-if="jsonPreview && !jsonPreviewError">
<el-divider content-position="left">JSON预览</el-divider>
<div class="json-preview">
<pre>{{ jsonPreviewFormatted }}</pre>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeImportDialog"> </el-button>
<el-button type="primary" @click="handleImport" :loading="importing">
确认导入
</el-button>
</div>
</template>
</el-dialog>
</el-drawer>
</div>
</template>
@@ -305,11 +330,13 @@
const availableParentDictionaries = ref([])
// 导入相关
const importDialogVisible = ref(false)
const importDrawerVisible = ref(false)
const importJsonText = ref('')
const importing = ref(false)
const jsonPreviewError = ref('')
const jsonPreview = ref(null)
const isDragging = ref(false)
const fileInputRef = ref(null)
// 监听JSON文本变化实时预览
watch(importJsonText, (newVal) => {
@@ -333,6 +360,7 @@
return JSON.stringify(jsonPreview.value, null, 2)
})
// 查询
const getTableData = async () => {
const res = await getSysDictionaryList({
@@ -492,20 +520,85 @@
}
}
// 打开导入对话框
// 打开导入抽屉
const openImportDialog = () => {
importDialogVisible.value = true
importDrawerVisible.value = true
importJsonText.value = ''
jsonPreview.value = null
jsonPreviewError.value = ''
isDragging.value = false
}
// 关闭导入对话框
const closeImportDialog = () => {
importDialogVisible.value = false
// 关闭导入抽屉
const closeImportDrawer = () => {
importDrawerVisible.value = false
importJsonText.value = ''
jsonPreview.value = null
jsonPreviewError.value = ''
isDragging.value = false
}
// 处理拖拽进入
const handleDragOver = (e) => {
isDragging.value = true
}
// 处理拖拽离开
const handleDragLeave = (e) => {
isDragging.value = false
}
// 处理文件拖拽
const handleDrop = (e) => {
isDragging.value = false
const files = e.dataTransfer.files
if (files.length === 0) return
const file = files[0]
readJsonFile(file)
}
// 触发文件选择
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileSelect = (e) => {
const files = e.target.files
if (files.length === 0) return
const file = files[0]
readJsonFile(file)
// 清空input以便可以重复选择同一文件
e.target.value = ''
}
// 读取JSON文件
const readJsonFile = (file) => {
// 检查文件类型
if (!file.name.endsWith('.json')) {
ElMessage.warning('请上传 JSON 文件')
return
}
// 读取文件内容
const reader = new FileReader()
reader.onload = (event) => {
try {
const content = event.target.result
// 验证是否为有效的 JSON
JSON.parse(content)
importJsonText.value = content
ElMessage.success('文件读取成功')
} catch (error) {
ElMessage.error('文件内容不是有效的 JSON 格式')
}
}
reader.onerror = () => {
ElMessage.error('文件读取失败')
}
reader.readAsText(file)
}
// 处理导入
@@ -522,11 +615,10 @@
try {
importing.value = true
const jsonData = JSON.parse(importJsonText.value)
const res = await importSysDictionary(jsonData)
const res = await importSysDictionary({ json: importJsonText.value })
if (res.code === 0) {
ElMessage.success('导入成功')
closeImportDialog()
closeImportDrawer()
getTableData()
}
} catch (error) {
@@ -547,9 +639,53 @@
color: #fff;
}
.import-dialog-content {
max-height: 70vh;
overflow-y: auto;
.import-drawer-content {
padding: 0 4px;
}
/* 拖拽上传区域 */
.drag-upload-area {
border: 2px dashed #dcdfe6;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
background-color: #fafafa;
transition: all 0.3s ease;
cursor: pointer;
}
.drag-upload-area:hover {
border-color: #409eff;
background-color: #ecf5ff;
}
.drag-upload-area.is-dragging {
border-color: #409eff;
background-color: #ecf5ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 48px;
color: #8c939d;
margin-bottom: 16px;
}
.drag-upload-area.is-dragging .upload-icon {
color: #409eff;
}
.upload-text {
color: #606266;
}
.upload-text p {
margin: 4px 0;
}
.upload-hint {
font-size: 12px;
color: #909399;
}
.json-editor-container {
@@ -582,6 +718,17 @@
word-wrap: break-word;
}
.dark .drag-upload-area {
background-color: #1d1e1f;
border-color: #414243;
}
.dark .drag-upload-area:hover,
.dark .drag-upload-area.is-dragging {
background-color: #1a3a52;
border-color: #409eff;
}
.dark .json-preview {
background-color: #1d1e1f;
border-color: #414243;