Files
TtxPageAdmin/lib/studio_app_pages_sql.dart
T
2026-05-16 01:10:06 +08:00

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();
}
}