Skip to content

Commit 9e5b30c

Browse files
pythonHuangclaude
andcommitted
feat: 报表查询功能-设计器查询参数配置(文本/数字/日期/开关/下拉+默认值+URL参数覆盖)+数据集按查询参数过滤(未填绑NULL支持@p IS NULL判空)+正式报表访问页(/report/view/:id查询/导出Excel/PDF/打印)+菜单报表查询动态子菜单
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 58c277d commit 9e5b30c

9 files changed

Lines changed: 288 additions & 48 deletions

File tree

‎Juggle.Api/Controllers/Api/ReportController.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ public async Task<ApiResult> Preview([FromBody] ReportPreviewRequest req)
8080
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
8181
}
8282

83+
/// <summary>报表详情(正式访问页用:名称/参数配置等)</summary>
84+
[HttpGet("info/{id}")]
85+
public async Task<ApiResult> Info(long id)
86+
{
87+
var rpt = await _db.Set<ReportEntity>().FindAsync(id);
88+
if (rpt == null) return ApiResult.Fail("报表不存在");
89+
return ApiResult.Success(new { rpt.Id, rpt.Name, rpt.GroupName, rpt.ParamsConfig, rpt.Status });
90+
}
91+
8392
[HttpPost("export-pdf")]
8493
public async Task<IActionResult> ExportPdf([FromBody] ReportPreviewRequest req)
8594
{

‎Juggle.Application/Services/Impl/ReportExecutionService.cs‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,28 @@ public async Task<DataTable> ExecuteQuery(long dataSourceId, string sql, Diction
3939

4040
using var cmd = conn.CreateCommand();
4141
cmd.CommandText = sql;
42+
43+
// 查询参数绑定:SQL 中以 @参数名 引用的参数必须全部提供值;
44+
// 未提供/空字符串/空值统一绑定 NULL,配合 SQL 内 "(@p IS NULL OR 字段 = @p)" 判空实现"未填即不过滤"
45+
var sqlParamNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
46+
foreach (Match m in Regex.Matches(sql, @"@([A-Za-z_][A-Za-z0-9_]*)"))
47+
sqlParamNames.Add(m.Groups[1].Value);
48+
49+
var boundNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
4250
if (parameters != null)
43-
foreach (var p in parameters)
44-
cmd.Parameters.Add(CreateParameter(p.Key, p.Value, ds.DsType!));
51+
foreach (var (key, value) in parameters)
52+
{
53+
// JSON 反序列化的 JsonElement 转为原生类型(如前端传来的数字/布尔)
54+
var v = value is JsonElement je ? ToNativeValue(je) : value;
55+
if (v is string s && string.IsNullOrEmpty(s)) v = null;
56+
cmd.Parameters.Add(CreateParameter(key, v, ds.DsType!));
57+
boundNames.Add(key);
58+
}
59+
foreach (var name in sqlParamNames)
60+
{
61+
if (boundNames.Contains(name)) continue;
62+
cmd.Parameters.Add(CreateParameter(name, null, ds.DsType!));
63+
}
4564

4665
var dt = new DataTable();
4766
using var reader = cmd.ExecuteReader();
@@ -827,6 +846,20 @@ private static List<float> GetColWidths(JsonElement root, int maxCols)
827846
_ => throw new Exception($"Unsupported DB: {dsType}")
828847
};
829848

849+
/// <summary>JsonElement → 原生类型(查询参数绑定用)</summary>
850+
private static object? ToNativeValue(JsonElement je)
851+
{
852+
return je.ValueKind switch
853+
{
854+
JsonValueKind.String => je.GetString(),
855+
JsonValueKind.Number => je.TryGetInt64(out var l) ? l : je.GetDouble(),
856+
JsonValueKind.True => true,
857+
JsonValueKind.False => false,
858+
JsonValueKind.Null => null,
859+
_ => je.ToString()
860+
};
861+
}
862+
830863
private static IDbDataParameter CreateParameter(string name, object? value, string dsType)
831864
{
832865
return dsType switch
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<template>
2+
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
3+
<div v-for="p in params" :key="p.name" style="display:flex;flex-direction:column;gap:2px">
4+
<span style="font-size:12px;color:#666">{{ p.label || p.name }}</span>
5+
<!-- 文本 -->
6+
<el-input v-if="!p.type || p.type==='text'" v-model="values[p.name]" size="small" style="width:160px" clearable placeholder="请输入" />
7+
<!-- 数字 -->
8+
<el-input-number v-else-if="p.type==='number' || p.type==='int'" v-model="values[p.name]" size="small" style="width:160px" :controls="false" placeholder="请输入数字" />
9+
<!-- 日期 -->
10+
<el-date-picker v-else-if="p.type==='date'" v-model="values[p.name]" size="small" type="date" value-format="YYYY-MM-DD" style="width:160px" placeholder="选择日期" />
11+
<!-- 开关 -->
12+
<el-switch v-else-if="p.type==='switch'" v-model="values[p.name]" size="small" />
13+
<!-- 下拉 -->
14+
<el-select v-else-if="p.type==='select'" v-model="values[p.name]" size="small" style="width:160px" clearable placeholder="请选择">
15+
<el-option v-for="(o, i) in selectOptions(p)" :key="i" :label="o" :value="o" />
16+
</el-select>
17+
</div>
18+
</div>
19+
</template>
20+
21+
<script setup lang="ts">
22+
const props = defineProps<{
23+
params: Array<{ name: string; label?: string; type?: string; options?: string; default?: any }>
24+
values: Record<string, any>
25+
}>()
26+
27+
/** 下拉选项解析:支持逗号或换行分隔 */
28+
function selectOptions(p: any): string[] {
29+
if (Array.isArray(p.options)) return p.options
30+
return String(p.options || '').split(/[,,\n]/).map((s: string) => s.trim()).filter(Boolean)
31+
}
32+
</script>

‎JuggleNet6.Frontend/src/router/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const routes = [
3838
{ path: 'flow/testcase', component: () => import('../views/flow/FlowTestCase.vue') },
3939
{ path: 'report/dataview', component: () => import('../views/report/DataViewList.vue') },
4040
{ path: 'report/design', component: () => import('../views/report/ReportList.vue') },
41-
{ path: 'report/designer/:id', component: () => import('../views/report/ReportDesigner.vue') }
41+
{ path: 'report/designer/:id', component: () => import('../views/report/ReportDesigner.vue') },
42+
{ path: 'report/view/:id', component: () => import('../views/report/ReportView.vue') }
4243
]
4344
},
4445
{
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* 报表查询参数工具:默认值初始化(URL 参数值优先)与查询载荷构建(空值不传 → 可选过滤)。
3+
*/
4+
5+
/** 初始化查询参数值:默认值 → URL 查询参数覆盖 → 按类型转换 */
6+
export function initParamValues(params: any[], urlQuery?: Record<string, any>): Record<string, any> {
7+
const values: Record<string, any> = {}
8+
for (const p of params || []) {
9+
let v: any = p.default
10+
if (urlQuery && urlQuery[p.name] !== undefined && urlQuery[p.name] !== '') {
11+
v = urlQuery[p.name]
12+
}
13+
if (p.type === 'switch') v = v === true || v === 'true' || v === '1'
14+
else if (p.type === 'number') v = (v === '' || v === undefined || v === null) ? undefined : Number(v)
15+
values[p.name] = v ?? ''
16+
}
17+
return values
18+
}
19+
20+
/** 构建查询载荷:空值跳过(后端跳过绑定 → 未填即不过滤) */
21+
export function buildParamPayload(params: any[], values: Record<string, any>): Record<string, any> {
22+
const payload: Record<string, any> = {}
23+
for (const p of params || []) {
24+
let v = values[p.name]
25+
if (v === undefined || v === null || v === '') continue
26+
if (p.type === 'number') v = Number(v)
27+
if (p.type === 'switch') v = v === true
28+
payload[p.name] = v
29+
}
30+
return payload
31+
}

‎JuggleNet6.Frontend/src/views/Layout.vue‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
<el-menu-item index="/report/dataview">数据视图</el-menu-item>
4848
<el-menu-item index="/report/design">报表管理</el-menu-item>
4949
</el-sub-menu>
50+
<el-sub-menu index="report-view" v-if="reportMenuList.length > 0">
51+
<template #title>
52+
<el-icon><Document /></el-icon>
53+
<span>报表查询</span>
54+
</template>
55+
<el-menu-item v-for="r in reportMenuList" :key="r.id" :index="`/report/view/${r.id}`">{{ r.name }}</el-menu-item>
56+
</el-sub-menu>
5057
<el-sub-menu index="system" v-if="hasMenu('/system/token')">
5158
<template #title>
5259
<el-icon><Setting /></el-icon>
@@ -98,16 +105,26 @@
98105
</template>
99106

100107
<script setup lang="ts">
101-
import { computed, ref } from 'vue'
108+
import { computed, ref, onMounted } from 'vue'
102109
import { useRoute, useRouter } from 'vue-router'
103110
import { ElMessageBox } from 'element-plus'
104111
import { Connection, Grid, DataBoard, Setting, ArrowDown, Histogram, Document } from '@element-plus/icons-vue'
112+
import request from '../utils/request'
105113
106114
const route = useRoute()
107115
const router = useRouter()
108116
const userName = computed(() => localStorage.getItem('userName') || 'User')
109117
const activeMenu = computed(() => route.path)
110118
119+
// 报表查询菜单:每个启用报表一个子菜单
120+
const reportMenuList = ref<any[]>([])
121+
onMounted(async () => {
122+
try {
123+
const res = await request.post('/report/page', { pageNum: 1, pageSize: 200 })
124+
reportMenuList.value = (res.data?.list || []).filter((r: any) => r.status === 1)
125+
} catch { /* 忽略菜单加载失败 */ }
126+
})
127+
111128
// 权限菜单列表
112129
const menuKeys = ref<string[]>([])
113130
try {

‎JuggleNet6.Frontend/src/views/report/ReportDesigner.vue‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
<el-button size="small" @click="deleteCol">删列</el-button>
7676
<el-button size="small" :disabled="!canUndo" @click="undo" title="Ctrl+Z">↩</el-button>
7777
<el-button size="small" :disabled="!canRedo" @click="redo" title="Ctrl+Y">↪</el-button>
78+
<el-button size="small" type="primary" plain @click="paramDialogVisible = true">查询参数</el-button>
7879
<span style="font-size:11px;color:#888;margin-left:4px">Ctrl多选</span>
7980
</div>
8081
<div class="grid-wrapper" @scroll="onGridScroll">
@@ -362,10 +363,38 @@ NOW() / TODAY() / ROW() 当前时间 / 日期 / 行号</pre>
362363
</div>
363364
</el-dialog>
364365

366+
<!-- 查询参数配置 -->
367+
<el-dialog v-model="paramDialogVisible" title="查询参数配置" width="820px" append-to-body>
368+
<div v-for="(p, i) in previewParams" :key="i" style="display:flex;gap:6px;margin-bottom:6px;align-items:center">
369+
<el-input v-model="p.name" placeholder="参数名(如 beginDate)" size="small" style="width:150px;flex-shrink:0" />
370+
<el-input v-model="p.label" placeholder="显示名" size="small" style="width:110px;flex-shrink:0" />
371+
<el-select v-model="p.type" size="small" style="width:90px;flex-shrink:0">
372+
<el-option value="text" label="文本" />
373+
<el-option value="number" label="数字" />
374+
<el-option value="date" label="日期" />
375+
<el-option value="switch" label="开关" />
376+
<el-option value="select" label="下拉" />
377+
</el-select>
378+
<el-input v-if="p.type==='select'" v-model="p.options" placeholder="选项,逗号/换行分隔" size="small" style="width:180px;flex-shrink:0" />
379+
<el-input v-else-if="p.type!=='switch'" v-model="p.default" placeholder="默认值" size="small" style="width:110px;flex-shrink:0" />
380+
<el-switch v-else v-model="p.default" size="small" />
381+
<span v-if="p.type==='switch'" style="font-size:11px;color:#888;width:70px;flex-shrink:0">{{ p.default ? '默认开' : '默认关' }}</span>
382+
<el-button size="small" type="danger" link @click="previewParams.splice(i, 1)">删</el-button>
383+
</div>
384+
<el-button size="small" @click="previewParams.push({ name:'', label:'', type:'text', default:'' })">+添加参数</el-button>
385+
<div style="margin-top:8px;color:#909399;font-size:12px">
386+
参数类型:文本/数字/日期/开关/下拉(下拉需填选项,逗号或换行分隔)。默认值在正式访问页生效;访问 URL 带同参数名(如 ?beginDate=2026-01-01)时以 URL 值为准。<br>
387+
数据集 SQL 中用 <code>@参数名</code> 引用即可按查询条件过滤,推荐写成 <code>(@参数名 IS NULL OR 字段 = @参数名)</code> / <code>(@kw IS NULL OR 字段 LIKE '%' || @kw || '%')</code> — 未填值时该条件自动失效(不过滤)。
388+
</div>
389+
<template #footer>
390+
<el-button @click="paramDialogVisible=false">关闭</el-button>
391+
</template>
392+
</el-dialog>
393+
365394
<!-- 预览 -->
366395
<el-dialog v-model="showPreview" title="预览" width="90%" top="5vh">
367-
<div v-if="previewParams.length>0" style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
368-
<el-input v-for="p in previewParams" :key="p.name" v-model="previewValues[p.name]" size="small" style="width:160px" :placeholder="p.label||p.name" />
396+
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
397+
<ReportParams :params="previewParams" :values="previewValues" />
369398
<el-button size="small" type="primary" @click="doRender">查询</el-button>
370399
<el-button size="small" @click="doPrint">打印</el-button>
371400
</div>
@@ -392,7 +421,9 @@ import { Edit, View, Refresh, Loading, Close } from '@element-plus/icons-vue'
392421
import request from '../../utils/request'
393422
import DbObjectBrowser from '../../components/DbObjectBrowser.vue'
394423
import SqlTestDialog from '../../components/SqlTestDialog.vue'
424+
import ReportParams from '../../components/ReportParams.vue'
395425
import { generateColumnMapping } from '../../utils/dbAssist'
426+
import { initParamValues, buildParamPayload } from '../../utils/reportParams'
396427
397428
const route = useRoute()
398429
const router = useRouter()
@@ -442,6 +473,7 @@ const rowDataset = ref('') // 当前选中行绑定的数据
442473
const rowZebras = ref<Record<number, boolean>>({}) // 行号 → 斑马纹开关
443474
const rowZebra = ref(false) // 当前选中行的斑马纹
444475
const showPreview = ref(false), pageSettingsVisible = ref(false)
476+
const paramDialogVisible = ref(false)
445477
const formulaHelpVisible = ref(false)
446478
const previewHtml = ref(''), previewParams = ref<any[]>([])
447479
const previewValues = ref<Record<string,any>>({})
@@ -1061,11 +1093,26 @@ async function saveReport() { saveLayoutJson()
10611093
else { const res=await request.post('/report/add',form); form.id=res.data; ElMessage.success('已创建') }
10621094
}
10631095
1064-
function openPreview() { saveLayoutJson(); showPreview.value=true; doRender() }
1096+
function openPreview() {
1097+
saveLayoutJson()
1098+
previewValues.value = initParamValues(previewParams.value)
1099+
showPreview.value = true
1100+
doRender()
1101+
}
10651102
1066-
async function doRender() { try { const res=await request.post('/report/preview',{id:form.id,params:previewValues.value}); previewHtml.value=res.data?.html||'' } catch { ElMessage.error('预览失败') } }
1103+
async function doRender() {
1104+
try {
1105+
const res = await request.post('/report/preview', { id: form.id, params: buildParamPayload(previewParams.value, previewValues.value) })
1106+
previewHtml.value = res.data?.html || ''
1107+
} catch { ElMessage.error('预览失败') }
1108+
}
10671109
1068-
function doPrint() { window.print() }
1110+
function doPrint() {
1111+
const w = window.open('', '_blank')
1112+
if (!w || !previewHtml.value) { ElMessage.warning('请先预览再打印'); return }
1113+
w.document.write(`<html><head><title>${form.name}</title><meta charset="utf-8"><style>@media print{@page{size:A4;margin:15mm}}body{font-family:'Microsoft YaHei',sans-serif}</style></head><body>${previewHtml.value}</body></html>`)
1114+
w.document.close(); w.focus(); setTimeout(() => { w.print() }, 300)
1115+
}
10691116
10701117
function colLetter(n:number):string { return String.fromCharCode(65+n) }
10711118
</script>

‎JuggleNet6.Frontend/src/views/report/ReportList.vue‎

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,6 @@
3333
<el-pagination v-model:current-page="page" :page-size="20" layout="prev,next" :total="total" @change="loadData" style="margin-top:12px;justify-content:flex-end" />
3434
</el-card>
3535

36-
<el-dialog v-model="previewVisible" title="预览报表" width="90%" top="5vh">
37-
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center">
38-
<div v-for="p in previewParams" :key="p.name" style="display:flex;align-items:center;gap:4px">
39-
<span style="font-size:12px">{{ p.label||p.name }}:</span>
40-
<el-input v-model="previewValues[p.name]" size="small" style="width:140px" />
41-
</div>
42-
<el-button size="small" type="primary" :loading="previewLoading" @click="doPreview">查询</el-button>
43-
<span style="font-size:12px;color:#888;margin-left:auto">第 {{ previewPage }} 页 · 每页 {{ previewPageSize }} 行</span>
44-
<el-pagination small layout="prev,next" :total="previewTotal" v-model:current-page="previewPage" :page-size="previewPageSize" @change="doPreview" />
45-
</div>
46-
<div v-loading="previewLoading" v-if="previewHtml" v-html="previewHtml" style="border:1px solid #eee;padding:16px;overflow:auto;max-height:65vh"></div>
47-
<el-empty v-else-if="!previewLoading" description="暂无数据" :image-size="60" />
48-
</el-dialog>
4936
</div>
5037
</template>
5138

@@ -60,15 +47,6 @@ const loading = ref(false)
6047
const tableData = ref<any[]>([])
6148
const page = ref(1)
6249
const total = ref(0)
63-
const previewVisible = ref(false)
64-
const previewHtml = ref('')
65-
const previewLoading = ref(false)
66-
const previewParams = ref<any[]>([])
67-
const previewValues = ref<Record<string,any>>({})
68-
const previewId = ref(0)
69-
const previewPage = ref(1)
70-
const previewPageSize = ref(20)
71-
const previewTotal = ref(0)
7250
7351
onMounted(loadData)
7452
@@ -91,24 +69,10 @@ function openDesigner(id?: number) {
9169
else router.push('/report/designer/0')
9270
}
9371
72+
/** 预览:打开正式报表访问页(新窗口) */
9473
function openPreview(row: any) {
95-
previewId.value = row.id
96-
try { previewParams.value = JSON.parse(row.paramsConfig||'[]') } catch { previewParams.value = [] }
97-
previewValues.value = {}
98-
for (const p of previewParams.value) previewValues.value[p.name] = p.default || ''
99-
previewHtml.value = ''
100-
previewPage.value = 1
101-
previewVisible.value = true
102-
doPreview() // 打开即自动查询(无参数报表也能出数据)
103-
}
104-
105-
async function doPreview() {
106-
previewLoading.value = true
107-
try {
108-
const res = await request.post('/report/preview', { id: previewId.value, params: previewValues.value, page: previewPage.value, pageSize: previewPageSize.value })
109-
previewHtml.value = res.data?.html || ''
110-
previewTotal.value = res.data?.total || 0
111-
} finally { previewLoading.value = false }
74+
const w = window.open(`/report/view/${row.id}`, '_blank')
75+
if (!w) ElMessage.warning('浏览器拦截了弹出窗口,请允许后重试')
11276
}
11377
11478
async function doExportPdf(row: any) {

0 commit comments

Comments
 (0)