Skip to content

Commit 611085d

Browse files
pythonHuangclaude
andcommitted
feat: 数据视图与报表自定义SQL数据集增加辅助SQL生成(表/视图/存储过程浏览)+SQL测试(参数绑定/前100行/单表中文注释)+字段中文对照自动生成(字段=中文注释)+共享组件复用
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent b5ab040 commit 611085d

10 files changed

Lines changed: 594 additions & 21 deletions

File tree

‎Juggle.Api/Controllers/Api/DataViewController.cs‎

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ public class DataViewController : ControllerBase
1616
{
1717
private readonly JuggleDbContext _db;
1818
private readonly ReportExecutionService _reportExec;
19+
private readonly DataSourceService _dsService;
1920

20-
public DataViewController(JuggleDbContext db, ReportExecutionService reportExec)
21+
public DataViewController(JuggleDbContext db, ReportExecutionService reportExec, DataSourceService dsService)
2122
{
22-
_db = db; _reportExec = reportExec;
23+
_db = db; _reportExec = reportExec; _dsService = dsService;
2324
}
2425

2526
[HttpPost("page")]
@@ -49,7 +50,8 @@ public async Task<ApiResult> Update([FromBody] DataViewEntity entity)
4950
if (dv == null) return ApiResult.Fail("数据视图不存在");
5051
dv.Name = entity.Name; dv.GroupName = entity.GroupName;
5152
dv.DataSourceId = entity.DataSourceId; dv.Sql = entity.Sql;
52-
dv.Parameters = entity.Parameters; dv.Remark = entity.Remark;
53+
dv.Parameters = entity.Parameters; dv.ColumnMapping = entity.ColumnMapping;
54+
dv.Remark = entity.Remark;
5355
dv.Status = entity.Status;
5456
dv.UpdatedAt = DateTime.Now.ToString("o");
5557
await _db.SaveChangesAsync();
@@ -96,6 +98,67 @@ public async Task<ApiResult> Preview([FromBody] DataViewPreviewRequest req)
9698
}
9799
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
98100
}
101+
102+
/// <summary>
103+
/// 单独测试 SQL(与数据库节点类似):参数绑定执行,返回前 100 行预览;
104+
/// 单表查询时列附带中文注释。
105+
/// </summary>
106+
[HttpPost("test-sql")]
107+
public async Task<ApiResult> TestSql([FromBody] DataViewTestSqlRequest req)
108+
{
109+
try
110+
{
111+
var (dt, total) = await _reportExec.ExecuteQueryLimitedAsync(req.DataSourceId, req.Sql ?? "", req.Params, 100);
112+
113+
var colNames = dt.Columns.Cast<System.Data.DataColumn>().Select(c => c.ColumnName).ToList();
114+
var columns = new List<DbResultColumn>();
115+
116+
// 单表查询(无 JOIN 且能解析出表名)时附带中文注释
117+
var tableMatch = System.Text.RegularExpressions.Regex.Match(req.Sql ?? "", @"\bfrom\s+([A-Za-z0-9_\.]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
118+
var hasJoin = System.Text.RegularExpressions.Regex.IsMatch(req.Sql ?? "", @"\bjoin\b", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
119+
if (tableMatch.Success && !hasJoin)
120+
{
121+
try
122+
{
123+
var ds = await _db.DataSources.FindAsync(req.DataSourceId);
124+
if (ds?.DsName != null)
125+
{
126+
var meta = await _dsService.GetColumnsAsync(ds.DsName, tableMatch.Groups[1].Value);
127+
var commentMap = meta.ToDictionary(c => c.Name, c => c.Comment, StringComparer.OrdinalIgnoreCase);
128+
foreach (var name in colNames)
129+
columns.Add(new DbResultColumn
130+
{
131+
Name = name,
132+
Comment = commentMap.TryGetValue(name, out var comment) ? comment : ""
133+
});
134+
return BuildTestResult(columns, dt, total);
135+
}
136+
}
137+
catch { /* 注释获取失败不影响测试结果 */ }
138+
}
139+
140+
foreach (var name in colNames)
141+
columns.Add(new DbResultColumn { Name = name });
142+
return BuildTestResult(columns, dt, total);
143+
}
144+
catch (Exception ex)
145+
{
146+
return ApiResult.Fail(ex.Message);
147+
}
148+
}
149+
150+
private static ApiResult BuildTestResult(List<DbResultColumn> columns, System.Data.DataTable dt, int total)
151+
{
152+
var rows = new List<Dictionary<string, object?>>();
153+
foreach (System.Data.DataRow row in dt.Rows)
154+
{
155+
var dict = new Dictionary<string, object?>();
156+
foreach (System.Data.DataColumn col in dt.Columns)
157+
dict[col.ColumnName] = row[col];
158+
rows.Add(dict);
159+
}
160+
return ApiResult.Success(new { columns, rows, rowCount = total, truncated = total > 100 });
161+
}
99162
}
100163

101164
public class DataViewPreviewRequest
@@ -105,3 +168,10 @@ public class DataViewPreviewRequest
105168
public string? Sql { get; set; }
106169
public Dictionary<string, object?>? Params { get; set; }
107170
}
171+
172+
public class DataViewTestSqlRequest
173+
{
174+
public long DataSourceId { get; set; }
175+
public string? Sql { get; set; }
176+
public Dictionary<string, object?>? Params { get; set; }
177+
}

