SHA256
663 lines
21 KiB
Dart
663 lines
21 KiB
Dart
part of 'studio_app.dart';
|
|
|
|
class StudioSqlPage extends StatefulWidget {
|
|
const StudioSqlPage({
|
|
super.key,
|
|
required this.tab,
|
|
required this.runtime,
|
|
required this.onAction,
|
|
});
|
|
|
|
final StudioTab tab;
|
|
final StudioRuntime runtime;
|
|
final void Function(String title, String detail) onAction;
|
|
|
|
@override
|
|
State<StudioSqlPage> createState() => _StudioSqlPageState();
|
|
}
|
|
|
|
class _StudioSqlPageState extends State<StudioSqlPage> {
|
|
late final TextEditingController _sqlController;
|
|
late final TextEditingController _limitController;
|
|
late final TextEditingController _countSqlController;
|
|
late final TextEditingController _summarySqlController;
|
|
late final TextEditingController _criteriaController;
|
|
late final TextEditingController _extConfigController;
|
|
bool _loading = true;
|
|
bool _templateLoaded = false;
|
|
String _status = '等待加载';
|
|
String _detail = '';
|
|
Map<String, List<String>> _templateIndex = <String, List<String>>{};
|
|
List<Map<String, dynamic>> _resultRows = <Map<String, dynamic>>[];
|
|
List<String> _resultColumns = <String>[];
|
|
String _selectedTidValue = '';
|
|
String _selectedOidValue = '';
|
|
|
|
bool get _isTemplateMode => widget.tab.pageType == StudioPageType.sqlTemplate;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_sqlController = TextEditingController(
|
|
text:
|
|
'select id, code, name from ttx_demo_table order by id desc limit 20;',
|
|
);
|
|
_limitController = TextEditingController(text: '100');
|
|
_countSqlController = TextEditingController();
|
|
_summarySqlController = TextEditingController();
|
|
_criteriaController = TextEditingController(text: '{}');
|
|
_extConfigController = TextEditingController(text: '{}');
|
|
if (_isTemplateMode) {
|
|
_loadTemplateIndex();
|
|
} else {
|
|
_loading = false;
|
|
_status = '可直接执行 SQL';
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sqlController.dispose();
|
|
_limitController.dispose();
|
|
_countSqlController.dispose();
|
|
_summarySqlController.dispose();
|
|
_criteriaController.dispose();
|
|
_extConfigController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadTemplateIndex() async {
|
|
try {
|
|
final items = await widget.runtime.client.fetchSqlTemplateIndex(
|
|
session: widget.runtime.session,
|
|
);
|
|
final index = <String, List<String>>{};
|
|
for (final item in items) {
|
|
final tid = _readValue(item, const ['tid']);
|
|
final oid = _readValue(item, const ['oid']);
|
|
if (tid.isEmpty || oid.isEmpty) {
|
|
continue;
|
|
}
|
|
index.putIfAbsent(tid, () => <String>[]).add(oid);
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_templateIndex = index;
|
|
_status = index.isEmpty ? '没有找到 SQL 模板' : '已加载模板索引';
|
|
_loading = false;
|
|
});
|
|
if (index.isNotEmpty) {
|
|
await _loadSelectedTemplate();
|
|
}
|
|
} catch (error, stackTrace) {
|
|
_printError('加载 SQL 模板索引失败', error, stackTrace);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_status = '加载模板失败';
|
|
_detail = _normalizeError(error);
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
String _selectedTid() {
|
|
if (_selectedTidValue.isNotEmpty) {
|
|
return _selectedTidValue;
|
|
}
|
|
if (_templateIndex.isEmpty) {
|
|
return '';
|
|
}
|
|
return _templateIndex.keys.first;
|
|
}
|
|
|
|
String _selectedOid([String? tid]) {
|
|
final selectedTid = tid ?? _selectedTid();
|
|
final oids = _templateIndex[selectedTid];
|
|
if (oids == null || oids.isEmpty) {
|
|
return '';
|
|
}
|
|
if (_selectedOidValue.isNotEmpty && oids.contains(_selectedOidValue)) {
|
|
return _selectedOidValue;
|
|
}
|
|
return oids.first;
|
|
}
|
|
|
|
Future<void> _loadSelectedTemplate() async {
|
|
final tid = _selectedTid();
|
|
final oid = _selectedOid(tid);
|
|
if (tid.isEmpty || oid.isEmpty) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_loading = true;
|
|
_status = '正在加载模板...';
|
|
});
|
|
try {
|
|
final template = await widget.runtime.client.fetchSqlTemplate(
|
|
tid: tid,
|
|
oid: oid,
|
|
session: widget.runtime.session,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
if (template == null) {
|
|
setState(() {
|
|
_templateLoaded = false;
|
|
_status = '模板不存在';
|
|
_detail = '$tid / $oid';
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_templateLoaded = true;
|
|
_sqlController.text = _readValue(template, const ['value', 'sql']);
|
|
_countSqlController.text = _readValue(template, const ['countSql', 'count']);
|
|
_summarySqlController.text = _readValue(template, const ['summarySql', 'summary']);
|
|
_criteriaController.text = _readValue(template, const ['criteria'], '{}');
|
|
_extConfigController.text = _readValue(template, const ['extConfig'], '{}');
|
|
_status = '模板已加载';
|
|
_detail = '$tid / $oid';
|
|
});
|
|
widget.onAction('加载模板', '$tid / $oid');
|
|
} catch (error, stackTrace) {
|
|
_printError('加载 SQL 模板失败', error, stackTrace);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_templateLoaded = false;
|
|
_status = '加载模板失败';
|
|
_detail = _normalizeError(error);
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _runSql() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_status = '正在执行 SQL...';
|
|
_detail = '';
|
|
_resultRows = <Map<String, dynamic>>[];
|
|
_resultColumns = <String>[];
|
|
});
|
|
try {
|
|
final limit = int.tryParse(_limitController.text.trim()) ?? 100;
|
|
final result = await widget.runtime.client.runSql(
|
|
sql: _sqlController.text,
|
|
limit: limit,
|
|
session: widget.runtime.session,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final data = result.data is Map
|
|
? Map<String, dynamic>.from(result.data as Map)
|
|
: <String, dynamic>{};
|
|
final rows = _extractRows(data['list'] ?? data['rows'] ?? data['data']);
|
|
setState(() {
|
|
_resultRows = rows;
|
|
_resultColumns = rows.isEmpty ? <String>[] : rows.first.keys.toList();
|
|
_status = result.success ? 'SQL 执行完成' : 'SQL 执行失败';
|
|
_detail = _formatActionResult(result);
|
|
});
|
|
widget.onAction('执行 SQL', widget.tab.title);
|
|
} catch (error, stackTrace) {
|
|
_printError('执行 SQL 失败', error, stackTrace);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_status = 'SQL 执行失败';
|
|
_detail = _normalizeError(error);
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _saveTemplate() async {
|
|
final tid = _selectedTid();
|
|
final oid = _selectedOid(tid);
|
|
if (tid.isEmpty || oid.isEmpty) {
|
|
setState(() {
|
|
_status = '请选择 tid 和 oid';
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_loading = true;
|
|
_status = _templateLoaded ? '正在更新模板...' : '正在创建模板...';
|
|
_detail = '';
|
|
});
|
|
try {
|
|
final payload = <String, dynamic>{
|
|
'value': _sqlController.text,
|
|
'count': _countSqlController.text,
|
|
'criteria': _criteriaController.text,
|
|
'summary': _summarySqlController.text,
|
|
'extConfig': _extConfigController.text,
|
|
};
|
|
final wasLoaded = _templateLoaded;
|
|
final result = _templateLoaded
|
|
? await widget.runtime.client.updateSqlTemplate(
|
|
tid: tid,
|
|
oid: oid,
|
|
payload: payload,
|
|
session: widget.runtime.session,
|
|
)
|
|
: await widget.runtime.client.createSqlTemplate(
|
|
tid: tid,
|
|
oid: oid,
|
|
payload: payload,
|
|
session: widget.runtime.session,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_templateLoaded = true;
|
|
_status = result.success ? '模板已保存' : '保存失败';
|
|
_detail = _formatActionResult(result);
|
|
});
|
|
widget.onAction(wasLoaded ? '更新模板' : '创建模板', '$tid / $oid');
|
|
} catch (error, stackTrace) {
|
|
_printError('保存 SQL 模板失败', error, stackTrace);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_status = '保存失败';
|
|
_detail = _normalizeError(error);
|
|
});
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _formatSql() {
|
|
setState(() {
|
|
_sqlController.text = _sqlController.text
|
|
.replaceAll(RegExp(r'\s+'), ' ')
|
|
.trim();
|
|
});
|
|
widget.onAction('格式化 SQL', widget.tab.title);
|
|
}
|
|
|
|
void _printError(String title, Object error, StackTrace stackTrace) {
|
|
debugPrint('========== $title ==========');
|
|
debugPrint('error: $error');
|
|
debugPrint('stackTrace:\n$stackTrace');
|
|
debugPrint('========== $title end ==========');
|
|
}
|
|
|
|
String _normalizeError(Object error) {
|
|
final text = error.toString();
|
|
const marker = 'Bad state: ';
|
|
if (text.startsWith(marker)) {
|
|
return text.substring(marker.length);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
String _readValue(Map<String, dynamic> map, List<String> keys, [String fallback = '']) {
|
|
for (final key in keys) {
|
|
final value = map[key];
|
|
if (value != null && value.toString().trim().isNotEmpty) {
|
|
return value.toString();
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
List<Map<String, dynamic>> _extractRows(dynamic value) {
|
|
if (value is List) {
|
|
return value
|
|
.whereType<Map>()
|
|
.map((item) => Map<String, dynamic>.from(item))
|
|
.toList();
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
|
|
String _formatActionResult(StudioBackendActionResult result) {
|
|
return const JsonEncoder.withIndent(' ').convert({
|
|
'code': result.code,
|
|
'message': result.message,
|
|
'data': result.data,
|
|
'raw': result.raw,
|
|
});
|
|
}
|
|
|
|
Widget _buildTemplateBody(BuildContext context) {
|
|
final tids = _templateIndex.keys.toList();
|
|
final selectedTid = _selectedTid();
|
|
final oids = selectedTid.isEmpty
|
|
? const <String>[]
|
|
: _templateIndex[selectedTid] ?? const <String>[];
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
SizedBox(
|
|
width: 260,
|
|
child: DropdownButtonFormField<String>(
|
|
value: tids.contains(selectedTid) ? selectedTid : null,
|
|
decoration: const InputDecoration(labelText: 'tid'),
|
|
items: tids
|
|
.map((tid) => DropdownMenuItem(value: tid, child: Text(tid)))
|
|
.toList(),
|
|
onChanged: (value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_selectedTidValue = value;
|
|
_selectedOidValue = (_templateIndex[value] ?? const <String>[]).isNotEmpty
|
|
? _templateIndex[value]!.first
|
|
: '';
|
|
});
|
|
},
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 260,
|
|
child: DropdownButtonFormField<String>(
|
|
value: oids.contains(_selectedOid()) ? _selectedOid() : null,
|
|
decoration: const InputDecoration(labelText: 'oid'),
|
|
items: oids
|
|
.map((oid) => DropdownMenuItem(value: oid, child: Text(oid)))
|
|
.toList(),
|
|
onChanged: (value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_selectedOidValue = value;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _loading ? null : _loadSelectedTemplate,
|
|
icon: const Icon(Icons.folder_open_rounded),
|
|
label: const Text('加载模板'),
|
|
),
|
|
FilledButton.icon(
|
|
onPressed: _loading ? null : _saveTemplate,
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: Text(_templateLoaded ? '更新模板' : '创建模板'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
_buildSqlEditors(context, includeTemplateFields: true),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isTemplateMode && _selectedTidValue.isEmpty && _templateIndex.isNotEmpty) {
|
|
_selectedTidValue = _templateIndex.keys.first;
|
|
_selectedOidValue = _templateIndex[_selectedTidValue]!.first;
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.tab.title,
|
|
style: Theme.of(context).textTheme.headlineSmall
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
_isTemplateMode ? '从后端加载 SQL 模板并支持创建、更新。' : '直接执行 SQL 并查看结果。',
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: _mutedColor(context)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_loading)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
)
|
|
else if (_isTemplateMode)
|
|
_buildTemplateBody(context)
|
|
else
|
|
_buildSqlEditors(context, includeTemplateFields: false),
|
|
const SizedBox(height: 16),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'执行状态',
|
|
style: Theme.of(context).textTheme.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
if (!_isTemplateMode)
|
|
FilledButton.tonalIcon(
|
|
onPressed: _loading ? null : _runSql,
|
|
icon: const Icon(Icons.play_arrow_rounded),
|
|
label: const Text('执行 SQL'),
|
|
),
|
|
if (!_isTemplateMode) const SizedBox(width: 10),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _loading ? null : _formatSql,
|
|
icon: const Icon(Icons.format_align_left_rounded),
|
|
label: const Text('格式化'),
|
|
),
|
|
if (_isTemplateMode) ...[
|
|
const SizedBox(width: 10),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _loading ? null : _saveTemplate,
|
|
icon: const Icon(Icons.save_rounded),
|
|
label: const Text('保存模板'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
SelectableText(_status),
|
|
if (_detail.isNotEmpty) ...[
|
|
const SizedBox(height: 10),
|
|
SelectableText(
|
|
_detail,
|
|
style: const TextStyle(fontFamily: 'monospace', height: 1.45),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_resultRows.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'执行结果',
|
|
style: Theme.of(context).textTheme.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: DataTable(
|
|
columns: _resultColumns
|
|
.map((column) => DataColumn(label: Text(column)))
|
|
.toList(),
|
|
rows: _resultRows
|
|
.map(
|
|
(row) => DataRow(
|
|
cells: _resultColumns
|
|
.map(
|
|
(column) => DataCell(
|
|
SelectableText(_formatCellValue(row[column])),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSqlEditors(BuildContext context, {required bool includeTemplateFields}) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (includeTemplateFields) ...[
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
SizedBox(
|
|
width: 220,
|
|
child: TextField(
|
|
controller: _countSqlController,
|
|
decoration: const InputDecoration(labelText: 'count SQL'),
|
|
maxLines: 3,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 220,
|
|
child: TextField(
|
|
controller: _summarySqlController,
|
|
decoration: const InputDecoration(labelText: 'summary SQL'),
|
|
maxLines: 3,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _criteriaController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'criteria',
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _extConfigController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'extConfig',
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 14),
|
|
],
|
|
SizedBox(
|
|
height: 280,
|
|
child: TextField(
|
|
controller: _sqlController,
|
|
expands: true,
|
|
maxLines: null,
|
|
minLines: null,
|
|
keyboardType: TextInputType.multiline,
|
|
textAlignVertical: TextAlignVertical.top,
|
|
style: const TextStyle(fontFamily: 'monospace'),
|
|
decoration: const InputDecoration(
|
|
labelText: 'SQL 内容',
|
|
alignLabelWithHint: true,
|
|
),
|
|
),
|
|
),
|
|
if (!includeTemplateFields) ...[
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 160,
|
|
child: TextField(
|
|
controller: _limitController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'limit'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
FilledButton.tonalIcon(
|
|
onPressed: _loading ? null : _formatSql,
|
|
icon: const Icon(Icons.format_align_left_rounded),
|
|
label: const Text('格式化'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatCellValue(Object? value) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
if (value is Map || value is List) {
|
|
return const JsonEncoder.withIndent(' ').convert(value);
|
|
}
|
|
return value.toString();
|
|
}
|
|
}
|
|
|