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

859 lines
26 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 CodeController _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 = CodeController(
text: 'select id, code, name from item order by id desc limit 20',
language: sql_highlight.sql,
);
_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,
});
}
Map<String, TextStyle> _sqlCodeTheme(BuildContext context) {
final isDark = _isDarkTheme(context);
return isDark ? atomOneDarkTheme : atomOneLightTheme;
}
Widget _buildStatusGrid(BuildContext context) {
final items = <_StatusItem>[
_StatusItem('状态', _status),
_StatusItem('详情', _detail.isEmpty ? '-' : _detail),
_StatusItem(
'结果',
_resultRows.isEmpty
? '无结果'
: '共 ${_resultRows.length} 行,${_resultColumns.length} 列',
),
if (_isTemplateMode)
_StatusItem('模板', '${_selectedTid()} / ${_selectedOid()}'),
];
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth >= 920
? (constraints.maxWidth - 12) / 2
: constraints.maxWidth;
return Wrap(
spacing: 12,
runSpacing: 12,
children: items
.map(
(item) => SizedBox(
width: cardWidth,
child: _StatusTile(
title: item.title,
content: item.content,
),
),
)
.toList(),
);
},
);
}
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: [
LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 860;
final tidField = SizedBox(
width: wide ? 260 : double.infinity,
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
: '';
});
},
),
);
final oidField = SizedBox(
width: wide ? 260 : double.infinity,
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;
});
},
),
);
final buttons = Wrap(
spacing: 12,
runSpacing: 12,
children: [
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 ? '更新模板' : '创建模板'),
),
],
);
if (!wide) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
tidField,
const SizedBox(height: 12),
oidField,
const SizedBox(height: 12),
buttons,
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: tidField),
const SizedBox(width: 12),
Expanded(child: oidField),
const SizedBox(width: 12),
buttons,
],
);
},
),
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 LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 1120;
final content = _loading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
)
: _isTemplateMode
? _buildTemplateBody(context)
: _buildSqlEditors(context, includeTemplateFields: false);
if (!wide) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSqlHeader(context),
const SizedBox(height: 12),
content,
const SizedBox(height: 12),
_buildExecutionStatusCard(context),
if (_resultRows.isNotEmpty) ...[
const SizedBox(height: 16),
_buildExecutionResultCard(context),
],
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSqlHeader(context),
const SizedBox(height: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: SingleChildScrollView(child: content),
),
const SizedBox(width: 12),
SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildExecutionStatusCard(context),
if (_resultRows.isNotEmpty) ...[
const SizedBox(height: 16),
_buildExecutionResultCard(context),
],
],
),
),
),
],
),
),
],
);
},
);
}
Widget _buildSqlHeader(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Expanded(
child: Text(
_isTemplateMode ? 'SQL 模板执行' : 'SQL 执行',
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('保存模板'),
),
],
],
),
),
);
}
Widget _buildExecutionStatusCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'执行状态',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
_buildStatusGrid(context),
],
),
),
);
}
Widget _buildExecutionResultCard(BuildContext context) {
return 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(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (includeTemplateFields) ...[
Wrap(
spacing: 10,
runSpacing: 10,
children: [
SizedBox(
width: 200,
child: TextField(
controller: _countSqlController,
decoration: const InputDecoration(labelText: 'count SQL'),
maxLines: 3,
),
),
SizedBox(
width: 200,
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: 240,
child: CodeTheme(
data: CodeThemeData(styles: _sqlCodeTheme(context)),
child: CodeField(
controller: _sqlController,
expands: true,
wrap: true,
lineNumbers: false,
isDense: true,
keyboardType: TextInputType.multiline,
textStyle: const TextStyle(
fontFamily: 'monospace',
fontSize: 13.5,
height: 1.45,
),
cursorColor: Theme.of(context).colorScheme.primary,
background: Colors.transparent,
decoration: BoxDecoration(
color: _softPanelColor(context),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
padding: const EdgeInsets.all(12),
textSelectionTheme: TextSelectionThemeData(
cursorColor: Theme.of(context).colorScheme.primary,
selectionColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.20),
),
),
),
),
if (!includeTemplateFields) ...[
const SizedBox(height: 10),
Row(
children: [
SizedBox(
width: 150,
child: TextField(
controller: _limitController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'limit'),
),
),
const SizedBox(width: 10),
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();
}
}
class _StatusItem {
const _StatusItem(this.title, this.content);
final String title;
final String content;
}
class _StatusTile extends StatelessWidget {
const _StatusTile({required this.title, required this.content});
final String title;
final String content;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _mutedColor(context, 0.68),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
SelectableText(
content,
style: const TextStyle(fontFamily: 'monospace', height: 1.45),
),
],
),
),
);
}
}