feat(导出模板): 添加SQL预览功能并优化模板展示界面

This commit is contained in:
piexlMax(奇淼
2025-11-10 11:05:03 +08:00
parent 5d67fc95d2
commit 6bea7bc524
8 changed files with 365 additions and 25 deletions

View File

@@ -50,6 +50,34 @@ type SysExportTemplateApi struct {
var sysExportTemplateService = service.ServiceGroupApp.SystemServiceGroup.SysExportTemplateService
// PreviewSQL 预览最终生成的SQL
// @Tags SysExportTemplate
// @Summary 预览最终生成的SQL不执行查询仅返回SQL字符串
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param templateID query string true "导出模板ID"
// @Param params query string false "查询参数编码字符串,参考 ExportExcel 组件"
// @Success 200 {object} response.Response{data=map[string]string} "获取成功"
// @Router /sysExportTemplate/previewSQL [get]
func (sysExportTemplateApi *SysExportTemplateApi) PreviewSQL(c *gin.Context) {
templateID := c.Query("templateID")
if templateID == "" {
response.FailWithMessage("模板ID不能为空", c)
return
}
// 直接复用导出接口的参数组织方式:使用 URL Query其中 params 为内部编码的查询字符串
queryParams := c.Request.URL.Query()
if sqlPreview, err := sysExportTemplateService.PreviewSQL(templateID, queryParams); err != nil {
global.GVA_LOG.Error("获取失败!", zap.Error(err))
response.FailWithMessage("获取失败", c)
} else {
response.OkWithData(gin.H{"sql": sqlPreview}, c)
}
}
// CreateSysExportTemplate 创建导出模板
// @Tags SysExportTemplate
// @Summary 创建导出模板

View File

@@ -26,6 +26,7 @@ func (s *SysExportTemplateRouter) InitSysExportTemplateRouter(Router *gin.Router
sysExportTemplateRouterWithoutRecord.GET("getSysExportTemplateList", exportTemplateApi.GetSysExportTemplateList) // 获取导出模板列表
sysExportTemplateRouterWithoutRecord.GET("exportExcel", exportTemplateApi.ExportExcel) // 获取导出token
sysExportTemplateRouterWithoutRecord.GET("exportTemplate", exportTemplateApi.ExportTemplate) // 导出表格模板
sysExportTemplateRouterWithoutRecord.GET("previewSQL", exportTemplateApi.PreviewSQL) // 预览SQL
}
{
sysExportTemplateRouterWithoutAuth.GET("exportExcelByToken", exportTemplateApi.ExportExcelByToken) // 通过token导出表格

View File

@@ -213,6 +213,16 @@ func (sysExportTemplateService *SysExportTemplateService) ExportExcel(templateID
sql = fmt.Sprintf("%s %s (?)", condition.Column, condition.Operator)
}
if condition.Operator == "BETWEEN" {
sql = fmt.Sprintf("%s BETWEEN ? AND ?", condition.Column)
startValue := paramsValues.Get("start" + condition.From)
endValue := paramsValues.Get("end" + condition.From)
if startValue != "" && endValue != "" {
db = db.Where(sql, startValue, endValue)
}
continue
}
if value != "" {
if condition.Operator == "LIKE" {
value = "%" + value + "%"
@@ -317,14 +327,14 @@ func (sysExportTemplateService *SysExportTemplateService) ExportExcel(templateID
for j, colCell := range row {
cell := fmt.Sprintf("%s%d", getColumnName(j+1), i+1)
var sErr error
if v, err := strconv.ParseFloat(colCell, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else if v, err := strconv.ParseInt(colCell, 10, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else {
sErr = f.SetCellValue("Sheet1", cell, colCell)
}
var sErr error
if v, err := strconv.ParseFloat(colCell, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else if v, err := strconv.ParseInt(colCell, 10, 64); err == nil {
sErr = f.SetCellValue("Sheet1", cell, v)
} else {
sErr = f.SetCellValue("Sheet1", cell, colCell)
}
if sErr != nil {
return nil, "", sErr
@@ -340,6 +350,185 @@ func (sysExportTemplateService *SysExportTemplateService) ExportExcel(templateID
return file, template.Name, nil
}
// PreviewSQL 预览最终生成的 SQL不执行查询仅返回 SQL 字符串)
// Author [piexlmax](https://github.com/piexlmax) & [trae-ai]
func (sysExportTemplateService *SysExportTemplateService) PreviewSQL(templateID string, values url.Values) (sqlPreview string, err error) {
// 解析 params与导出逻辑保持一致
var params = values.Get("params")
paramsValues, _ := url.ParseQuery(params)
// 加载模板
var template system.SysExportTemplate
err = global.GVA_DB.Preload("Conditions").Preload("JoinTemplate").First(&template, "template_id = ?", templateID).Error
if err != nil {
return "", err
}
// 解析模板列
var templateInfoMap = make(map[string]string)
columns, err := utils.GetJSONKeys(template.TemplateInfo)
if err != nil {
return "", err
}
err = json.Unmarshal([]byte(template.TemplateInfo), &templateInfoMap)
if err != nil {
return "", err
}
var selectKeyFmt []string
for _, key := range columns {
selectKeyFmt = append(selectKeyFmt, key)
}
selects := strings.Join(selectKeyFmt, ", ")
// 生成 FROM 与 JOIN 片段
var sb strings.Builder
sb.WriteString("SELECT ")
sb.WriteString(selects)
sb.WriteString(" FROM ")
sb.WriteString(template.TableName)
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
sb.WriteString(" ")
sb.WriteString(join.JOINS)
sb.WriteString(" ")
sb.WriteString(join.Table)
sb.WriteString(" ON ")
sb.WriteString(join.ON)
}
}
// WHERE 条件
var wheres []string
// 软删除过滤
filterDeleted := false
if paramsValues != nil {
filterParam := paramsValues.Get("filterDeleted")
if filterParam == "true" {
filterDeleted = true
}
}
if filterDeleted {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", template.TableName))
if len(template.JoinTemplate) > 0 {
for _, join := range template.JoinTemplate {
if sysExportTemplateService.hasDeletedAtColumn(join.Table) {
wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table))
}
}
}
}
// 模板条件(保留与 ExportExcel 同步的解析规则)
if len(template.Conditions) > 0 {
for _, condition := range template.Conditions {
op := strings.ToUpper(strings.TrimSpace(condition.Operator))
col := strings.TrimSpace(condition.Column)
// 预览优先展示传入值,没有则展示占位符
val := ""
if paramsValues != nil {
val = paramsValues.Get(condition.From)
}
switch op {
case "BETWEEN":
startValue := ""
endValue := ""
if paramsValues != nil {
startValue = paramsValues.Get("start" + condition.From)
endValue = paramsValues.Get("end" + condition.From)
}
if startValue != "" && endValue != "" {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", col, startValue, endValue))
} else {
wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", col, condition.From, condition.From))
}
case "IN", "NOT IN":
if val != "" {
// 逗号分隔值做简单展示
parts := strings.Split(val, ",")
for i := range parts { parts[i] = strings.TrimSpace(parts[i]) }
wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", col, op, strings.Join(parts, "','")))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", col, op, condition.From))
}
case "LIKE":
if val != "" {
wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", col, val))
} else {
wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", col, condition.From))
}
default:
if val != "" {
wheres = append(wheres, fmt.Sprintf("%s %s '%s'", col, op, val))
} else {
wheres = append(wheres, fmt.Sprintf("%s %s {%s}", col, op, condition.From))
}
}
}
}
if len(wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(wheres, " AND "))
}
// 排序
order := ""
if paramsValues != nil {
order = paramsValues.Get("order")
}
if order == "" && template.Order != "" {
order = template.Order
}
if order != "" {
sb.WriteString(" ORDER BY ")
sb.WriteString(order)
}
// limit/offset如果传入或默认值为0则不生成
limitStr := ""
offsetStr := ""
if paramsValues != nil {
limitStr = paramsValues.Get("limit")
offsetStr = paramsValues.Get("offset")
}
// 处理模板默认limit仅当非0时
if limitStr == "" && template.Limit != nil && *template.Limit != 0 {
limitStr = strconv.Itoa(*template.Limit)
}
// 解析为数值,用于判断是否生成
limitInt := 0
offsetInt := 0
if limitStr != "" {
if v, e := strconv.Atoi(limitStr); e == nil { limitInt = v }
}
if offsetStr != "" {
if v, e := strconv.Atoi(offsetStr); e == nil { offsetInt = v }
}
if limitInt > 0 {
sb.WriteString(" LIMIT ")
sb.WriteString(strconv.Itoa(limitInt))
if offsetInt > 0 {
sb.WriteString(" OFFSET ")
sb.WriteString(strconv.Itoa(offsetInt))
}
} else {
// 当limit未设置或为0时仅当offset>0才生成OFFSET
if offsetInt > 0 {
sb.WriteString(" OFFSET ")
sb.WriteString(strconv.Itoa(offsetInt))
}
}
return sb.String(), nil
}
// ExportTemplate 导出Excel模板
// Author [piexlmax](https://github.com/piexlmax)
func (sysExportTemplateService *SysExportTemplateService) ExportTemplate(templateID string) (file *bytes.Buffer, name string, err error) {

View File

@@ -175,6 +175,7 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/getSysExportTemplateList", Description: "获取导出模板列表"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/exportExcel", Description: "导出Excel"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/exportTemplate", Description: "下载模板"},
{ApiGroup: "导出模板", Method: "GET", Path: "/sysExportTemplate/previewSQL", Description: "预览SQL"},
{ApiGroup: "导出模板", Method: "POST", Path: "/sysExportTemplate/importExcel", Description: "导入Excel"},
{ApiGroup: "公告", Method: "POST", Path: "/info/createInfo", Description: "新建公告"},

View File

@@ -176,6 +176,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/getSysExportTemplateList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/exportExcel", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/exportTemplate", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/previewSQL", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysExportTemplate/importExcel", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/info/createInfo", V2: "POST"},

View File

@@ -126,3 +126,20 @@ export const exportTemplate = (params) => {
params
})
}
// PreviewSQL 预览最终生成的SQL
// @Tags SysExportTemplate
// @Summary 预览最终生成的SQL
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Router /sysExportTemplate/previewSQL [get]
// @Param templateID query string true "导出模板ID"
// @Param params query string false "查询参数编码字符串,参考 ExportExcel 组件"
export const previewSQL = (params) => {
return service({
url: '/sysExportTemplate/previewSQL',
method: 'get',
params
})
}

View File

@@ -73,6 +73,7 @@
"/src/view/systemTools/index.vue": "System",
"/src/view/systemTools/installPlugin/index.vue": "Index",
"/src/view/systemTools/pubPlug/pubPlug.vue": "PubPlug",
"/src/view/systemTools/sysError/sysError.vue": "SysError",
"/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/version/version.vue": "SysVersion",
"/src/plugin/announcement/form/info.vue": "InfoForm",

View File

@@ -139,7 +139,7 @@
icon="edit-pen"
class="table-button"
@click="showCode(scope.row)"
>代码</el-button
>代码和SQL预览</el-button
>
<el-button
type="primary"
@@ -401,28 +401,79 @@
</el-form>
</el-drawer>
<!-- 合并:代码模板 + SQL预览 抽屉 -->
<el-drawer
v-model="codeVisible"
size="60%"
:before-close="closeDialog"
:title="type === 'create' ? '添加' : '修改'"
:show-close="false"
v-model="drawerVisible"
size="70%"
:title="'模板与预览'"
:show-close="true"
destroy-on-close
>
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">模板</span>
<span class="text-lg">模板与预览</span>
<div>
<el-button type="primary" @click="closeDialog">确 定</el-button>
<el-button @click="drawerVisible = false">关 闭</el-button>
<el-button v-if="activeTab === 'sql'" type="primary" @click="runPreview">生 成</el-button>
</div>
</div>
</template>
<v-ace-editor
v-model:value="webCode"
lang="vue"
theme="github_dark"
class="h-full"
/>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="代码模板" name="code">
<v-ace-editor
v-model:value="webCode"
lang="vue"
theme="github_dark"
class="w-full h-96"
:options="{ showPrintMargin: false, fontSize: 14 }"
/>
</el-tab-pane>
<el-tab-pane label="SQL预览" name="sql">
<div class="flex flex-col gap-4">
<div class="w-full">
<el-form :model="previewForm" label-width="120px">
<el-form-item label="过滤已删除">
<el-switch v-model="previewForm.filterDeleted" />
</el-form-item>
<el-form-item label="默认排序">
<el-input v-model="previewForm.order" placeholder="例如: id desc" />
</el-form-item>
<el-form-item label="限制条数">
<el-input-number v-model="previewForm.limit" :min="0" />
</el-form-item>
<el-form-item label="偏移量">
<el-input-number v-model="previewForm.offset" :min="0" />
</el-form-item>
<el-divider content-position="left">查询条件</el-divider>
<div v-if="previewConditions.length === 0" class="text-gray">该模板暂无条件</div>
<template v-for="(cond, idx) in previewConditions" :key="idx">
<el-form-item :label="cond.column + ' ' + cond.operator">
<template v-if="cond.operator === 'BETWEEN'">
<div class="flex gap-2 w-full">
<el-input v-model="previewForm['start' + cond.from]" placeholder="开始值: start{{cond.from}}" />
<el-input v-model="previewForm['end' + cond.from]" placeholder="结束值: end{{cond.from}}" />
</div>
</template>
<template v-else>
<el-input v-model="previewForm[cond.from]" :placeholder="'变量: ' + cond.from" />
</template>
</el-form-item>
</template>
</el-form>
</div>
<div class="w-full">
<v-ace-editor
v-model:value="previewSQLCode"
lang="sql"
theme="github_dark"
class="w-full h-96"
:options="aceOptions"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
</div>
</template>
@@ -436,6 +487,7 @@
findSysExportTemplate,
getSysExportTemplateList
} from '@/api/exportTemplate.js'
import { previewSQL } from '@/api/exportTemplate.js'
// 全量引入格式化工具 请按需保留
import { formatDate } from '@/utils/format'
@@ -448,6 +500,7 @@
import 'ace-builds/src-noconflict/mode-vue'
import 'ace-builds/src-noconflict/theme-github_dark'
import 'ace-builds/src-noconflict/mode-sql'
defineOptions({
name: 'ExportTemplate'
@@ -903,7 +956,8 @@ JOINS模式下不支持导入
getTableData()
}
}
const codeVisible = ref(false)
const drawerVisible = ref(false)
const activeTab = ref('code')
// 弹窗控制标记
const dialogFormVisible = ref(false)
@@ -911,7 +965,56 @@ JOINS模式下不支持导入
const showCode = (row) => {
webCode.value = getCode(row.templateID)
codeVisible.value = true
activeTab.value = 'code'
drawerVisible.value = true
}
// 预览 SQL
const previewForm = ref({ filterDeleted: true, order: '', limit: 0, offset: 0 })
const previewSQLCode = ref('')
const previewTemplate = ref(null)
const previewConditions = ref([])
const aceOptions = { wrap: true, showPrintMargin: false, fontSize: 14 }
const openPreview = async (row) => {
// 获取模板完整信息以展示条件输入项
const res = await findSysExportTemplate({ ID: row.ID })
if (res.code === 0) {
previewTemplate.value = res.data.resysExportTemplate
previewConditions.value = (previewTemplate.value.conditions || []).map((c) => ({
from: c.from,
column: c.column,
operator: c.operator
}))
// 预填默认的排序与限制
previewForm.value.order = previewTemplate.value.order || ''
previewForm.value.limit = previewTemplate.value.limit || 0
previewForm.value.offset = 0
previewSQLCode.value = ''
activeTab.value = 'sql'
drawerVisible.value = true
}
}
const runPreview = async () => {
if (!previewTemplate.value) return
// 组装 params与导出组件保持一致
const paramsCopy = JSON.parse(JSON.stringify(previewForm.value))
// 将布尔与数值等按照导出组件规则编码
if (paramsCopy.filterDeleted) paramsCopy.filterDeleted = 'true'
const entries = Object.entries(paramsCopy).filter(([key, v]) => {
if (v === '' || v === null || v === undefined) return false
if ((key === 'limit' || key === 'offset') && Number(v) === 0) return false
return true
})
const params = entries
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
const res = await previewSQL({ templateID: previewTemplate.value.templateID, params })
if (res.code === 0) {
previewSQLCode.value = res.data.sql || ''
}
}
// 打开弹窗
@@ -922,7 +1025,6 @@ JOINS模式下不支持导入
// 关闭弹窗
const closeDialog = () => {
codeVisible.value = false
dialogFormVisible.value = false
formData.value = {
name: '',