‎Juggle.Api/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ await ctx.Response.WriteAsync(
244244
// 报表模块表
245245
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_data_view(id INTEGER PRIMARY KEY AUTOINCREMENT, group_name TEXT, name TEXT, data_source_id INTEGER, sql TEXT, parameters TEXT, remark TEXT, status INTEGER DEFAULT 1, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
246246
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_report(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, group_name TEXT, source_type TEXT, source_ref TEXT, custom_sql TEXT, params_config TEXT, layout_json TEXT, status INTEGER DEFAULT 1, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
247+
// 数据视图字段中文对照
248+
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_data_view ADD COLUMN column_mapping TEXT DEFAULT NULL;"); } catch { }
247249
// 补建参数位置字段
248250
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_parameter ADD COLUMN param_position TEXT DEFAULT NULL;"); } catch { }
249251
}

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ public class ReportExecutionService
1818

1919
/// <summary>执行数据源的SQL查询返回DataTable</summary>
2020
public async Task<DataTable> ExecuteQuery(long dataSourceId, string sql, Dictionary<string, object?>? parameters = null)
21+
=> (await ExecuteQueryInternal(dataSourceId, sql, parameters, 0)).Table;
22+
23+
/// <summary>执行查询并限制返回行数(SQL 测试用),通过 Total 返回总行数</summary>
24+
public async Task<(DataTable Table, int Total)> ExecuteQueryLimitedAsync(long dataSourceId, string sql, Dictionary<string, object?>? parameters, int maxRows)
25+
=> await ExecuteQueryInternal(dataSourceId, sql, parameters, maxRows);
26+
27+
private async Task<(DataTable Table, int Total)> ExecuteQueryInternal(long dataSourceId, string sql, Dictionary<string, object?>? parameters, int maxRows)
2128
{
2229
ValidateSql(sql);
2330
var ds = await _db.DataSources.FindAsync(dataSourceId)
@@ -34,8 +41,25 @@ public async Task<DataTable> ExecuteQuery(long dataSourceId, string sql, Diction
3441

3542
var dt = new DataTable();
3643
using var reader = cmd.ExecuteReader();
37-
dt.Load(reader);
38-
return dt;
44+
if (maxRows <= 0)
45+
{
46+
dt.Load(reader);
47+
return (dt, dt.Rows.Count);
48+
}
49+
50+
// 受限读取:统计总行数,只保留前 maxRows 行
51+
for (int i = 0; i < reader.FieldCount; i++)
52+
dt.Columns.Add(reader.GetName(i), reader.GetFieldType(i) ?? typeof(object));
53+
var values = new object[reader.FieldCount];
54+
var total = 0;
55+
while (reader.Read())
56+
{
57+
total++;
58+
if (dt.Rows.Count >= maxRows) continue;
59+
reader.GetValues(values);
60+
dt.Rows.Add(values.Select(v => v == DBNull.Value ? (object?)null : v).ToArray());
61+
}
62+
return (dt, total);
3963
}
4064

4165
/// <summary>SQL 安全校验:禁止写操作关键字</summary>

‎Juggle.Domain/Entities/DataViewEntity.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ public class DataViewEntity : BaseEntity
77
public long DataSourceId { get; set; }
88
public string? Sql { get; set; }
99
public string? Parameters { get; set; } // JSON: [{name,type,default,label}]
10+
11+
/// <summary>SQL 字段中文对照(多行文本,每行一条:字段=中文注释)</summary>
12+
public string? ColumnMapping { get; set; }
13+
1014
public string? Remark { get; set; }
1115
public int Status { get; set; } = 1;
1216
}

‎Juggle.Infrastructure/Persistence/JuggleDbContext.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
104104
e.Property(p => p.DataSourceId).HasColumnName("data_source_id");
105105
e.Property(p => p.Sql).HasColumnName("sql");
106106
e.Property(p => p.Parameters).HasColumnName("parameters");
107+
e.Property(p => p.ColumnMapping).HasColumnName("column_mapping");
107108
e.Property(p => p.Remark).HasColumnName("remark");
108109
e.Property(p => p.Status).HasColumnName("status");
109110
});
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<template>
2+
<el-dialog :model-value="visible" @update:model-value="onVisibleChange" title="🗄 数据库对象(辅助生成 SQL)" width="1000px" append-to-body>
3+
<div style="display:flex;gap:10px">
4+
<div style="flex:1;min-width:0">
5+
<el-radio-group v-model="tab" size="small" @change="onTabChange">
6+
<el-radio-button value="tables">表 ({{ tables.length }})</el-radio-button>
7+
<el-radio-button value="views">视图 ({{ views.length }})</el-radio-button>
8+
<el-radio-button value="procedures">存储过程 ({{ procedures.length }})</el-radio-button>
9+
</el-radio-group>
10+
<el-input v-model="search" placeholder="搜索名称" size="small" clearable style="margin:8px 0" />
11+
<div class="db-object-list" v-loading="loading">
12+
<div v-for="obj in filteredObjects" :key="obj.name" class="db-object-item"
13+
:class="{ active: selectedObject === obj.name }" @click="selectObject(obj.name)">
14+
<span class="db-object-name">{{ obj.name }}</span>
15+
<el-button size="small" link type="primary" @click.stop="generateFromObject(obj)">生成SQL</el-button>
16+
</div>
17+
<el-empty v-if="!loading && filteredObjects.length === 0" description="暂无对象" :image-size="40" />
18+
</div>
19+
</div>
20+
<div style="width:480px;flex-shrink:0">
21+
<div class="prop-section-title" style="margin-top:0">
22+
{{ tab === 'procedures' ? '参数列表' : '字段列表' }}
23+
<span v-if="selectedObject" style="font-size:11px;color:#909399;font-weight:normal;margin-left:6px">{{ selectedObject }}</span>
24+
<el-button v-if="tab === 'procedures'" size="small" icon="VideoPlay" type="primary" link @click="generateCallFromSelected" style="margin-left:auto">生成调用语句</el-button>
25+
</div>
26+
27+
<!-- 表:字段名称/中文注释/类型/默认值/是否必填 -->
28+
<el-table v-if="tab === 'tables'" :data="columns" size="small" border max-height="380" v-loading="panelLoading">
29+
<el-table-column prop="name" label="字段名称" min-width="120" show-overflow-tooltip />
30+
<el-table-column prop="comment" label="中文注释" min-width="110" show-overflow-tooltip>
31+
<template #default="{ row }"><span style="color:#606266">{{ row.comment || '—' }}</span></template>
32+
</el-table-column>
33+
<el-table-column prop="dataType" label="类型" width="110" show-overflow-tooltip />
34+
<el-table-column prop="defaultValue" label="默认值" width="90" show-overflow-tooltip>
35+
<template #default="{ row }"><span style="color:#909399">{{ row.defaultValue ?? '—' }}</span></template>
36+
</el-table-column>
37+
<el-table-column label="必填" width="60" align="center">
38+
<template #default="{ row }"><el-tag size="small" :type="row.isNullable === false ? 'danger' : 'info'">{{ row.isNullable === false ? '是' : '否' }}</el-tag></template>
39+
</el-table-column>
40+
</el-table>
41+
42+
<!-- 视图:字段名称/中文注释/类型 -->
43+
<el-table v-else-if="tab === 'views'" :data="columns" size="small" border max-height="380" v-loading="panelLoading">
44+
<el-table-column prop="name" label="字段名称" min-width="140" show-overflow-tooltip />
45+
<el-table-column prop="comment" label="中文注释" min-width="140" show-overflow-tooltip>
46+
<template #default="{ row }"><span style="color:#606266">{{ row.comment || '—' }}</span></template>
47+
</el-table-column>
48+
<el-table-column prop="dataType" label="类型" width="130" show-overflow-tooltip />
49+
</el-table>
50+
51+
<!-- 存储过程:参数列表(当前值可编辑) -->
52+
<el-table v-else :data="procParams" size="small" border max-height="380" v-loading="panelLoading">
53+
<el-table-column prop="name" label="参数名" min-width="110" show-overflow-tooltip />
54+
<el-table-column prop="dataType" label="参数类型" width="100" show-overflow-tooltip />
55+
<el-table-column prop="mode" label="模式" width="70" align="center">
56+
<template #default="{ row }"><el-tag size="small" :type="(row.mode || 'IN').toUpperCase().includes('OUT') ? 'warning' : 'primary'">{{ row.mode || 'IN' }}</el-tag></template>
57+
</el-table-column>
58+
<el-table-column prop="defaultValue" label="默认值" width="80" show-overflow-tooltip>
59+
<template #default="{ row }"><span style="color:#909399">{{ row.defaultValue ?? '—' }}</span></template>
60+
</el-table-column>
61+
<el-table-column label="当前值" min-width="140">
62+
<template #default="{ row }">
63+
<el-input v-model="row.currentValue" size="small" placeholder="如: 1 或 '张三'" :disabled="(row.mode || '').toUpperCase().includes('OUT')" />
64+
</template>
65+
</el-table-column>
66+
</el-table>
67+
<div v-if="tab === 'procedures'" class="ch-note" style="margin-top:6px">
68+
当前值填实参值(字符串请带引号),留空生成 NULL;OUT/INOUT 参数不参与调用。
69+
</div>
70+
<el-empty v-if="tab !== 'procedures' && !panelLoading && columns.length === 0"
71+
description="点击左侧表/视图查看字段" :image-size="40" />
72+
</div>
73+
</div>
74+
</el-dialog>
75+
</template>
76+
77+
<script setup lang="ts">
78+
import { ref, computed, watch } from 'vue'
79+
import { ElMessage } from 'element-plus'
80+
import request from '../utils/request'
81+
82+
const props = defineProps<{
83+
visible: boolean
84+
dataSourceName: string
85+
dataSourceType: string
86+
}>()
87+
const emit = defineEmits<{ (e: 'update:visible', v: boolean): void; (e: 'generated', sql: string): void }>()
88+
89+
function onVisibleChange(v: boolean) { emit('update:visible', v) }
90+
91+
const tab = ref('tables')
92+
const search = ref('')
93+
const loading = ref(false)
94+
const panelLoading = ref(false)
95+
const tables = ref<any[]>([])
96+
const views = ref<any[]>([])
97+
const procedures = ref<any[]>([])
98+
const selectedObject = ref('')
99+
const columns = ref<any[]>([])
100+
const procParams = ref<any[]>([])
101+
102+
const filteredObjects = computed(() => {
103+
const list = tab.value === 'tables' ? tables.value : tab.value === 'views' ? views.value : procedures.value
104+
const kw = search.value.trim().toLowerCase()
105+
return kw ? list.filter((o: any) => (o.name || '').toLowerCase().includes(kw)) : list
106+
})
107+
108+
watch(() => props.visible, async v => {
109+
if (!v) return
110+
tab.value = 'tables'
111+
search.value = ''
112+
selectedObject.value = ''
113+
columns.value = []
114+
procParams.value = []
115+
tables.value = []; views.value = []; procedures.value = []
116+
loading.value = true
117+
try {
118+
const res: any = await request.post('/system/datasource/metadata', { dataSourceName: props.dataSourceName })
119+
tables.value = res.data?.tables || []
120+
views.value = res.data?.views || []
121+
procedures.value = res.data?.procedures || []
122+
} catch { /* 拦截器已提示 */ } finally { loading.value = false }
123+
})
124+
125+
function onTabChange() {
126+
selectedObject.value = ''
127+
columns.value = []
128+
procParams.value = []
129+
}
130+
131+
async function selectObject(name: string) {
132+
selectedObject.value = name
133+
if (tab.value === 'procedures') { await loadProcParams(name); return }
134+
await loadColumns(name)
135+
}
136+
137+
async function loadColumns(name: string) {
138+
panelLoading.value = true
139+
columns.value = []
140+
try {
141+
const res: any = await request.post('/system/datasource/columns', {
142+
dataSourceName: props.dataSourceName, tableName: name
143+
})
144+
columns.value = res.data || []
145+
} catch { columns.value = [] } finally { panelLoading.value = false }
146+
}
147+
148+
async function loadProcParams(name: string) {
149+
panelLoading.value = true
150+
procParams.value = []
151+
try {
152+
const res: any = await request.post('/system/datasource/procedure-params', {
153+
dataSourceName: props.dataSourceName, procName: name
154+
})
155+
procParams.value = (res.data || []).map((p: any) => ({ ...p, currentValue: p.defaultValue || '' }))
156+
} catch { procParams.value = [] } finally { panelLoading.value = false }
157+
}
158+
159+
function generateCallFromSelected() {
160+
const proc = procedures.value.find((p: any) => p.name === selectedObject.value)
161+
if (!proc) { ElMessage.warning('请先在左侧选择存储过程'); return }
162+
generateFromObject(proc)
163+
}
164+
165+
async function generateFromObject(obj: any) {
166+
selectedObject.value = obj.name
167+
const dsType = (props.dataSourceType || 'mysql').toLowerCase()
168+
let sql = ''
169+
if (tab.value === 'procedures') {
170+
if (!procParams.value.length) await loadProcParams(obj.name)
171+
const args = procParams.value
172+
.filter((p: any) => !(p.mode || '').toUpperCase().includes('OUT'))
173+
.map((p: any) => formatCallArg(p.currentValue))
174+
sql = dsType === 'sqlserver' || dsType === 'mssql' ? `EXEC ${obj.name} ${args.join(', ')}`
175+
: dsType === 'oracle' || dsType === 'dm' ? `BEGIN ${obj.name}(${args.join(', ')}); END;`
176+
: `CALL ${obj.name}(${args.join(', ')})`
177+
} else {
178+
await loadColumns(obj.name)
179+
const cols = columns.value.length ? columns.value.map((c: any) => c.name).join(', ') : '*'
180+
const from = `FROM ${obj.name}`
181+
if (dsType === 'sqlserver' || dsType === 'mssql') sql = `SELECT TOP 100 ${cols}\n${from}`
182+
else if (dsType === 'oracle' || dsType === 'dm') sql = `SELECT ${cols}\n${from}\nWHERE ROWNUM <= 100`
183+
else sql = `SELECT ${cols}\n${from}\nLIMIT 100`
184+
}
185+
emit('generated', sql)
186+
emit('update:visible', false)
187+
ElMessage.success('已生成 SQL')
188+
}
189+
190+
function formatCallArg(v: string): string {
191+
const val = (v || '').trim()
192+
if (!val) return 'NULL'
193+
if (/^-?\d+(\.\d+)?$/.test(val) || val.toUpperCase() === 'NULL') return val
194+
return "'" + val.replace(/'/g, "''") + "'"
195+
}
196+
</script>
197+
198+
<style scoped>
199+
.db-object-list { max-height: 400px; overflow-y: auto; border: 1px solid #e4e7ed; border-radius: 6px; }
200+
.db-object-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; cursor: pointer; border-bottom: 1px solid #f0f2f5; }
201+
.db-object-item:last-child { border-bottom: none; }
202+
.db-object-item:hover { background: #f5f7fa; }
203+
.db-object-item.active { background: #ecf5ff; }
204+
.db-object-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
205+
.ch-note { color: #909399; font-size: 12px; }
206+
.prop-section-title { font-weight: 600; margin: 12px 0 4px; color: #303133; display: flex; align-items: center; }
207+
</style>

0 commit comments

Comments
 (0)