diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java index 1c59e97..c46b5e3 100644 --- a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java @@ -10,6 +10,7 @@ import java.util.HashSet; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import com.alibaba.fastjson2.JSON; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.generator.domain.GenProject; @@ -475,6 +476,10 @@ public class BusinessBlockGenerationService context.put("chartType", file.getDefinition().getChartType()); context.put("chartDataset", instance.getDataset()); context.put("chartDisplay", instance.getDisplay()); + context.put("chartDisplayJson", javascriptJson(instance.getDisplay() == null + ? Collections.emptyMap() : instance.getDisplay())); + context.put("chartInstanceNameJson", javascriptJson( + StringUtils.defaultIfEmpty(instance.getName(), file.getDefinition().getName()))); context.put("chartMetrics", chartMetricRenderModels(chartQuery.getMetrics())); context.put("chartDateStartLiteral", ChartQueryParameter.toJavaLiteral( chartQuery.getDateRange() == null ? null : chartQuery.getDateRange().getStart())); @@ -537,6 +542,14 @@ public class BusinessBlockGenerationService return renderConfig; } + private String javascriptJson(Object value) + { + return JSON.toJSONString(value) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026"); + } + private List> chartMetricRenderModels(List metrics) { List> result = new ArrayList>(); diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm new file mode 100644 index 0000000..5799e82 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm @@ -0,0 +1,176 @@ + + + + + diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vm new file mode 100644 index 0000000..cb34b16 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vm @@ -0,0 +1,8 @@ +import request from "@/api/request" + +export function load${InstanceClassName}Data() { + return request({ + url: "${apiBasePath}/data", + method: "get" + }) +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartRuntime.js.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartRuntime.js.vm new file mode 100644 index 0000000..c9ce1a7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartRuntime.js.vm @@ -0,0 +1,58 @@ +import * as echarts from "echarts" + +export function createChart(element) { + return echarts.init(element) +} + +export function buildChartOption(chartType, display, payload) { + const safeDisplay = display || {} + const dimensions = (payload && payload.dimensions) || [] + const sourceSeries = (payload && payload.series) || [] + const labels = dimensions.map(item => item.label) + + if (chartType === "pie") { + const values = sourceSeries.length ? sourceSeries[0].values : [] + return { + tooltip: { trigger: "item" }, + legend: { show: safeDisplay.showLegend !== false }, + series: [{ + type: "pie", + radius: safeDisplay.donut ? ["45%", "70%"] : "70%", + label: { show: safeDisplay.showLabel !== false }, + data: labels.map((name, index) => ({ + name, + value: values[index] == null ? 0 : values[index] + })) + }] + } + } + + const horizontal = chartType === "bar" && safeDisplay.horizontal + const series = sourceSeries.map(item => ({ + name: item.name, + type: chartType === "bar" ? "bar" : "line", + smooth: chartType === "line" && Boolean(safeDisplay.smooth), + stack: chartType === "bar" && safeDisplay.stacked ? "total" : undefined, + areaStyle: chartType === "line" && safeDisplay.area ? {} : undefined, + data: item.values || [] + })) + + return { + tooltip: { trigger: "axis" }, + legend: { show: safeDisplay.showLegend !== false }, + xAxis: horizontal + ? { type: "value" } + : { type: "category", data: labels }, + yAxis: horizontal + ? { type: "category", data: labels } + : { type: "value" }, + series + } +} + +export function hasChartData(payload) { + return Boolean(payload && ( + (payload.summary && payload.summary.value !== null && payload.summary.value !== undefined) + || (payload.dimensions && payload.dimensions.length) + )) +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/page/business-block-page.vue.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/page/business-block-page.vue.vm new file mode 100644 index 0000000..e3b67d8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/business-blocks/page/business-block-page.vue.vm @@ -0,0 +1,63 @@ + + + + + diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/index.vue.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/index.vue.vm index 6a6118d..2711fa8 100644 --- a/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/index.vue.vm +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/index.vue.vm @@ -18,6 +18,8 @@ #set($showForm = true) #set($renderForm = true) #set($currentUserOnly = false) +#set($isDetailPage = $pageDesignIsDetail) +#set($detailColumns = $columns) #set($toolbarBusinessActions = $pageDesignToolbarBusinessActions) #set($rowBusinessActions = $pageDesignRowBusinessActions) #set($allPageBusinessActions = $pageDesignBusinessActions) @@ -54,11 +56,26 @@ #set($showToolbar = $pageDesignShowToolbar) #set($showForm = $pageDesignShowForm) #set($currentUserOnly = $pageDesignIsCurrentUserList) +#set($detailColumns = $pageDesignFormColumns) #end #if(!$showToolbar) #set($showAdd = false) #set($showBatchDelete = false) #end +#if($isDetailPage) +#set($showQuery = false) +#set($showToolbar = false) +#set($showAdd = false) +#set($showBatchDelete = false) +#set($showView = false) +#set($showEdit = false) +#set($showDelete = false) +#set($showForm = false) +#set($renderForm = false) +#if($detailColumns.size() == 0) +#set($detailColumns = $columns) +#end +#end #set($renderForm = $showForm) #if($showAdd || $showEdit) #set($renderForm = true) @@ -67,7 +84,9 @@ #if($hasPageDesignTopBusinessBlocks)
#foreach($block in $topBusinessBlocks) - <${block.componentName} /> +
+ <${block.componentName} /> +
#end
#end @@ -130,12 +149,70 @@ #end #foreach($action in $toolbarBusinessActions) - ${action.name} + #set($buttonLabel = $action.buttonLabel) + #if(!$buttonLabel)#set($buttonLabel = $action.name)#end + #set($buttonType = $action.buttonType) + #if(!$buttonType)#set($buttonType = "primary")#end + #set($buttonIcon = $action.buttonIcon) + #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end + ${buttonLabel} #end #end +#if($isDetailPage) +
+
+ + 暂无详情数据 +
+ +
+#else + #if($showQuery)
#foreach ($column in $queryColumns) @@ -188,8 +265,8 @@ + :src="resolveResourceUrl(item.${cardImageColumn.javaField})" + :preview-src-list="[resolveResourceUrl(item.${cardImageColumn.javaField})]" /> #end #if($cardTitleColumn)

{{ item.${cardTitleColumn.javaField} || '-' }}

@@ -209,8 +286,8 @@ + :src="resolveResourceUrl(item.${javaField})" + :preview-src-list="[resolveResourceUrl(item.${javaField})]" /> #elseif($column.htmlType == "select" || $column.htmlType == "radio")
${columnComment} @@ -249,7 +326,13 @@ #end #foreach($action in $rowBusinessActions) - ${action.name} + #set($buttonLabel = $action.buttonLabel) + #if(!$buttonLabel)#set($buttonLabel = $action.name)#end + #set($buttonType = $action.buttonType) + #if(!$buttonType)#set($buttonType = "text")#end + #set($buttonIcon = $action.buttonIcon) + #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end + ${buttonLabel} #end
#end @@ -280,8 +363,8 @@ + :src="resolveResourceUrl(scope.row.${javaField})" + :preview-src-list="[resolveResourceUrl(scope.row.${javaField})]" /> - @@ -321,7 +404,13 @@ #end #foreach($action in $rowBusinessActions) - ${action.name} + #set($buttonLabel = $action.buttonLabel) + #if(!$buttonLabel)#set($buttonLabel = $action.name)#end + #set($buttonType = $action.buttonType) + #if(!$buttonType)#set($buttonType = "text")#end + #set($buttonIcon = $action.buttonIcon) + #if(!$buttonIcon)#set($buttonIcon = "el-icon-position")#end + ${buttonLabel} #end @@ -338,12 +427,15 @@ :page-size="queryParams.pageSize" layout="total, sizes, prev, pager, next, jumper" :total="total" /> +#end #if($hasPageDesignBottomBusinessBlocks)
#foreach($block in $bottomBusinessBlocks) - <${block.componentName} /> +
+ <${block.componentName} /> +
#end
#end @@ -362,9 +454,10 @@ #set($javaField=$column.javaField) #set($columnComment=$column.columnComment) #set($remoteSelect=$qingRemoteSelectFields.contains($javaField)) - +#set($autoIncrementField=$column.isIncrement == "1") + #if($column.htmlType == "input") - + #elseif($column.htmlType == "textarea") #elseif($column.htmlType == "select" || $column.htmlType == "radio") @@ -391,7 +484,7 @@ action="/api/files/upload" :show-file-list="false" :on-success="(res) => uploadSuccess('${javaField}', res)"> - + #elseif($column.htmlType == "fileUpload") @@ -412,6 +505,73 @@
#end + + +

+ {{ activeBusinessAction.confirmMessage }} +

+ + + + + + + + + + + +
@@ -453,9 +613,20 @@ export default { #end #end }, +#if($isDetailPage) + detailRecord: {}, + detailLoading: false, + detailLoaded: false, +#end form: {}, viewing: false, dialogFormVisible: false, + businessActionDialogVisible: false, + businessActionSubmitting: false, + activeBusinessAction: null, + businessActionBasePayload: {}, + businessActionForm: {}, + businessActionInputFields: [], multipleSelection: [], remoteOptionMap: {}, remoteSelectConfig: { @@ -478,17 +649,53 @@ export default { businessActionMap: { #foreach($action in $allPageBusinessActions) "${action.code}": { + name: "${action.name}", + buttonLabel: "${action.buttonLabel}", method: "${action.method}", path: "${action.path}", - requestFields: [#foreach($field in $action.requestFields)"${field}"#if($foreach.count != $action.requestFields.size()), #end#end] + successMessage: "${action.successMessage}", + refreshTarget: "${action.refreshTarget}", + confirmMessage: "${action.confirmMessage}", + requestFields: [#foreach($field in $action.requestFields)"${field}"#if($foreach.hasNext), #end#end], + currentUserFields: [#foreach($field in $action.currentUserFields)"${field}"#if($foreach.hasNext), #end#end], + inputFields: [ +#foreach($field in $action.inputFields) + { + field: "${field.field}", + label: "${field.label}", + htmlType: "${field.htmlType}", + dictType: "${field.dictType}" + }#if($foreach.hasNext),#end +#end + ] }#if($foreach.count != $allPageBusinessActions.size()),#end +#end + }, + routeActionMap: { +#foreach($routeAction in $pageDesignRouteActionEntries) + "${routeAction.key}": { + path: "${routeAction.path}", + paramMap: {#set($paramMap = $routeAction.paramMap)#foreach($param in $paramMap.entrySet())"${param.key}":"${param.value}"#if($foreach.count != $paramMap.size()),#end#end} + }#if($foreach.count != $pageDesignRouteActionEntries.size()),#end #end } } }, + computed: { + businessActionDialogTitle() { + if (!this.activeBusinessAction) { + return "业务操作" + } + return this.activeBusinessAction.buttonLabel || this.activeBusinessAction.name || "业务操作" + } + }, created() { this.loadRemoteOptions() +#if($isDetailPage) + this.loadDetail() +#else this.load() +#end }, methods: { loadRemoteOptions() { @@ -517,6 +724,7 @@ export default { } return value === undefined || value === null || value === "" ? "-" : value }, +#if(!$isDetailPage) load() { this.request.get("/${moduleName}/${businessName}/page", { params: this.queryParams @@ -526,6 +734,33 @@ export default { this.total = page.total || 0 }) }, +#end +#if($isDetailPage) + resolveDetailId() { + const params = this.#[[$route]]# && this.#[[$route]]#.params ? this.#[[$route]]#.params : {} + return params.id || params.${pkColumn.javaField} || "" + }, + loadDetail() { + const detailId = this.resolveDetailId() + if (!detailId) { + this.detailRecord = {} + this.form = {} + this.detailLoaded = true + return + } + this.detailLoading = true + this.request.get("/${moduleName}/${businessName}/" + detailId).then(res => { + this.detailRecord = res.data || {} + this.form = JSON.parse(JSON.stringify(this.detailRecord)) + this.detailLoaded = true + }).finally(() => { + this.detailLoading = false + }) + }, + formatDetailValue(value) { + return value === undefined || value === null || value === "" ? "-" : value + }, +#end save() { this.#[[$refs]]#.form.validate(valid => { if (!valid) { @@ -543,17 +778,32 @@ export default { }) }, handleAdd() { + if (this.handleConfiguredRoute("toolbar:create")) { + return + } this.form = {} this.viewing = false this.dialogFormVisible = true this.#[[$nextTick]]#(() => this.#[[$refs]]#.form && this.#[[$refs]]#.form.clearValidate()) }, handleView(row) { + if (this.handleConfiguredRoute("table:view", row)) { + return + } + if (this.handleConfiguredRoute("cardActions:view", row)) { + return + } this.form = JSON.parse(JSON.stringify(row)) this.viewing = true this.dialogFormVisible = true }, handleEdit(row) { + if (this.handleConfiguredRoute("table:edit", row)) { + return + } + if (this.handleConfiguredRoute("cardActions:edit", row)) { + return + } this.form = JSON.parse(JSON.stringify(row)) this.viewing = false this.dialogFormVisible = true @@ -575,6 +825,32 @@ export default { handleCardSelectionChange() { this.multipleSelection = this.${businessName}List.filter(item => item.__checked) }, + handleConfiguredRoute(actionKey, row) { + const action = this.routeActionMap[actionKey] + if (!action || !action.path) { + return false + } + const path = this.buildConfiguredRoutePath(action.path, action.paramMap || {}, row) + if (!path) { + return false + } + this.#[[$router]]#.push(path) + return true + }, + buildConfiguredRoutePath(routePath, paramMap, row) { + let path = routePath + Object.keys(paramMap || {}).forEach(param => { + const field = paramMap[param] + const value = row && row[field] + if (value !== undefined && value !== null && value !== "") { + path = path.replace(new RegExp(":" + param + "(?=/|$)", "g"), encodeURIComponent(value)) + } + }) + return this.hasUnresolvedRouteParam(path) ? "" : path + }, + hasUnresolvedRouteParam(path) { + return String(path || "").split("/").some(segment => segment.indexOf(":") === 0) + }, delBatch() { const ids = this.multipleSelection.map(item => item.${pkColumn.javaField}) if (!ids.length) { @@ -599,28 +875,130 @@ export default { this.load() }, handleBusinessAction(code, row) { + if (this.handleConfiguredRoute("business:" + code, row) || this.handleConfiguredRoute("toolbar:" + code, row) || this.handleConfiguredRoute("table:" + code, row)) { + return + } const action = this.businessActionMap[code] if (!action || !action.path) { this.#[[$message]]#.warning("动作未配置") return } const payload = this.buildBusinessActionPayload(action, row) + const missingFields = (action.inputFields || []).filter(field => !this.hasBusinessActionValue(payload[field.field])) + if (missingFields.length > 0) { + this.openBusinessActionDialog(action, payload, missingFields) + return + } + if (action.confirmMessage) { + this.#[[$confirm]]#(action.confirmMessage, "提示", { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + }).then(() => this.executeBusinessAction(action, payload)).catch(() => {}) + return + } + this.executeBusinessAction(action, payload).catch(() => {}) + }, + openBusinessActionDialog(action, payload, fields) { + const form = {} + fields.forEach(field => { + form[field.field] = this.hasBusinessActionValue(payload[field.field]) ? payload[field.field] : undefined + }) + this.activeBusinessAction = action + this.businessActionBasePayload = Object.assign({}, payload) + this.businessActionInputFields = fields.slice() + this.businessActionForm = form + this.businessActionDialogVisible = true + this.#[[$nextTick]]#(() => { + const formRef = this.#[[$refs]]#.businessActionForm + if (formRef) { + formRef.clearValidate() + } + }) + }, + submitBusinessAction() { + const formRef = this.#[[$refs]]#.businessActionForm + if (!formRef || !this.activeBusinessAction) { + return + } + formRef.validate(valid => { + if (!valid) { + return + } + const action = this.activeBusinessAction + const payload = Object.assign({}, this.businessActionBasePayload, this.businessActionForm) + this.businessActionSubmitting = true + this.executeBusinessAction(action, payload) + .then(() => { + this.businessActionDialogVisible = false + }) + .catch(() => {}) + .finally(() => { + this.businessActionSubmitting = false + }) + }) + }, + resetBusinessActionDialog() { + this.activeBusinessAction = null + this.businessActionBasePayload = {} + this.businessActionForm = {} + this.businessActionInputFields = [] + }, + hasBusinessActionValue(value) { + if (value === undefined || value === null) { + return false + } + return typeof value !== "string" || value.trim() !== "" + }, + getBusinessActionFieldOptions(field) { + if (!field || !field.dictType) { + return [] + } + return this._dict.getDictDataByType(field.dictType).map(option => ({ + label: option.name, + value: option.value + })) + }, + executeBusinessAction(action, payload) { const method = String(action.method || "POST").toLowerCase() const request = method === "get" ? this.request.get(action.path, { params: payload }) : this.request[method] ? this.request[method](action.path, payload) : this.request.post(action.path, payload) - request.then(res => { - if (res.code === "200") { - this.#[[$message]]#.success("操作成功") - this.load() - } else { - this.#[[$message]]#.error(res.msg || "操作失败") + return request.then(res => { + if (res.code !== "200") { + return Promise.reject(new Error(res.msg || "操作失败")) } + this.#[[$message]]#.success(action.successMessage || "操作成功") + this.refreshBusinessActionTarget(action.refreshTarget) + return res + }).catch(error => { + const data = error && error.response ? error.response.data : null + this.#[[$message]]#.error((data && data.msg) || (error && error.message) || "操作失败") + return Promise.reject(error) }) }, + refreshBusinessActionTarget(refreshTarget) { + const target = refreshTarget || "list" + if (target === "none") { + return + } + if (target === "page") { + window.location.reload() + return + } +#if($isDetailPage) + this.loadDetail() +#else + this.load() +#end + }, buildBusinessActionPayload(action, row) { const payload = {} + const currentUserFields = new Set(action.currentUserFields || []) ;(action.requestFields || []).forEach(field => { + if (currentUserFields.has(field)) { + return + } const value = this.resolvePayloadValue(field, row) if (value !== undefined) { payload[field] = value @@ -630,7 +1008,7 @@ export default { }, resolvePayloadValue(field, row) { const camelField = this.toCamelCase(field) - const sources = [row, this.form, this.queryParams] + const sources = [row, this.detailRecord, this.form, this.queryParams] for (const source of sources) { if (!source) { continue @@ -655,6 +1033,21 @@ export default { this.queryParams.pageNum = pageNum this.load() }, + resolveResourceUrl(url) { + if (!url) { + return "" + } + if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0 || url.indexOf("//") === 0 || url.indexOf("data:") === 0 || url.indexOf("blob:") === 0) { + return url + } + if (url.indexOf("/api/") === 0) { + return url + } + if (url.indexOf("/files/") === 0) { + return "/api" + url + } + return url + }, uploadSuccess(field, res) { if (res && res.code === "200" && res.data && res.data.url) { this.#[[$set]]#(this.form, field, res.data.url) @@ -674,9 +1067,14 @@ export default { .embedded-business-blocks { display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 16px; } +.embedded-business-block__item--span-12 { grid-column: span 12; } +.embedded-business-block__item--span-6 { grid-column: span 6; } +.embedded-business-block__item--span-4 { grid-column: span 4; } + .content-board { display: grid; grid-template-columns: minmax(0, 1.36fr) minmax(320px, 0.64fr); @@ -691,6 +1089,12 @@ export default { background: #ffffff; } +@media (max-width: 900px) { + .embedded-business-block__item { + grid-column: span 12; + } +} + .feature-illustration { position: absolute; inset: 0; @@ -999,6 +1403,66 @@ export default { border-top: 1px solid #edf0f5; } +.detail-view { + min-height: 180px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px 18px; + margin: 0; +} + +.detail-field { + display: grid; + gap: 6px; + padding: 14px 0; + border-bottom: 1px solid #edf0f5; +} + +.detail-field.wide { + grid-column: 1 / -1; +} + +.detail-field dt { + color: #7b8493; + font-size: 13px; + font-weight: 600; +} + +.detail-field dd { + margin: 0; + color: #172033; + font-size: 15px; + line-height: 1.65; + word-break: break-word; +} + +.detail-image { + width: 180px; + height: 128px; + border-radius: 4px; + object-fit: cover; +} + +.detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid #edf0f5; +} + +.empty-detail { + display: flex; + align-items: center; + gap: 8px; + min-height: 120px; + color: #7b8493; +} + .pagination { margin-top: 14px; text-align: right; @@ -1037,6 +1501,23 @@ export default { word-break: break-all; } +.business-action-hint { + margin: 0 0 18px; + padding: 10px 12px; + border-radius: 4px; + color: #606266; + background: #f5f7fa; + line-height: 1.6; +} + +.business-action-hint + .el-form .el-date-editor, +.business-action-hint + .el-form .el-select, +.business-action-hint + .el-form .el-input, +.el-dialog .el-form .el-date-editor, +.el-dialog .el-form .el-select { + width: 100%; +} + @media (max-width: 980px) { .content-board { grid-template-columns: 1fr; diff --git a/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/vue-package.json.vm b/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/vue-package.json.vm new file mode 100644 index 0000000..6336ede --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/resources/qing/vue-package.json.vm @@ -0,0 +1,22 @@ +{ + "name": "${businessName}-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vue-cli-service serve --host 0.0.0.0", + "serve": "vue-cli-service serve --host 0.0.0.0", + "build": "vue-cli-service build" + }, + "dependencies": { + "axios": "^0.27.2", + "core-js": "^3.8.3", + "echarts": "5.4.0", + "element-ui": "^2.15.14", + "vue": "^2.6.14", + "vue-router": "^3.6.5" + }, + "devDependencies": { + "@vue/cli-service": "^4.5.19", + "vue-template-compiler": "^2.6.14" + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java index 7376843..6813284 100644 --- a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java @@ -202,6 +202,7 @@ public class BusinessBlockGenerationServiceTest assertTrue(pageContent.contains("portal-page")); assertTrue(pageContent.contains("import Notice001Block from \"@/views/pages/book_catalog/blocks/notice_001/NoticeBlock.vue\"")); + assertTrue(pageContent.contains("embedded-business-block__item--span-12")); assertTrue(pageContent.contains("")); } @@ -367,6 +368,41 @@ public class BusinessBlockGenerationServiceTest assertTrue(rendered.get("controller").contains("@GetMapping(\"/data\")")); } + @Test + public void generatedChartComponentHasBlockLevelStatesAndCleanup() + { + GenProject project = chartProject(); + String component = renderChartFrontendFile(project, "frontendComponent"); + String api = renderChartFrontendFile(project, "frontendApi"); + String runtime = service.renderFile(project, + findPath(service.listFiles(project, "admin_frontend"), "src/utils/chartRuntime.js")); + + assertTrue(component.contains("el-skeleton")); + assertTrue(component.contains("el-empty")); + assertTrue(component.contains("retry")); + assertTrue(component.contains("disposeChart")); + assertTrue(component.contains("window.removeEventListener")); + assertTrue(component.contains("chartType: \"line\"")); + assertTrue(api.contains("url: \"/admin/business-blocks/admin_line_chart_001/data\"")); + assertTrue(runtime.contains("import * as echarts from \"echarts\"")); + assertTrue(runtime.contains("buildChartOption")); + assertTrue(runtime.contains("hasChartData")); + } + + @Test + public void businessBlockPageUsesTwelveColumnSpanClasses() + { + GenProject project = chartProject(); + GeneratedBusinessBlockFile pageFile = findPath(service.listFiles(project, "admin_frontend"), + "src/views/pages/sales_dashboard/index.vue"); + + String page = service.renderFile(project, pageFile); + + assertTrue(page.contains("business-block-page__item--span-6")); + assertTrue(page.contains("business-block-page__item--span-4")); + assertTrue(page.contains("grid-template-columns: repeat(12, minmax(0, 1fr))")); + } + private void setField(String name, Object value) throws Exception { Field field = BusinessBlockGenerationService.class.getDeclaredField(name); @@ -442,6 +478,13 @@ public class BusinessBlockGenerationServiceTest return rendered; } + private String renderChartFrontendFile(GenProject project, String templateKey) + { + GeneratedBusinessBlockFile file = findBlockFile(service.listFiles(project, "admin_frontend"), + "admin_line_chart_001", templateKey); + return service.renderFile(project, file); + } + private void assertRenderedFiles(GenProject project, List files) { for (GeneratedBusinessBlockFile file : files) diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java index 357520f..a12476e 100644 --- a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java @@ -10,6 +10,10 @@ import com.ruoyi.generator.domain.GenProject; import com.ruoyi.generator.domain.front.FrontProjectPageDesign; import com.ruoyi.generator.domain.front.dto.AppMenuDesign; import com.ruoyi.generator.domain.front.dto.AppPageDesign; +import com.ruoyi.generator.domain.front.dto.BusinessActionDesign; +import com.ruoyi.generator.domain.front.dto.BusinessActionEffectDesign; +import com.ruoyi.generator.domain.front.dto.BusinessActionInputFieldDesign; +import com.ruoyi.generator.domain.front.dto.BusinessActionRuleDesign; import com.ruoyi.generator.service.GenTableServiceImpl; import com.ruoyi.generator.service.GenProjectServiceImpl; import java.lang.reflect.Method; @@ -20,7 +24,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.velocity.Template; @@ -62,6 +68,15 @@ public class QingTemplateSupportTest } } + @Test + public void qingPackageIncludesEcharts() + { + VelocityInitializer.initVelocity(); + String content = render("qing/vue-package.json.vm", VelocityUtils.prepareContextProject(project())); + + assertTrue(content.contains("\"echarts\": \"5.4.0\"")); + } + @Test public void qingTemplatesAllResolveRunnableOutputFileNames() { @@ -128,6 +143,145 @@ public class QingTemplateSupportTest assertTrue(controllerTemplate.contains("${className}Service.saveOrUpdate(${className})")); } + @Test + public void qingEntityImportsDateTypesForStandaloneAuditColumns() + { + GenTable table = renderTable(); + GenTableColumn createTime = column("create_time", "创建时间", "Date", "createTime", + "0", "1", "0", "1", "EQ", "datetime"); + GenTableColumn updateTime = column("update_time", "更新时间", "Date", "updateTime", + "0", "1", "0", "1", "EQ", "datetime"); + List columns = new ArrayList(table.getColumns()); + columns.add(createTime); + columns.add(updateTime); + table.setColumns(columns); + + VelocityInitializer.initVelocity(); + String rendered = render("qing/entity.java.vm", VelocityUtils.prepareQingContext(table)); + + assertTrue(rendered.contains("import java.util.Date;")); + assertTrue(rendered.contains("import com.fasterxml.jackson.annotation.JsonFormat;")); + assertTrue(rendered.contains("private Date createTime;")); + assertTrue(rendered.contains("private Date updateTime;")); + } + + @Test + public void qingTemplatesRenderExecutableBusinessActions() + { + GenTable table = renderTable(); + BusinessActionDesign action = businessAction(); + action.setCurrentUserFields(Arrays.asList("user_id", "username")); + BusinessActionRuleDesign rule = new BusinessActionRuleDesign(); + rule.setType("NUMBER_GTE"); + rule.setTargetTable("demo_product"); + rule.setTargetField("product_stock"); + rule.setCompareValue("1"); + rule.setConditionFields(Arrays.asList("id")); + rule.setMessage("Stock is not enough"); + action.setRuleChecks(Arrays.asList(rule)); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + context.put("tableBusinessActions", Arrays.asList(action)); + context.put("hasTableBusinessActions", true); + + String controller = render("qing/controller.java.vm", context); + String service = render("qing/service.java.vm", context); + String implementation = render("qing/serviceImpl.java.vm", context); + + assertTrue(controller.contains("@PostMapping(\"/library/borrow/borrow\")")); + assertTrue(controller.contains("actionParams.put(\"__currentUserId\", currentUser.getUserId())")); + assertTrue(controller.contains( + "prepareBusinessActionParams(params, request, new String[] {\"user_id\", \"username\"})")); + assertTrue(controller.contains("\"username\".equals(field)")); + assertTrue(controller.contains("currentUser.getUsername()")); + assertTrue(controller.contains("actionParams.put(field, currentUser.getUserId())")); + assertTrue(service.contains("int borrow_book(Map params)")); + assertTrue(implementation.contains("@Transactional(rollbackFor = Exception.class)")); + assertTrue(implementation.contains("resolveBusinessValue")); + assertTrue(implementation.contains("\"${current_user.id}\"")); + assertTrue(implementation.contains( + "assertNumberGte(params, \"demo_product\", \"product_stock\", \"1\", new String[] {\"id\"}")); + assertTrue(implementation.contains("where.append(safeField).append(\" = ?\")")); + assertFalse(implementation.contains("conditionColumns")); + } + + @Test + public void qingBackendResolvesLegacyCurrentUserIdPlaceholder() + { + GenTable table = renderTable(); + BusinessActionDesign action = businessAction(); + action.getEffects().get(0).getValues().put("user_id", "${current_user_id}"); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + context.put("tableBusinessActions", Arrays.asList(action)); + context.put("hasTableBusinessActions", true); + + String implementation = render("qing/serviceImpl.java.vm", context); + + assertTrue(implementation.contains( + "insertValues0.put(\"user_id\", resolveBusinessValue(params, \"${current_user_id}\"))")); + assertTrue(implementation.contains("\"${current_user_id}\".equals(valueExpression)")); + assertTrue(implementation.contains("return params.get(\"__currentUserId\")")); + } + + @Test + public void qingFrontendUsesOneDialogForMissingBusinessActionFields() + { + GenTable table = renderTable(); + BusinessActionDesign action = businessAction(); + action.setButtonLabel("Borrow"); + action.setCurrentUserFields(Arrays.asList("user_id")); + BusinessActionInputFieldDesign dueDate = new BusinessActionInputFieldDesign(); + dueDate.setField("due_date"); + dueDate.setLabel("Due date"); + dueDate.setHtmlType("datetime"); + dueDate.setDictType(""); + action.setInputFields(Arrays.asList(dueDate)); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + context.put("pageDesignBusinessActions", Arrays.asList(action)); + + String page = render("qing/index.vue.vm", context); + + assertTrue(page.contains("businessActionDialogVisible")); + assertTrue(page.contains("businessActionInputFields")); + assertTrue(page.contains("submitBusinessAction")); + assertTrue(page.contains("openBusinessActionDialog")); + assertTrue(page.contains("currentUserFields: [\"user_id\"]")); + assertTrue(page.contains("field: \"due_date\"")); + assertTrue(page.contains("label: \"Due date\"")); + assertTrue(page.contains("htmlType: \"datetime\"")); + assertTrue(page.contains("v-for=\"field in businessActionInputFields\"")); + assertTrue(page.contains("field.htmlType === 'datetime'")); + assertTrue(page.contains("field.htmlType === 'textarea'")); + assertTrue(page.contains("getBusinessActionFieldOptions(field)")); + assertFalse(page.contains("this.$prompt")); + assertFalse(page.contains("collectBusinessActionPayload")); + } + + @Test + public void qingCrudFormHidesAutoIncrementIdOnCreateAndShowsItReadOnlyOnEdit() + { + GenTable table = renderTable(); + GenTableColumn id = column("id", "ID", "Long", "id", + "1", "1", "1", "1", "EQ", "input"); + id.setIsRequired("1"); + GenTableColumn name = column("name", "Name", "String", "name", + "0", "1", "1", "1", "LIKE", "input"); + name.setIsRequired("1"); + table.setPkColumn(id); + table.setColumns(Arrays.asList(id, name)); + + VelocityInitializer.initVelocity(); + String rendered = render("qing/index.vue.vm", VelocityUtils.prepareContext(table)); + + assertTrue(rendered.contains("")); + assertTrue(rendered.contains(" columns = new ArrayList(table.getColumns()); + columns.add(cover); + table.setColumns(columns); + + VelocityInitializer.initVelocity(); + String rendered = render("qing/index.vue.vm", VelocityUtils.prepareContext(table)); + + assertTrue(rendered.contains(":src=\"resolveResourceUrl(scope.row.coverUrl)\"")); + assertTrue(rendered.contains(":preview-src-list=\"[resolveResourceUrl(scope.row.coverUrl)]\"")); + assertTrue(rendered.contains(":src=\"resolveResourceUrl(form.coverUrl)\"")); + assertTrue(rendered.contains("resolveResourceUrl(url)")); + assertTrue(rendered.contains("return \"/api\" + url")); + } + + @Test + public void qingIndexTemplateBuildsConfiguredRoutesWithoutSqlFragileRegexLiteral() + { + VelocityInitializer.initVelocity(); + String rendered = render("qing/index.vue.vm", VelocityUtils.prepareContext(renderTable())); + + assertTrue(rendered.contains("hasUnresolvedRouteParam(path)")); + assertFalse(rendered.contains("return /(^|\\/):[^/]+/.test(path) ? \"\" : path")); + assertFalse(rendered.contains("return /(^|/):[^/]+/.test(path) ? \"\" : path")); + } + @Test public void qingProjectTemplatesRenderWithSampleProject() { @@ -489,6 +675,229 @@ public class QingTemplateSupportTest assertTrue(router.contains("component: BookCatalogPage")); } + @Test + public void qingPortalAppRendersPageDesignerMenuHierarchy() + { + GenProject project = project(); + project.setProjectName("Library Portal"); + GenTable book = table(); + book.setTableName("book_info"); + book.setModuleName("library"); + book.setBusinessName("book"); + book.setClassName("Book"); + book.setFunctionName("Books"); + project.setTables(Arrays.asList(book)); + + FrontProjectPageDesign home = new FrontProjectPageDesign(); + home.setPageCode("home_page"); + home.setMenuCode("home_page"); + home.setPageName("Home"); + home.setRoutePath("/home"); + home.setPageScope("frontend"); + home.setPageType("list"); + home.setTableName("book_info"); + home.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"menuCode\":\"home_page\",\"menuName\":\"Home\",\"menuOrder\":1},\"regions\":[]}"); + + FrontProjectPageDesign directory = new FrontProjectPageDesign(); + directory.setPageCode("library_directory"); + directory.setMenuCode("library_directory"); + directory.setPageName("Library"); + directory.setPageScope("frontend"); + directory.setPageType("menu_directory"); + directory.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"nodeType\":\"directory\",\"menuCode\":\"library_directory\",\"menuName\":\"Library\",\"menuOrder\":2}}"); + + FrontProjectPageDesign books = new FrontProjectPageDesign(); + books.setPageCode("books_page"); + books.setMenuCode("library_directory"); + books.setPageName("Books"); + books.setRoutePath("/books"); + books.setPageScope("frontend"); + books.setPageType("list"); + books.setTableName("book_info"); + books.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"secondary\",\"parentMenuCode\":\"library_directory\",\"parentMenuName\":\"Library\",\"menuOrder\":1},\"regions\":[]}"); + + FrontProjectPageDesign notices = new FrontProjectPageDesign(); + notices.setPageCode("notices_page"); + notices.setMenuCode("notices_page"); + notices.setPageName("Notices"); + notices.setRoutePath("/notices"); + notices.setPageScope("frontend"); + notices.setPageType("list"); + notices.setTableName("book_info"); + notices.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"menuCode\":\"notices_page\",\"menuName\":\"Notices\",\"menuOrder\":3},\"regions\":[]}"); + project.setPageDesigns(Arrays.asList(home, directory, books, notices)); + + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContextProject(project); + + assertEquals(true, context.get("hasFrontendNavGroups")); + String app = render("qing/vue-app.vue.vm", context); + assertTrue(app.contains("class=\"portal-nav-group has-children\"")); + assertTrue(app.contains(":class='{ active: isNavGroupActive([\"/home\"]) }'")); + assertFalse(app.contains(":class=\"{ active: isNavGroupActive([\"")); + assertTrue(app.contains("portal-nav-directory")); + assertTrue(app.contains("class=\"portal-nav-dropdown\"")); + assertTrue(app.contains(">Library<")); + assertTrue(app.contains(">Books<")); + assertTrue(app.indexOf(">Home<") < app.indexOf(">Library<")); + assertTrue(app.indexOf(">Library<") < app.indexOf(">Notices<")); + + String router = render("qing/vue-router.js.vm", context); + assertFalse(router.contains("LibraryDirectory")); + assertTrue(router.contains("path: \"/books\"")); + assertTrue(router.contains("path: \"/notices\"")); + } + + @Test + public void qingPortalTreatsLegacyMenuCodeAsPrimaryMenu() + { + GenProject project = project(); + project.setProjectName("Library Portal"); + GenTable announcement = table(); + announcement.setTableName("announcement"); + announcement.setModuleName("library"); + announcement.setBusinessName("announcement"); + announcement.setClassName("Announcement"); + announcement.setFunctionName("Announcements"); + project.setTables(Arrays.asList(announcement)); + + FrontProjectPageDesign page = new FrontProjectPageDesign(); + page.setPageCode("announcement_list_page"); + page.setMenuCode("announcements"); + page.setPageName("\u516c\u544a\u5217\u8868"); + page.setRoutePath("/announcements"); + page.setPageScope("frontend"); + page.setPageType("list"); + page.setTableName("announcement"); + page.setLayoutJson("{\"canvas\":\"frontend-list-v1\",\"regions\":[]}"); + project.setPageDesigns(Arrays.asList(page)); + + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContextProject(project); + String app = render("qing/vue-app.vue.vm", context); + + assertTrue(app.contains("class=\"portal-nav-parent\" to=\"/announcements\" exact>\u516c\u544a\u5217\u8868")); + assertFalse(app.contains("portal-nav-directory\">announcements")); + } + + @SuppressWarnings("unchecked") + @Test + public void qingAdminContextUsesPageDesignerMenuHierarchy() + { + GenProject project = project(); + GenTable book = table(); + book.setTableName("book_info"); + book.setModuleName("library"); + book.setBusinessName("book"); + book.setClassName("Book"); + book.setFunctionName("Books"); + GenTable user = table(); + user.setTableName("user_info"); + user.setModuleName("system"); + user.setBusinessName("user"); + user.setClassName("User"); + user.setFunctionName("Users"); + project.setTables(Arrays.asList(book, user)); + + FrontProjectPageDesign directory = new FrontProjectPageDesign(); + directory.setPageCode("admin_library"); + directory.setMenuCode("admin_library"); + directory.setPageName("Library"); + directory.setPageScope("admin"); + directory.setPageType("menu_directory"); + directory.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"nodeType\":\"directory\",\"menuCode\":\"admin_library\",\"menuName\":\"Library\",\"menuOrder\":1}}"); + + FrontProjectPageDesign books = new FrontProjectPageDesign(); + books.setPageCode("admin_books"); + books.setMenuCode("admin_library"); + books.setPageName("Books"); + books.setRoutePath("/admin/books"); + books.setPageScope("admin"); + books.setPageType("list"); + books.setTableName("book_info"); + books.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"secondary\",\"parentMenuCode\":\"admin_library\",\"parentMenuName\":\"Library\",\"menuOrder\":1}}"); + + FrontProjectPageDesign users = new FrontProjectPageDesign(); + users.setPageCode("admin_users"); + users.setMenuCode("admin_users"); + users.setPageName("Users"); + users.setRoutePath("/admin/users"); + users.setPageScope("admin"); + users.setPageType("list"); + users.setTableName("user_info"); + users.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"menuCode\":\"admin_users\",\"menuName\":\"Users\",\"menuOrder\":2}}"); + project.setPageDesigns(Arrays.asList(directory, books, users)); + + VelocityContext context = VelocityUtils.prepareContextProject(project); + + assertEquals(true, context.get("hasAdminNavGroups")); + assertEquals("/admin/books", context.get("defaultAdminPath")); + List> groups = (List>) context.get("adminNavGroups"); + assertEquals(2, groups.size()); + assertEquals("Library", groups.get(0).get("name")); + assertEquals(true, groups.get(0).get("directory")); + assertEquals(true, groups.get(0).get("hasChildren")); + List> children = (List>) groups.get(0).get("children"); + assertEquals("Books", children.get(0).get("name")); + assertEquals("/admin/books", children.get(0).get("path")); + + List> routeItems = (List>) context.get("adminRouteItems"); + assertEquals(2, routeItems.size()); + assertEquals("/admin/books", routeItems.get(0).get("path")); + assertEquals("Book", routeItems.get(0).get("componentName")); + assertEquals("/admin/users", routeItems.get(1).get("path")); + } + + @SuppressWarnings("unchecked") + @Test + public void qingAdminContextRoutesBusinessBlockHomePageDesign() + { + GenProject project = project(); + FrontProjectPageDesign home = new FrontProjectPageDesign(); + home.setPageCode("admin_home"); + home.setMenuCode("admin_home"); + home.setPageName("Home"); + home.setRoutePath("/admin/home"); + home.setPageScope("admin"); + home.setPageType("business_block"); + home.setLayoutJson("{\"canvas\":\"business-blocks-v1\",\"blocks\":[],\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"menuCode\":\"admin_home\",\"menuName\":\"Home\",\"menuOrder\":1}}"); + project.setPageDesigns(Arrays.asList(home)); + + VelocityContext context = VelocityUtils.prepareContextProject(project); + + assertEquals("/admin/home", context.get("defaultAdminPath")); + List> routeItems = (List>) context.get("adminRouteItems"); + assertEquals(1, routeItems.size()); + assertEquals("/admin/home", routeItems.get(0).get("path")); + assertEquals("AdminHome", routeItems.get(0).get("componentName")); + assertEquals("@/views/pages/admin_home/index.vue", routeItems.get(0).get("componentPath")); + } + + @Test + public void qingPortalSkipsMenuDirectoryPageDesignsAsRoutes() + { + GenProject project = project(); + FrontProjectPageDesign directory = new FrontProjectPageDesign(); + directory.setPageCode("library_directory"); + directory.setMenuCode("library_directory"); + directory.setPageName("Library"); + directory.setPageScope("frontend"); + directory.setPageType("menu_directory"); + directory.setLayoutJson("{\"navigation\":{\"showInMenu\":true,\"menuLevel\":\"primary\",\"nodeType\":\"directory\",\"menuCode\":\"library_directory\",\"menuName\":\"Library\"}}"); + project.setPageDesigns(Arrays.asList(directory)); + + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContextProject(project); + + String app = render("qing/vue-app.vue.vm", context); + assertTrue(app.contains("Library")); + assertTrue(app.contains("portal-nav-directory")); + + String router = render("qing/vue-router.js.vm", context); + assertFalse(router.contains("LibraryDirectory")); + assertFalse(router.contains("library_directory")); + } + @Test public void qingIndexTemplateUsesFrontendPageDesignFields() { @@ -546,6 +955,99 @@ public class QingTemplateSupportTest assertTrue(rendered.contains(":disabled=\"viewing\"")); } + @Test + public void qingIndexTemplateRoutesConfiguredViewActionToAssociatedDetailPage() + { + GenTable table = renderTable(); + GenProject project = project(); + project.setTables(Arrays.asList(table)); + FrontProjectPageDesign listDesign = new FrontProjectPageDesign(); + listDesign.setPageCode("product_page"); + listDesign.setTableName("demo_product"); + listDesign.setRoutePath("/products"); + listDesign.setLayoutJson("{\"regions\":[" + + "{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"productName\"],\"rowActions\":[\"view\"]}," + + "{\"id\":\"form\",\"type\":\"dialogForm\",\"fields\":[\"productName\"]}" + + "],\"interactions\":{\"actions\":{\"table:view\":{\"type\":\"route\",\"targetPageCode\":\"product_detail_page\",\"paramMap\":{\"id\":\"productId\"},\"fallback\":\"dialog\"}}}}"); + FrontProjectPageDesign detailDesign = new FrontProjectPageDesign(); + detailDesign.setPageCode("product_detail_page"); + detailDesign.setTableName("demo_product"); + detailDesign.setPageType("detail"); + detailDesign.setRoutePath("/products/:id"); + detailDesign.setLayoutJson("{\"navigation\":{\"showInMenu\":false,\"parentPageCode\":\"product_page\"}}"); + project.setPageDesigns(Arrays.asList(listDesign, detailDesign)); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + VelocityUtils.putPageDesignVelocityContext(context, project, table, "frontend", "product_page"); + + String rendered = render("qing/index.vue.vm", context); + + assertTrue(rendered.contains("\"table:view\": {")); + assertTrue(rendered.contains("path: \"/products/:id\"")); + assertTrue(rendered.contains("paramMap: {\"id\":\"productId\"}")); + assertTrue(rendered.contains("if (this.handleConfiguredRoute(\"table:view\", row))")); + assertTrue(rendered.contains("this.$router.push(path)")); + } + + @Test + public void qingIndexTemplateRepairsLegacyDetailRouteAndInfersViewParamMap() + { + GenTable table = renderTable(); + GenProject project = project(); + project.setTables(Arrays.asList(table)); + FrontProjectPageDesign listDesign = new FrontProjectPageDesign(); + listDesign.setPageCode("product_page"); + listDesign.setTableName("demo_product"); + listDesign.setRoutePath("/products"); + listDesign.setLayoutJson("{\"regions\":[" + + "{\"id\":\"table\",\"type\":\"table\",\"fields\":[\"productName\"],\"rowActions\":[\"view\"]}" + + "],\"interactions\":{\"actions\":{\"table:view\":{\"type\":\"route\",\"targetPageCode\":\"product_detail_page\",\"paramMap\":{}}}}}"); + FrontProjectPageDesign detailDesign = new FrontProjectPageDesign(); + detailDesign.setPageCode("product_detail_page"); + detailDesign.setTableName("demo_product"); + detailDesign.setPageType("detail"); + detailDesign.setRoutePath("/products/-id"); + detailDesign.setLayoutJson("{\"navigation\":{\"showInMenu\":false,\"parentPageCode\":\"product_page\"}}"); + project.setPageDesigns(Arrays.asList(listDesign, detailDesign)); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + VelocityUtils.putPageDesignVelocityContext(context, project, table, "frontend", "product_page"); + + String rendered = render("qing/index.vue.vm", context); + + assertTrue(rendered.contains("path: \"/products/:id\"")); + assertTrue(rendered.contains("paramMap: {\"id\":\"productId\"}")); + assertFalse(rendered.contains("path: \"/products/-id\"")); + } + + @Test + public void qingIndexTemplateRendersDetailPageAsSingleRecord() + { + GenTable table = renderTable(); + GenProject project = project(); + project.setTables(Arrays.asList(table)); + FrontProjectPageDesign detailDesign = new FrontProjectPageDesign(); + detailDesign.setPageCode("product_detail_page"); + detailDesign.setTableName("demo_product"); + detailDesign.setPageType("detail"); + detailDesign.setRoutePath("/products/:id"); + detailDesign.setLayoutJson("{\"navigation\":{\"showInMenu\":false,\"parentPageCode\":\"product_page\"}}"); + project.setPageDesigns(Arrays.asList(detailDesign)); + VelocityInitializer.initVelocity(); + VelocityContext context = VelocityUtils.prepareContext(table); + VelocityUtils.putPageDesignVelocityContext(context, project, table, "frontend", "product_detail_page"); + + String rendered = render("qing/index.vue.vm", context); + + assertTrue(rendered.contains("detailRecord: {}")); + assertTrue(rendered.contains("loadDetail()")); + assertTrue(rendered.contains("const detailId = this.resolveDetailId()")); + assertTrue(rendered.contains("this.request.get(\"/demo/product/\" + detailId)")); + assertTrue(rendered.contains("{{ formatDetailValue(detailRecord.productName) }}")); + assertFalse(rendered.contains(" values = new LinkedHashMap(); + values.put("product_id", "${param.product_id}"); + values.put("user_id", "${current_user.id}"); + values.put("due_date", "${param.due_date}"); + effect.setValues(values); + action.setEffects(Arrays.asList(effect)); + return action; + } + private Path repoRoot() { Path cwd = Paths.get("").toAbsolutePath(); @@ -979,6 +1604,15 @@ public class QingTemplateSupportTest return text.replace("\r\n", "\n"); } + private void assertSeedContainsBundledTemplate(String sql, Path resourceRoot, String resourceName, + String databaseFileName) throws Exception + { + String content = normalizeNewlines(read(resourceRoot.resolve(resourceName))).replace("'", "''"); + String recordFragment = "'" + databaseFileName + "', NULL, '" + databaseFileName + "', '" + content + + "', 'admin', sysdate())"; + assertTrue(databaseFileName + " should match the bundled Qing template", sql.contains(recordFragment)); + } + private void assertProjectStructureNodeNamesFitSchema(String sql) { Matcher matcher = Pattern.compile("^\\(\\d+, \\d+, '([^']*)', '", Pattern.MULTILINE).matcher(sql); diff --git a/RuoYi-Vue/sql/db.sql b/RuoYi-Vue/sql/db.sql index f99d46e..75abbef 100644 --- a/RuoYi-Vue/sql/db.sql +++ b/RuoYi-Vue/sql/db.sql @@ -791,6 +791,13 @@ create table gen_table ( function_author varchar(50) comment '生成功能作者', gen_type char(1) default '0' comment '生成代码方式(0zip压缩包 1自定义路径)', gen_path varchar(200) default '/' comment '生成路径(不填默认项目路径)', + create_table_sql longtext comment '建表SQL语句', + admin_menu_name varchar(100) default '' comment '后台菜单名称', + allow_add char(1) default '1' comment '是否允许新增(1是 0否)', + allow_edit char(1) default '1' comment '是否允许修改(1是 0否)', + allow_remove char(1) default '1' comment '是否允许删除(1是 0否)', + remove_type varchar(20) default 'physical' comment '删除方式(none不允许 logical逻辑删除 physical物理删除)', + operation_note varchar(500) default '' comment '操作能力说明', options varchar(1000) comment '其它生成选项', create_by varchar(64) default '' comment '创建者', create_time datetime comment '创建时间', @@ -1018,9 +1025,6 @@ commit; -- 代码生成扩展表 -- ---------------------------- --- 给若依原生 gen_table 补充建表 SQL 字段,GenTableMapper 会读取 create_table_sql -alter table gen_table add column create_table_sql longtext comment '建表SQL语句'; - -- ---------------------------- -- 1、项目表 -- ---------------------------- @@ -1267,6 +1271,8 @@ drop table if exists front_ai_quota_bucket; drop table if exists front_project_generation; drop table if exists front_project_column; drop table if exists front_project_table; +drop table if exists front_project_page_design; +drop table if exists front_project_module; drop table if exists front_project; drop table if exists front_user; @@ -1301,11 +1307,12 @@ create table front_project ( industry_template varchar(50) default '' comment '行业模板', front_framework varchar(50) default 'Vue3 + Element Plus' comment '前台框架', back_framework varchar(50) default 'Spring Boot + MyBatis' comment '后端框架', + frontend_enabled char(1) default '1' comment '是否生成前台前端(1是 0否)', generate_status char(1) default '0' comment '数据库生成状态(0未生成 1生成中 2已生成 3失败)', preview_status char(1) default '0' comment '预览状态(0未预览 1已预览 2失败)', - app_blueprint longtext comment '应用蓝图草稿JSON', + app_blueprint longtext comment '系统模块设计草稿JSON', er_diagram longtext comment 'ER图草稿JSON', - business_blueprint longtext comment '业务蓝图草稿JSON', + business_blueprint longtext comment '业务流程设计草稿JSON', status char(1) default '0' comment '状态(0正常 1停用)', create_time datetime default null comment '创建时间', update_time datetime default null comment '更新时间', @@ -1315,6 +1322,47 @@ create table front_project ( constraint fk_front_project_user foreign key (user_id) references front_user(user_id) ) engine=innodb auto_increment=100 default charset=utf8mb4 comment='前台项目草稿表'; +create table front_project_module ( + id bigint(20) not null auto_increment comment 'id', + project_id bigint(20) not null comment '前台项目ID', + module_id bigint(20) not null comment '功能模块ID', + status char(1) default '0' comment '状态(0正常 1停用)', + create_time datetime default null comment '创建时间', + update_time datetime default null comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (id), + unique key uk_front_project_module (project_id, module_id), + key idx_front_project_module_project_id (project_id), + key idx_front_project_module_module_id (module_id), + constraint fk_front_project_module_project foreign key (project_id) references front_project(project_id), + constraint fk_front_project_module_module foreign key (module_id) references sys_module(module_id) +) engine=innodb auto_increment=100 default charset=utf8mb4 comment='前台项目功能模块关系'; + +create table front_project_page_design ( + design_id bigint(20) not null auto_increment comment 'Page design ID', + project_id bigint(20) not null comment 'Project ID', + user_id bigint(20) not null comment 'Front user ID', + page_scope varchar(20) not null default 'frontend' comment 'Page scope: frontend/admin', + page_code varchar(100) not null comment 'Page code', + menu_code varchar(100) default '' comment 'Menu code', + page_name varchar(100) not null comment 'Page name', + route_path varchar(255) not null comment 'Route path', + page_type varchar(50) not null comment 'Page type', + table_name varchar(100) default '' comment 'Bound table name', + layout_json longtext comment 'Layout JSON', + action_json longtext comment 'Action JSON', + status char(1) default '0' comment 'Status', + version int default 1 comment 'Version', + create_time datetime default null comment 'Create time', + update_time datetime default null comment 'Update time', + remark varchar(500) default null comment 'Remark', + primary key (design_id), + unique key uk_front_project_page_design (project_id, page_scope, page_code), + key idx_front_page_design_menu (project_id, menu_code), + key idx_front_page_design_table (project_id, table_name), + constraint fk_front_page_design_project foreign key (project_id) references front_project(project_id) +) engine=innodb auto_increment=100 default charset=utf8mb4 comment='Frontend page design'; + create table front_project_table ( table_id bigint(20) not null auto_increment comment '表设计ID', project_id bigint(20) not null comment '项目ID', @@ -1325,6 +1373,12 @@ create table front_project_table ( module_name varchar(30) default '' comment '生成模块名', business_name varchar(30) default '' comment '生成业务名', function_name varchar(50) default '' comment '生成功能名', + admin_menu_name varchar(100) default '' comment '后台菜单名称', + allow_add char(1) default '1' comment '是否允许新增(1是 0否)', + allow_edit char(1) default '1' comment '是否允许修改(1是 0否)', + allow_remove char(1) default '1' comment '是否允许删除(1是 0否)', + remove_type varchar(20) default 'physical' comment '删除方式(none不允许 logical逻辑删除 physical物理删除)', + operation_note varchar(500) default '' comment '操作能力说明', tpl_category varchar(200) default 'crud' comment '模板类型', tpl_web_type varchar(30) default 'element-plus' comment '前端模板类型', options varchar(1000) default '' comment '生成选项', @@ -1563,7 +1617,6 @@ values SET FOREIGN_KEY_CHECKS = 1; --- EasyCode business blueprint templates -- Business blueprint Velocity templates -- Import this script after sql/db.sql. -- Template ids: @@ -1590,37 +1643,88 @@ INSERT INTO sys_template_file (template_file_id, template_id, file_name, module_ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.5.15 + + + ${packageName} ${projectFileName}-backend 1.0.0 ${projectName} Generated backend with business blueprint actions - - com.ruoyi - ruoyi - 3.8.8 - + + 1.8 + 3.8.8 + com.ruoyi ruoyi-common + ${ruoyi.version} com.ruoyi ruoyi-framework + ${ruoyi.version} + + + mysql + mysql-connector-java + runtime + + + + + org.springframework.boot + spring-boot-maven-plugin + + + ', 'admin', sysdate()), (910102, 9101, 'application.yml.vm', NULL, 'application.yml.vm', -'server: - port: 8080 +'#set($d = ''$'') +server: + port: ${d}{SERVER_PORT:8080} spring: application: name: ${projectFileName}-backend + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${d}{DB_URL:jdbc:mysql://localhost:3306/vip?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8} + username: ${d}{DB_USERNAME:root} + password: ${d}{DB_PASSWORD:123456} + +mybatis: + typeAliasesPackage: ${packageName}.domain + mapperLocations: classpath:mapper/**/*.xml +', 'admin', sysdate()), +(910110, 9101, 'Application.java.vm', NULL, 'Application.java.vm', +'package ${packageName}; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +@MapperScan("${packageName}.mapper") +@SpringBootApplication(exclude = { SecurityAutoConfiguration.class }) +public class ${projectClassName}Application +{ + public static void main(String[] args) + { + SpringApplication.run(${projectClassName}Application.class, args); + } +} ', 'admin', sysdate()), (910103, 9101, 'README.md.vm', NULL, 'README.md.vm', '# ${projectName} Backend @@ -1937,7 +2041,7 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service public int ${action.code}(Map params) { if (params == null) { - throw new ServiceException("业务动作参数不能为空"); + throw new ServiceException("涓氬姟鍔ㄤ綔鍙傛暟涓嶈兘涓虹┖"); } #if($action.requestFields) #foreach($field in $action.requestFields) @@ -1967,9 +2071,6 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service #foreach($effect in $action.effects) #if($effect.type == "UPDATE_FIELD") #set($conditionFields = $effect.conditionFields) -#if(!$conditionFields || $conditionFields.size() == 0) -#set($conditionFields = $action.requestFields) -#end rows += executeUpdateFieldEffect(params, "${effect.targetTable}", "${effect.targetField}", "${effect.value}", new String[] {#foreach($field in $conditionFields)"${field}"#if($foreach.hasNext), #end#end}); #elseif($effect.type == "INSERT_ROW") #set($effectIndex = $foreach.index) @@ -2007,7 +2108,7 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service private void requireBusinessParam(Map params, String field) { if (!params.containsKey(field) || params.get(field) == null) { - throw new ServiceException("缺少业务动作参数:" + field); + throw new ServiceException("缂哄皯涓氬姟鍔ㄤ綔鍙傛暟锛? + field); } } @@ -2151,8 +2252,8 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service private int executeUpdateFieldEffect(Map params, String tableName, String targetField, String valueExpression, String[] conditionFields) { - String safeTableName = requireSafeIdentifier(tableName, "业务影响目标表不合法"); - String safeTargetField = requireSafeIdentifier(targetField, "业务影响目标字段不合法"); + String safeTableName = requireSafeIdentifier(tableName, "涓氬姟褰卞搷鐩爣琛ㄤ笉鍚堟硶"); + String safeTargetField = requireSafeIdentifier(targetField, "涓氬姟褰卞搷鐩爣瀛楁涓嶅悎娉?); String safeValueExpression = requireSafeExpression(valueExpression); String whereClause = buildWhereClause(params, conditionFields); Object[] values = conditionValues(params, conditionFields); @@ -2163,11 +2264,11 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service private String buildWhereClause(Map params, String[] conditionFields) { if (conditionFields == null || conditionFields.length == 0) { - throw new ServiceException("业务影响缺少条件字段"); + throw new ServiceException("涓氬姟褰卞搷缂哄皯鏉′欢瀛楁"); } StringBuilder where = new StringBuilder(); for (String field : conditionFields) { - String safeField = requireSafeIdentifier(field, "业务影响条件字段不合法"); + String safeField = requireSafeIdentifier(field, "涓氬姟褰卞搷鏉′欢瀛楁涓嶅悎娉?); requireBusinessParam(params, safeField); if (where.length() > 0) { where.append(" and "); @@ -2260,7 +2361,7 @@ public class ${ClassName}ServiceImpl implements I${ClassName}Service { if (valueExpression == null || !valueExpression.matches("[A-Za-z0-9_ +*/().-]{1,200}") || valueExpression.contains(";") || valueExpression.contains("--")) { - throw new ServiceException("业务影响字段表达式不合法"); + throw new ServiceException("涓氬姟褰卞搷瀛楁琛ㄨ揪寮忎笉鍚堟硶"); } return valueExpression; } @@ -2387,14 +2488,22 @@ INSERT INTO sys_template_file (template_file_id, template_id, file_name, module_ "version": "1.0.0", "private": true, "scripts": { - "dev": "vue-cli-service serve", + "dev": "vue-cli-service serve --host 0.0.0.0", + "serve": "vue-cli-service serve --host 0.0.0.0", "build": "vue-cli-service build" }, "dependencies": { + "axios": "1.7.2", + "core-js": "3.37.1", "element-ui": "2.15.14", "vue": "2.6.14", "vue-router": "3.6.5", "vuex": "3.6.2" + }, + "devDependencies": { + "@vue/cli-plugin-babel": "5.0.8", + "@vue/cli-service": "5.0.8", + "vue-template-compiler": "2.6.14" } } ', 'admin', sysdate()), @@ -2467,7 +2576,7 @@ export function ${action.code}(data) { @@ -2475,14 +2584,14 @@ export function ${action.code}(data) { #end #end - 搜索 - 重置 + 鎼滅储 + 閲嶇疆 - 新增 + 鏂板 @@ -2493,13 +2602,13 @@ export function ${action.code}(data) { #end #end - +