SHA256
2076 lines
67 KiB
Dart
2076 lines
67 KiB
Dart
part of 'studio_app.dart';
|
||
|
||
class StudioFormEditorPage extends StatefulWidget {
|
||
const StudioFormEditorPage({
|
||
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<StudioFormEditorPage> createState() => _StudioFormEditorPageState();
|
||
}
|
||
|
||
class _StudioFormEditorPageState extends State<StudioFormEditorPage> {
|
||
late final TextEditingController _nameController;
|
||
late final TextEditingController _codeController;
|
||
late final TextEditingController _versionController;
|
||
late final TextEditingController _remarkController;
|
||
late final JsonTextFieldController _contentController;
|
||
late final FocusNode _contentFocusNode;
|
||
late final LinkedScrollControllerGroup _jsonScrollGroup;
|
||
late final ScrollController _jsonLineScrollController;
|
||
late final ScrollController _jsonFieldScrollController;
|
||
late final ScrollController _jsonDiffScrollController;
|
||
late final ScrollController _navigatorTreeScrollController;
|
||
late final TextEditingController _modelSearchController;
|
||
bool _loading = true;
|
||
bool _saving = false;
|
||
bool _loadedExisting = false;
|
||
String _originalSavePreview = '';
|
||
String _jsonErrorMessage = '';
|
||
int? _jsonErrorOffset;
|
||
int? _jsonErrorLine;
|
||
String _status = '加载中';
|
||
String _detail = '';
|
||
List<Map<String, dynamic>> _options = <Map<String, dynamic>>[];
|
||
String _selectedKey = '';
|
||
List<Map<String, dynamic>> _navigatorStructure = <Map<String, dynamic>>[];
|
||
String _navigatorSelectedId = '';
|
||
final Set<String> _navigatorExpandedIds = <String>{};
|
||
late final TextEditingController _navigatorIdController;
|
||
late final TextEditingController _navigatorNameController;
|
||
late final TextEditingController _navigatorTidController;
|
||
late final TextEditingController _navigatorOidController;
|
||
late final TextEditingController _navigatorIconController;
|
||
late final TextEditingController _navigatorParentController;
|
||
late final TextEditingController _navigatorMaxOpenedController;
|
||
late final TextEditingController _navigatorParamController;
|
||
bool _navigatorPublic = false;
|
||
bool _navigatorHidden = false;
|
||
|
||
bool get _isTableMode => widget.tab.id.toLowerCase().contains('table');
|
||
|
||
bool get _isBillMode => widget.tab.id.toLowerCase().contains('bill');
|
||
|
||
bool get _isViewMode =>
|
||
widget.tab.id.toLowerCase().contains('view') ||
|
||
widget.tab.id.toLowerCase().contains('wsodata');
|
||
|
||
bool get _isNavigatorMode =>
|
||
widget.tab.id.toLowerCase().contains('navigator') ||
|
||
widget.tab.id.toLowerCase().contains('navigation');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_nameController = TextEditingController(text: widget.tab.title);
|
||
_codeController = TextEditingController(text: widget.tab.id);
|
||
_versionController = TextEditingController(text: '1');
|
||
_remarkController = TextEditingController(text: widget.tab.summary);
|
||
_contentController = JsonTextFieldController()..text = '{}';
|
||
_contentFocusNode = FocusNode();
|
||
_jsonScrollGroup = LinkedScrollControllerGroup();
|
||
_jsonLineScrollController = _jsonScrollGroup.addAndGet();
|
||
_jsonFieldScrollController = _jsonScrollGroup.addAndGet();
|
||
_jsonDiffScrollController = ScrollController();
|
||
_navigatorTreeScrollController = ScrollController();
|
||
_navigatorIdController = TextEditingController();
|
||
_navigatorNameController = TextEditingController();
|
||
_navigatorTidController = TextEditingController();
|
||
_navigatorOidController = TextEditingController();
|
||
_navigatorIconController = TextEditingController();
|
||
_navigatorParentController = TextEditingController();
|
||
_navigatorMaxOpenedController = TextEditingController();
|
||
_navigatorParamController = TextEditingController();
|
||
_modelSearchController = TextEditingController();
|
||
_contentController.addListener(_onContentChanged);
|
||
_loadInitialData();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
_codeController.dispose();
|
||
_versionController.dispose();
|
||
_remarkController.dispose();
|
||
_contentController.dispose();
|
||
_contentFocusNode.dispose();
|
||
_jsonLineScrollController.dispose();
|
||
_jsonFieldScrollController.dispose();
|
||
_jsonDiffScrollController.dispose();
|
||
_navigatorTreeScrollController.dispose();
|
||
_navigatorIdController.dispose();
|
||
_navigatorNameController.dispose();
|
||
_navigatorTidController.dispose();
|
||
_navigatorOidController.dispose();
|
||
_navigatorIconController.dispose();
|
||
_navigatorParentController.dispose();
|
||
_navigatorMaxOpenedController.dispose();
|
||
_navigatorParamController.dispose();
|
||
_modelSearchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onContentChanged() {
|
||
final result = _decodeContent();
|
||
setState(() {
|
||
if (result.isSuccess) {
|
||
_jsonErrorMessage = '';
|
||
_jsonErrorOffset = null;
|
||
_jsonErrorLine = null;
|
||
} else {
|
||
_jsonErrorMessage = result.errorMessage;
|
||
_jsonErrorOffset = result.errorOffset;
|
||
_jsonErrorLine = _jsonLineFromOffset(result.errorOffset);
|
||
}
|
||
});
|
||
if (!result.isSuccess) {
|
||
_highlightJsonError();
|
||
}
|
||
}
|
||
|
||
Future<void> _loadInitialData() async {
|
||
if (_isTableMode) {
|
||
await _loadTableLikeOptions();
|
||
return;
|
||
}
|
||
if (_isBillMode) {
|
||
await _loadBillLikeOptions();
|
||
return;
|
||
}
|
||
if (_isViewMode) {
|
||
await _loadViewOptions();
|
||
return;
|
||
}
|
||
if (_isNavigatorMode) {
|
||
await _loadNavigator();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_contentController.text = _prettyJson(_defaultPayload());
|
||
_status = '可直接编辑';
|
||
_loading = false;
|
||
});
|
||
_captureOriginalSavePreview(_defaultPayload());
|
||
}
|
||
|
||
Future<void> _loadTableLikeOptions() async {
|
||
try {
|
||
final items = await widget.runtime.client.fetchTableModels(
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_options = items;
|
||
_selectedKey = items.isNotEmpty ? _itemKey(items.first) : '';
|
||
});
|
||
if (_selectedKey.isNotEmpty) {
|
||
await _loadTableLikeItem();
|
||
} else {
|
||
setState(() {
|
||
_loading = false;
|
||
_status = '没有找到数据模型';
|
||
_contentController.text = _prettyJson(_defaultPayload());
|
||
});
|
||
_captureOriginalSavePreview(_defaultPayload());
|
||
}
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载数据模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadBillLikeOptions() async {
|
||
try {
|
||
final items = await widget.runtime.client.fetchBillModels(
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_options = items;
|
||
_selectedKey = items.isNotEmpty ? _itemKey(items.first) : '';
|
||
});
|
||
if (_selectedKey.isNotEmpty) {
|
||
await _loadBillLikeItem();
|
||
} else {
|
||
setState(() {
|
||
_loading = false;
|
||
_status = '没有找到单据模型';
|
||
_contentController.text = _prettyJson(_defaultPayload());
|
||
});
|
||
_captureOriginalSavePreview(_defaultPayload());
|
||
}
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载单据模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadViewOptions() async {
|
||
try {
|
||
final items = await widget.runtime.client.fetchViewModelOptions(
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_options = items;
|
||
_selectedKey = items.isNotEmpty ? _viewItemKey(items.first) : '';
|
||
});
|
||
if (_selectedKey.isNotEmpty) {
|
||
await _loadViewItem();
|
||
} else {
|
||
setState(() {
|
||
_loading = false;
|
||
_status = '没有找到界面模型';
|
||
_contentController.text = _prettyJson(_defaultPayload());
|
||
});
|
||
_captureOriginalSavePreview(_defaultPayload());
|
||
}
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载界面模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadNavigator() async {
|
||
try {
|
||
final navigator = await widget.runtime.client
|
||
.fetchCreationNavigatorRecord(session: widget.runtime.session);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
final structure = navigator['structure'] is List
|
||
? List<Map<String, dynamic>>.from(
|
||
(navigator['structure'] as List).whereType<Map>(),
|
||
)
|
||
: <Map<String, dynamic>>[];
|
||
setState(() {
|
||
_navigatorStructure = structure;
|
||
_navigatorExpandedIds.clear();
|
||
_navigatorSelectedId = '';
|
||
_versionController.text = _readValue(navigator, const ['version'], '1');
|
||
_contentController.text = _prettyJson(
|
||
navigator.isNotEmpty
|
||
? navigator
|
||
: <String, dynamic>{'structure': structure},
|
||
);
|
||
_status = '导航数据已加载';
|
||
_detail = 'root items: ${structure.length}';
|
||
_loadedExisting = true;
|
||
_loading = false;
|
||
});
|
||
final defaultSelectedId =
|
||
_navigatorDefaultSelectedId() ??
|
||
(structure.isNotEmpty
|
||
? _readValue(structure.first, const ['id'])
|
||
: '');
|
||
if (defaultSelectedId.isNotEmpty) {
|
||
_selectNavigatorNode(defaultSelectedId, scrollIntoView: false);
|
||
}
|
||
_refreshNavigatorContent();
|
||
_captureOriginalSavePreview(
|
||
navigator.isNotEmpty
|
||
? navigator
|
||
: <String, dynamic>{'structure': structure},
|
||
);
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载导航失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
String? _navigatorDefaultSelectedId() {
|
||
for (final item in _navigatorStructure) {
|
||
final id = _readValue(item, const ['id']);
|
||
if (id.isEmpty || id == 'root') {
|
||
continue;
|
||
}
|
||
if (_navigatorChildrenOf(id).isEmpty) {
|
||
return id;
|
||
}
|
||
}
|
||
for (final item in _navigatorStructure) {
|
||
final id = _readValue(item, const ['id']);
|
||
if (id.isNotEmpty && id != 'root') {
|
||
return id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Map<String, dynamic>? _navigatorNodeById(String id) {
|
||
for (final item in _navigatorStructure) {
|
||
if (_readValue(item, const ['id']) == id) {
|
||
return item;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
List<Map<String, dynamic>> _navigatorChildrenOf(String parentId) {
|
||
return _navigatorStructure
|
||
.where((item) => _readValue(item, const ['parent']) == parentId)
|
||
.toList();
|
||
}
|
||
|
||
List<String> _navigatorAncestorIds(String id) {
|
||
final ancestors = <String>[];
|
||
var current = _navigatorNodeById(id);
|
||
while (current != null) {
|
||
final parentId = _readValue(current, const ['parent']);
|
||
if (parentId.isEmpty || parentId == 'root') {
|
||
break;
|
||
}
|
||
ancestors.add(parentId);
|
||
current = _navigatorNodeById(parentId);
|
||
}
|
||
ancestors.add('root');
|
||
return ancestors;
|
||
}
|
||
|
||
void _selectNavigatorNode(String id, {bool scrollIntoView = true}) {
|
||
final node = _navigatorNodeById(id);
|
||
if (node == null) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_navigatorSelectedId = id;
|
||
_navigatorExpandedIds.addAll(_navigatorAncestorIds(id));
|
||
_navigatorIdController.text = _readValue(node, const ['id']);
|
||
_navigatorNameController.text = _readValue(node, const ['name', 'title']);
|
||
_navigatorTidController.text = _readValue(node, const ['tid']);
|
||
_navigatorOidController.text = _readValue(node, const ['oid']);
|
||
_navigatorIconController.text = _readValue(node, const ['icon']);
|
||
_navigatorParentController.text = _readValue(node, const [
|
||
'parent',
|
||
], 'root');
|
||
_navigatorMaxOpenedController.text = _readValue(node, const [
|
||
'maxOpened',
|
||
], '0');
|
||
_navigatorParamController.text = _readValue(node, const ['param']);
|
||
_navigatorPublic = _readBoolValue(node, 'public');
|
||
_navigatorHidden = _readBoolValue(node, 'hidden');
|
||
_detail = '$id / ${_navigatorNameController.text}';
|
||
});
|
||
if (scrollIntoView) {
|
||
_refreshNavigatorContent();
|
||
}
|
||
}
|
||
|
||
bool _readBoolValue(Map<String, dynamic> item, String key) {
|
||
final value = item[key];
|
||
if (value is bool) {
|
||
return value;
|
||
}
|
||
final text = value?.toString().trim().toLowerCase();
|
||
return text == '1' || text == 'true' || text == 'yes';
|
||
}
|
||
|
||
void _refreshNavigatorContent() {
|
||
if (!_isNavigatorMode) {
|
||
return;
|
||
}
|
||
final version = int.tryParse(_versionController.text.trim()) ?? 1;
|
||
final payload = <String, dynamic>{
|
||
'version': version,
|
||
'structure': _navigatorStructure,
|
||
};
|
||
_contentController.text = _prettyJson(payload);
|
||
}
|
||
|
||
void _updateNavigatorField(String key, dynamic value) {
|
||
final node = _navigatorNodeById(_navigatorSelectedId);
|
||
if (node == null) {
|
||
return;
|
||
}
|
||
final oldId = _readValue(node, const ['id']);
|
||
setState(() {
|
||
node[key] = value;
|
||
if (key == 'public') {
|
||
_navigatorPublic = value == 1 || value == true;
|
||
} else if (key == 'hidden') {
|
||
_navigatorHidden = value == 1 || value == true;
|
||
}
|
||
if (key == 'id' && value.toString().trim().isNotEmpty) {
|
||
final newId = value.toString().trim();
|
||
for (final item in _navigatorStructure) {
|
||
if (_readValue(item, const ['parent']) == oldId) {
|
||
item['parent'] = newId;
|
||
}
|
||
}
|
||
_navigatorSelectedId = newId;
|
||
}
|
||
_detail = '${_navigatorSelectedId} / ${_readValue(node, const ['name'])}';
|
||
});
|
||
_refreshNavigatorContent();
|
||
}
|
||
|
||
Future<void> _loadTableLikeItem() async {
|
||
final code = _selectedKey;
|
||
if (code.isEmpty) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_loading = true;
|
||
_status = '正在加载数据模型...';
|
||
});
|
||
try {
|
||
final item = await widget.runtime.client.fetchTableModel(
|
||
code: code,
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
if (item == null) {
|
||
setState(() {
|
||
_status = '模型不存在';
|
||
_detail = code;
|
||
_loading = false;
|
||
});
|
||
return;
|
||
}
|
||
_applyLoadedItem(item, code: _readValue(item, const ['code'], code));
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载数据模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadBillLikeItem() async {
|
||
final code = _selectedKey;
|
||
if (code.isEmpty) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_loading = true;
|
||
_status = '正在加载单据模型...';
|
||
});
|
||
try {
|
||
final item = await widget.runtime.client.fetchBillModel(
|
||
code: code,
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
if (item == null) {
|
||
setState(() {
|
||
_status = '模型不存在';
|
||
_detail = code;
|
||
_loading = false;
|
||
});
|
||
return;
|
||
}
|
||
_applyLoadedItem(item, code: _readValue(item, const ['code'], code));
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载单据模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadViewItem() async {
|
||
final parts = _selectedKey.split('::');
|
||
if (parts.length != 2) {
|
||
return;
|
||
}
|
||
final tid = parts[0];
|
||
final oid = parts[1];
|
||
setState(() {
|
||
_loading = true;
|
||
_status = '正在加载界面模型...';
|
||
});
|
||
try {
|
||
final item = await widget.runtime.client.fetchViewModel(
|
||
tid: tid,
|
||
oid: oid,
|
||
session: widget.runtime.session,
|
||
);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
if (item == null) {
|
||
setState(() {
|
||
_status = '模型不存在';
|
||
_detail = '$tid / $oid';
|
||
_loading = false;
|
||
});
|
||
return;
|
||
}
|
||
_applyLoadedItem(
|
||
item,
|
||
code: _readValue(item, const ['tid', 'code'], tid),
|
||
version: _readValue(item, const ['oid', 'version'], oid),
|
||
);
|
||
} catch (error, stackTrace) {
|
||
_reportError('加载界面模型失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
void _applyLoadedItem(
|
||
Map<String, dynamic> item, {
|
||
required String code,
|
||
String? version,
|
||
}) {
|
||
setState(() {
|
||
_nameController.text = _readValue(item, const [
|
||
'name',
|
||
'title',
|
||
], widget.tab.title);
|
||
_codeController.text = code;
|
||
_versionController.text =
|
||
version ?? _readValue(item, const ['version', 'oid'], '1');
|
||
_remarkController.text = _readValue(item, const [
|
||
'remark',
|
||
'description',
|
||
'summary',
|
||
], widget.tab.summary);
|
||
_contentController.text = _prettyJson(item);
|
||
_status = '已加载';
|
||
_detail = code;
|
||
_loadedExisting = true;
|
||
_loading = false;
|
||
});
|
||
_captureOriginalSavePreview(item);
|
||
}
|
||
|
||
Map<String, dynamic> _defaultPayload() {
|
||
return <String, dynamic>{
|
||
'name': widget.tab.title,
|
||
'code': widget.tab.id,
|
||
'version': '1',
|
||
'remark': widget.tab.summary,
|
||
'enabled': true,
|
||
};
|
||
}
|
||
|
||
dynamic _buildSavePreviewPayload(dynamic parsed) {
|
||
if (_isNavigatorMode) {
|
||
final map = parsed is Map<String, dynamic>
|
||
? Map<String, dynamic>.from(parsed)
|
||
: <String, dynamic>{'structure': parsed};
|
||
final structure = map['structure'] is List
|
||
? List<Map<String, dynamic>>.from(
|
||
(map['structure'] as List).whereType<Map>(),
|
||
)
|
||
: <Map<String, dynamic>>[];
|
||
final version = int.tryParse(_versionController.text.trim()) ?? 1;
|
||
return <String, dynamic>{'version': version, 'structure': structure};
|
||
}
|
||
|
||
final payload = parsed is Map<String, dynamic>
|
||
? Map<String, dynamic>.from(parsed)
|
||
: <String, dynamic>{'data': parsed};
|
||
payload['name'] = _nameController.text.trim();
|
||
payload['code'] = _codeController.text.trim();
|
||
payload['version'] = _versionController.text.trim();
|
||
payload['remark'] = _remarkController.text.trim();
|
||
|
||
if (_isViewMode) {
|
||
payload['tid'] = _codeController.text.trim();
|
||
payload['oid'] = _versionController.text.trim();
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
void _captureOriginalSavePreview(dynamic parsed) {
|
||
_originalSavePreview = _prettyJson(_buildSavePreviewPayload(parsed));
|
||
}
|
||
|
||
String _itemKey(Map<String, dynamic> item) {
|
||
return _readValue(item, const ['code', 'id']);
|
||
}
|
||
|
||
String _itemLabel(Map<String, dynamic> item) {
|
||
if (_isViewMode) {
|
||
return '${_readValue(item, const ['tid'])} / ${_readValue(item, const ['oid'])}';
|
||
}
|
||
return _readValue(item, const ['code', 'id']);
|
||
}
|
||
|
||
List<DropdownMenuEntry<String>> _filterModelEntries(
|
||
List<DropdownMenuEntry<String>> entries,
|
||
String filter,
|
||
) {
|
||
final query = filter.trim().toLowerCase();
|
||
if (query.isEmpty) {
|
||
return entries;
|
||
}
|
||
|
||
final terms = query
|
||
.split(RegExp(r'\s+'))
|
||
.where((term) => term.isNotEmpty)
|
||
.toList();
|
||
return entries.where((entry) {
|
||
final haystack = '${entry.label} ${entry.value}'.toLowerCase();
|
||
return terms.every(haystack.contains);
|
||
}).toList();
|
||
}
|
||
|
||
String _viewItemKey(Map<String, dynamic> item) {
|
||
final tid = _readValue(item, const ['tid']);
|
||
final oid = _readValue(item, const ['oid']);
|
||
if (tid.isEmpty || oid.isEmpty) {
|
||
return '';
|
||
}
|
||
return '$tid::$oid';
|
||
}
|
||
|
||
String _readValue(
|
||
Map<String, dynamic> item,
|
||
List<String> keys, [
|
||
String fallback = '',
|
||
]) {
|
||
for (final key in keys) {
|
||
final value = item[key];
|
||
if (value != null && value.toString().trim().isNotEmpty) {
|
||
return value.toString();
|
||
}
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
String _prettyJson(dynamic value) {
|
||
try {
|
||
return const JsonEncoder.withIndent(' ').convert(value);
|
||
} catch (_) {
|
||
return value.toString();
|
||
}
|
||
}
|
||
|
||
void _reportError(String title, Object error, StackTrace stackTrace) {
|
||
debugPrint('========== $title ==========');
|
||
debugPrint('error: $error');
|
||
debugPrint('stackTrace:\n$stackTrace');
|
||
debugPrint('========== $title end ==========');
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_status = '$title';
|
||
_detail = error.toString().replaceFirst('Bad state: ', '');
|
||
_loading = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
final result = _decodeContent();
|
||
if (!result.isSuccess) {
|
||
setState(() {
|
||
_status = 'JSON 格式错误';
|
||
_detail = '请先修正内容后再保存';
|
||
_jsonErrorMessage = result.errorMessage;
|
||
_jsonErrorOffset = result.errorOffset;
|
||
});
|
||
_highlightJsonError();
|
||
return;
|
||
}
|
||
final previewPayload = _buildSavePreviewPayload(result.value);
|
||
final previewText = _prettyJson(previewPayload);
|
||
|
||
if (_loadedExisting) {
|
||
final confirmed = await _confirmUpdateChanges(
|
||
before: _originalSavePreview,
|
||
after: previewText,
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
setState(() {
|
||
_saving = true;
|
||
_status = _loadedExisting ? '正在更新...' : '正在创建...';
|
||
_detail = '';
|
||
});
|
||
try {
|
||
final wasExisting = _loadedExisting;
|
||
final result = await _saveByMode(previewPayload);
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
final resultMessage = result.message.isNotEmpty
|
||
? result.message
|
||
: result.success
|
||
? '保存已完成'
|
||
: '保存未成功';
|
||
setState(() {
|
||
_loadedExisting = true;
|
||
_status = result.success
|
||
? '保存成功:$resultMessage'
|
||
: '保存失败:$resultMessage';
|
||
_detail = _buildActionResultDetail(result);
|
||
if (result.success) {
|
||
_jsonErrorMessage = '';
|
||
_jsonErrorOffset = null;
|
||
}
|
||
});
|
||
if (result.success) {
|
||
_originalSavePreview = previewText;
|
||
}
|
||
widget.onAction(
|
||
result.success
|
||
? (wasExisting ? '更新成功' : '创建成功')
|
||
: '保存失败',
|
||
'${widget.tab.title} / $resultMessage',
|
||
);
|
||
} catch (error, stackTrace) {
|
||
_reportError('保存失败', error, stackTrace);
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() {
|
||
_saving = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<StudioBackendActionResult> _saveByMode(dynamic previewPayload) async {
|
||
if (_isNavigatorMode) {
|
||
final map = previewPayload is Map<String, dynamic>
|
||
? Map<String, dynamic>.from(previewPayload)
|
||
: <String, dynamic>{'structure': previewPayload};
|
||
final structure = map['structure'] is List
|
||
? List<Map<String, dynamic>>.from(
|
||
(map['structure'] as List).whereType<Map>(),
|
||
)
|
||
: <Map<String, dynamic>>[];
|
||
final version = int.tryParse(map['version']?.toString() ?? '') ?? 1;
|
||
return widget.runtime.client.saveCreationNavigator(
|
||
data: structure,
|
||
version: version,
|
||
session: widget.runtime.session,
|
||
);
|
||
}
|
||
|
||
final payload = previewPayload is Map<String, dynamic>
|
||
? Map<String, dynamic>.from(previewPayload)
|
||
: <String, dynamic>{'data': previewPayload};
|
||
|
||
if (_isTableMode) {
|
||
return _loadedExisting
|
||
? widget.runtime.client.updateTableModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
)
|
||
: widget.runtime.client.createTableModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
);
|
||
}
|
||
|
||
if (_isBillMode) {
|
||
return _loadedExisting
|
||
? widget.runtime.client.updateBillModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
)
|
||
: widget.runtime.client.createBillModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
);
|
||
}
|
||
|
||
if (_isViewMode) {
|
||
return _loadedExisting
|
||
? widget.runtime.client.updateViewModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
)
|
||
: widget.runtime.client.createViewModel(
|
||
payload: payload,
|
||
session: widget.runtime.session,
|
||
);
|
||
}
|
||
|
||
return StudioBackendActionResult(
|
||
success: true,
|
||
code: '',
|
||
message: '无需保存',
|
||
data: payload,
|
||
raw: payload,
|
||
);
|
||
}
|
||
|
||
String _buildActionResultDetail(StudioBackendActionResult result) {
|
||
final buffer = StringBuffer()
|
||
..writeln('success: ${result.success}')
|
||
..writeln('code: ${result.code.isEmpty ? '空' : result.code}')
|
||
..writeln('message: ${result.message.isEmpty ? '无' : result.message}')
|
||
..writeln(
|
||
'data: ${const JsonEncoder.withIndent(' ').convert(result.data)}',
|
||
)
|
||
..writeln('raw: ${const JsonEncoder.withIndent(' ').convert(result.raw)}');
|
||
return buffer.toString();
|
||
}
|
||
|
||
Future<bool> _confirmUpdateChanges({
|
||
required String before,
|
||
required String after,
|
||
}) async {
|
||
final diffLines = _buildJsonDiffLines(before, after);
|
||
final addedCount = diffLines
|
||
.where((line) => line.type == _JsonDiffType.added)
|
||
.length;
|
||
final removedCount = diffLines
|
||
.where((line) => line.type == _JsonDiffType.removed)
|
||
.length;
|
||
return await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (dialogContext) {
|
||
final theme = Theme.of(dialogContext);
|
||
final hasChanges = addedCount > 0 || removedCount > 0;
|
||
return AlertDialog(
|
||
title: const Text('确认更新'),
|
||
content: SizedBox(
|
||
width: 900,
|
||
height: 520,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
hasChanges
|
||
? 'JSON 内容已变化,确认后将提交更新。'
|
||
: '未检测到 JSON 内容变化,仍要继续更新吗?',
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'新增 $addedCount 行,删除 $removedCount 行',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: _softPanelColor(dialogContext),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: theme.colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
child: Scrollbar(
|
||
controller: _jsonDiffScrollController,
|
||
child: ListView.builder(
|
||
controller: _jsonDiffScrollController,
|
||
padding: const EdgeInsets.all(12),
|
||
itemCount: diffLines.length,
|
||
itemBuilder: (context, index) {
|
||
return _buildJsonDiffLine(
|
||
context,
|
||
diffLines[index],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||
child: const Text('确认更新'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
) ??
|
||
false;
|
||
}
|
||
|
||
List<_JsonDiffLine> _buildJsonDiffLines(String before, String after) {
|
||
if (before.trim() == after.trim()) {
|
||
return <_JsonDiffLine>[
|
||
const _JsonDiffLine(_JsonDiffType.unchanged, '未检测到 JSON 内容变化'),
|
||
];
|
||
}
|
||
|
||
final oldLines = before.split('\n');
|
||
final newLines = after.split('\n');
|
||
final lcs = List.generate(
|
||
oldLines.length + 1,
|
||
(_) => List<int>.filled(newLines.length + 1, 0),
|
||
);
|
||
|
||
for (var i = oldLines.length - 1; i >= 0; i--) {
|
||
for (var j = newLines.length - 1; j >= 0; j--) {
|
||
if (oldLines[i] == newLines[j]) {
|
||
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
||
} else {
|
||
lcs[i][j] = lcs[i + 1][j] >= lcs[i][j + 1]
|
||
? lcs[i + 1][j]
|
||
: lcs[i][j + 1];
|
||
}
|
||
}
|
||
}
|
||
|
||
final result = <_JsonDiffLine>[];
|
||
var oldIndex = 0;
|
||
var newIndex = 0;
|
||
while (oldIndex < oldLines.length && newIndex < newLines.length) {
|
||
if (oldLines[oldIndex] == newLines[newIndex]) {
|
||
result.add(_JsonDiffLine(_JsonDiffType.unchanged, oldLines[oldIndex]));
|
||
oldIndex++;
|
||
newIndex++;
|
||
continue;
|
||
}
|
||
if (lcs[oldIndex + 1][newIndex] >= lcs[oldIndex][newIndex + 1]) {
|
||
result.add(_JsonDiffLine(_JsonDiffType.removed, oldLines[oldIndex]));
|
||
oldIndex++;
|
||
} else {
|
||
result.add(_JsonDiffLine(_JsonDiffType.added, newLines[newIndex]));
|
||
newIndex++;
|
||
}
|
||
}
|
||
|
||
while (oldIndex < oldLines.length) {
|
||
result.add(_JsonDiffLine(_JsonDiffType.removed, oldLines[oldIndex]));
|
||
oldIndex++;
|
||
}
|
||
while (newIndex < newLines.length) {
|
||
result.add(_JsonDiffLine(_JsonDiffType.added, newLines[newIndex]));
|
||
newIndex++;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
Widget _buildJsonDiffLine(BuildContext context, _JsonDiffLine line) {
|
||
final theme = Theme.of(context);
|
||
final color = switch (line.type) {
|
||
_JsonDiffType.added => Colors.green.shade700,
|
||
_JsonDiffType.removed => Colors.red.shade700,
|
||
_JsonDiffType.unchanged => theme.colorScheme.onSurfaceVariant,
|
||
};
|
||
final prefix = switch (line.type) {
|
||
_JsonDiffType.added => '+',
|
||
_JsonDiffType.removed => '-',
|
||
_JsonDiffType.unchanged => ' ',
|
||
};
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||
child: SelectableText(
|
||
'$prefix ${line.text}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
fontFamily: 'monospace',
|
||
height: 1.35,
|
||
color: color,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
_JsonDecodeResult _decodeContent() {
|
||
try {
|
||
return _JsonDecodeResult.success(jsonDecode(_contentController.text));
|
||
} on FormatException catch (error) {
|
||
return _JsonDecodeResult.failure(error.message, error.offset);
|
||
} catch (error) {
|
||
return _JsonDecodeResult.failure(error.toString(), null);
|
||
}
|
||
}
|
||
|
||
int? _jsonLineFromOffset(int? offset) {
|
||
if (offset == null) {
|
||
return null;
|
||
}
|
||
final text = _contentController.text;
|
||
if (text.isEmpty) {
|
||
return null;
|
||
}
|
||
final clamped = offset.clamp(0, text.length) as int;
|
||
return '\n'.allMatches(text.substring(0, clamped)).length + 1;
|
||
}
|
||
|
||
void _formatContent() {
|
||
final result = _decodeContent();
|
||
if (!result.isSuccess) {
|
||
setState(() {
|
||
_status = 'JSON 格式错误,无法格式化';
|
||
_jsonErrorMessage = result.errorMessage;
|
||
_jsonErrorOffset = result.errorOffset;
|
||
_jsonErrorLine = _jsonLineFromOffset(result.errorOffset);
|
||
_detail = result.errorMessage;
|
||
});
|
||
_highlightJsonError();
|
||
return;
|
||
}
|
||
setState(() {
|
||
_contentController.text = _prettyJson(result.value);
|
||
_status = 'JSON 已格式化';
|
||
_jsonErrorMessage = '';
|
||
_jsonErrorOffset = null;
|
||
_jsonErrorLine = null;
|
||
_detail = '';
|
||
});
|
||
widget.onAction('格式化 JSON', widget.tab.title);
|
||
}
|
||
|
||
void _highlightJsonError() {
|
||
if (!mounted || _jsonErrorMessage.isEmpty) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
void _clearJsonError() {
|
||
if (_jsonErrorMessage.isEmpty && _jsonErrorOffset == null) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_jsonErrorMessage = '';
|
||
_jsonErrorOffset = null;
|
||
_jsonErrorLine = null;
|
||
});
|
||
}
|
||
|
||
bool _canSaveCurrentJson() {
|
||
return _decodeContent().isSuccess;
|
||
}
|
||
|
||
Future<void> _reload() async {
|
||
setState(() {
|
||
_loading = true;
|
||
_status = '正在重新加载...';
|
||
});
|
||
await _loadInitialData();
|
||
widget.onAction('重新加载', widget.tab.title);
|
||
}
|
||
|
||
Future<void> _loadSelectedItem() async {
|
||
if (_isTableMode) {
|
||
await _loadTableLikeItem();
|
||
return;
|
||
}
|
||
if (_isBillMode) {
|
||
await _loadBillLikeItem();
|
||
return;
|
||
}
|
||
if (_isViewMode) {
|
||
await _loadViewItem();
|
||
return;
|
||
}
|
||
if (_isNavigatorMode) {
|
||
await _loadNavigator();
|
||
}
|
||
}
|
||
|
||
Future<void> _delete() async {
|
||
if (_isNavigatorMode) {
|
||
final selectedId = _navigatorSelectedId;
|
||
if (selectedId.isEmpty || selectedId == 'root') {
|
||
setState(() {
|
||
_status = '请选择要删除的菜单项';
|
||
});
|
||
return;
|
||
}
|
||
final children = _navigatorChildrenOf(selectedId);
|
||
if (children.isNotEmpty) {
|
||
setState(() {
|
||
_status = '请先删除子节点';
|
||
});
|
||
return;
|
||
}
|
||
setState(() {
|
||
_navigatorStructure.removeWhere(
|
||
(item) => _readValue(item, const ['id']) == selectedId,
|
||
);
|
||
_navigatorSelectedId = '';
|
||
_navigatorIdController.clear();
|
||
_navigatorNameController.clear();
|
||
_navigatorTidController.clear();
|
||
_navigatorOidController.clear();
|
||
_navigatorIconController.clear();
|
||
_navigatorParentController.text = 'root';
|
||
_navigatorMaxOpenedController.text = '0';
|
||
_navigatorParamController.clear();
|
||
_navigatorPublic = false;
|
||
_navigatorHidden = false;
|
||
_detail = '已删除 $selectedId';
|
||
_status = '已删除菜单项,等待保存';
|
||
_loadedExisting = false;
|
||
});
|
||
_refreshNavigatorContent();
|
||
return;
|
||
}
|
||
|
||
if (!_loadedExisting) {
|
||
setState(() {
|
||
_status = '当前内容还没有保存过,无需删除';
|
||
});
|
||
return;
|
||
}
|
||
try {
|
||
StudioBackendActionResult result;
|
||
if (_isTableMode) {
|
||
result = await widget.runtime.client.deleteTableModel(
|
||
code: _codeController.text.trim(),
|
||
session: widget.runtime.session,
|
||
);
|
||
} else if (_isBillMode) {
|
||
result = await widget.runtime.client.deleteBillModel(
|
||
code: _codeController.text.trim(),
|
||
session: widget.runtime.session,
|
||
);
|
||
} else if (_isViewMode) {
|
||
result = await widget.runtime.client.deleteViewModel(
|
||
tid: _codeController.text.trim(),
|
||
oid: _versionController.text.trim(),
|
||
session: widget.runtime.session,
|
||
);
|
||
} else {
|
||
setState(() {
|
||
_status = '当前页面不支持删除';
|
||
});
|
||
return;
|
||
}
|
||
if (!mounted) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_status = result.success ? '删除成功' : '删除失败';
|
||
_detail = _prettyJson(result.raw);
|
||
});
|
||
widget.onAction('删除', widget.tab.title);
|
||
} catch (error, stackTrace) {
|
||
_reportError('删除失败', error, stackTrace);
|
||
}
|
||
}
|
||
|
||
void _createNewNavigatorNode() {
|
||
if (!_isNavigatorMode) {
|
||
return;
|
||
}
|
||
final parentId = _navigatorSelectedId.isEmpty
|
||
? 'root'
|
||
: (_navigatorNodeById(_navigatorSelectedId)?['parent']?.toString() ??
|
||
'root');
|
||
final baseId = 'new_menu';
|
||
var candidateId = baseId;
|
||
var index = 1;
|
||
while (_navigatorNodeById(candidateId) != null) {
|
||
candidateId = '${baseId}_$index';
|
||
index += 1;
|
||
}
|
||
setState(() {
|
||
_navigatorStructure.add({
|
||
'id': candidateId,
|
||
'name': '新菜单项',
|
||
'tid': '',
|
||
'oid': '',
|
||
'icon': 'nav-report',
|
||
'parent': parentId,
|
||
'maxOpened': '0',
|
||
'public': 0,
|
||
'hidden': 0,
|
||
'param': '',
|
||
'checked': false,
|
||
'label': '',
|
||
'hintCode1': '',
|
||
'hintCode2': '',
|
||
});
|
||
_navigatorExpandedIds.add(parentId);
|
||
_navigatorSelectedId = candidateId;
|
||
_navigatorIdController.text = candidateId;
|
||
_navigatorNameController.text = '新菜单项';
|
||
_navigatorTidController.text = '';
|
||
_navigatorOidController.text = '';
|
||
_navigatorIconController.text = 'nav-report';
|
||
_navigatorParentController.text = parentId;
|
||
_navigatorMaxOpenedController.text = '0';
|
||
_navigatorParamController.text = '';
|
||
_navigatorPublic = false;
|
||
_navigatorHidden = false;
|
||
_detail = '$candidateId / 新菜单项';
|
||
_loadedExisting = false;
|
||
_status = '已创建新菜单项草稿';
|
||
});
|
||
_refreshNavigatorContent();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return LayoutBuilder(
|
||
builder: (context, _) {
|
||
final headerCard = Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
alignment: WrapAlignment.spaceBetween,
|
||
children: [
|
||
SizedBox(
|
||
width: 260,
|
||
child: SelectableText(
|
||
_status,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
FilledButton.tonalIcon(
|
||
onPressed: _loading || _saving ? null : _reload,
|
||
icon: const Icon(Icons.refresh_rounded),
|
||
label: const Text('重新加载'),
|
||
),
|
||
FilledButton.tonalIcon(
|
||
onPressed: _loading || _saving ? null : _formatContent,
|
||
icon: const Icon(Icons.data_object_rounded),
|
||
label: const Text('格式化'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _loading || _saving || !_canSaveCurrentJson()
|
||
? null
|
||
: _save,
|
||
icon: _saving
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.save_rounded),
|
||
label: Text(_loadedExisting ? '更新' : '保存'),
|
||
),
|
||
if (!_isNavigatorMode)
|
||
FilledButton.tonalIcon(
|
||
onPressed: _loading || _saving ? null : _delete,
|
||
icon: const Icon(Icons.delete_rounded),
|
||
label: const Text('删除'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
|
||
if (_isNavigatorMode) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
headerCard,
|
||
const SizedBox(height: 12),
|
||
Expanded(child: _buildNavigatorWorkspace(context)),
|
||
],
|
||
);
|
||
}
|
||
|
||
final bodyPanel = _buildEditorPanel(context);
|
||
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [headerCard, const SizedBox(height: 12), bodyPanel],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildEditorPanel(BuildContext context) {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'属性编辑',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (_options.isNotEmpty && !_isNavigatorMode) ...[
|
||
DropdownMenu<String>(
|
||
controller: _modelSearchController,
|
||
initialSelection: _selectedKey.isEmpty ? null : _selectedKey,
|
||
enableFilter: true,
|
||
enableSearch: true,
|
||
requestFocusOnTap: true,
|
||
leadingIcon: const Icon(Icons.search_rounded),
|
||
hintText: _isViewMode ? '搜索并选择 tid / oid' : '搜索并选择模型',
|
||
inputDecorationTheme: const InputDecorationTheme(
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 10,
|
||
),
|
||
),
|
||
expandedInsets: EdgeInsets.zero,
|
||
dropdownMenuEntries: _options.map((item) {
|
||
final value = _isViewMode
|
||
? _viewItemKey(item)
|
||
: _itemKey(item);
|
||
return DropdownMenuEntry<String>(
|
||
value: value,
|
||
label: _itemLabel(item),
|
||
);
|
||
}).toList(),
|
||
filterCallback: (entries, filter) =>
|
||
_filterModelEntries(entries, filter),
|
||
onSelected: (value) {
|
||
if (value == null || value.isEmpty) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_selectedKey = value;
|
||
});
|
||
_loadSelectedItem();
|
||
},
|
||
),
|
||
const SizedBox(height: 10),
|
||
],
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
'JSON 内容',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||
),
|
||
const SizedBox(height: 8),
|
||
SizedBox(
|
||
height: _jsonEditorHeight(context),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: _jsonErrorMessage.isEmpty
|
||
? _softPanelColor(context)
|
||
: Theme.of(context).colorScheme.error.withOpacity(0.05),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: _jsonErrorMessage.isEmpty
|
||
? Theme.of(context).colorScheme.outlineVariant
|
||
: Theme.of(context).colorScheme.error,
|
||
width: _jsonErrorMessage.isEmpty ? 1 : 2,
|
||
),
|
||
boxShadow: _jsonErrorMessage.isEmpty
|
||
? null
|
||
: [
|
||
BoxShadow(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.error.withOpacity(0.14),
|
||
blurRadius: 16,
|
||
spreadRadius: 1,
|
||
),
|
||
],
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Container(
|
||
width: 54,
|
||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 12),
|
||
decoration: BoxDecoration(
|
||
color: _jsonErrorMessage.isEmpty
|
||
? Theme.of(
|
||
context,
|
||
).colorScheme.surface.withOpacity(0.30)
|
||
: Theme.of(
|
||
context,
|
||
).colorScheme.error.withOpacity(0.12),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(16),
|
||
bottomLeft: Radius.circular(16),
|
||
),
|
||
),
|
||
child: SingleChildScrollView(
|
||
controller: _jsonLineScrollController,
|
||
child: RichText(text: _jsonLineNumberTextSpan(context)),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Theme(
|
||
data: Theme.of(context).copyWith(
|
||
textSelectionTheme: TextSelectionThemeData(
|
||
cursorColor: Theme.of(context).colorScheme.primary,
|
||
selectionColor: _jsonErrorMessage.isEmpty
|
||
? Theme.of(
|
||
context,
|
||
).colorScheme.primary.withOpacity(0.20)
|
||
: Theme.of(
|
||
context,
|
||
).colorScheme.error.withOpacity(0.34),
|
||
),
|
||
),
|
||
child: JsonField(
|
||
controller: _contentController,
|
||
focusNode: _contentFocusNode,
|
||
scrollController: _jsonFieldScrollController,
|
||
expands: true,
|
||
maxLines: null,
|
||
minLines: null,
|
||
keyboardType: TextInputType.multiline,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 13.5,
|
||
height: 1.45,
|
||
),
|
||
cursorColor: Theme.of(context).colorScheme.primary,
|
||
decoration: const InputDecoration(
|
||
border: InputBorder.none,
|
||
isCollapsed: true,
|
||
contentPadding: EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||
),
|
||
keyHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
stringHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.tertiary,
|
||
),
|
||
numberHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.secondary,
|
||
),
|
||
boolHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
nullHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.outline,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
specialCharHighlightStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.outlineVariant,
|
||
),
|
||
commonTextStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.onSurface,
|
||
),
|
||
errorTextStyle: _jsonSyntaxStyle(
|
||
context,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
errorContainerDecoration: BoxDecoration(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.error.withOpacity(0.10),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
showErrorMessage: false,
|
||
isFormatting: true,
|
||
doInitFormatting: false,
|
||
onError: (error) {
|
||
if (error == null) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_jsonErrorMessage = error;
|
||
});
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_jsonErrorMessage.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
SelectableText.rich(
|
||
TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: _jsonErrorMessage,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
),
|
||
if (_jsonErrorLine != null)
|
||
TextSpan(
|
||
text: '(第 $_jsonErrorLine 行)',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.error.withOpacity(0.75),
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorWorkspace(BuildContext context) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
SizedBox(width: 300, child: _buildNavigatorTreePanel(context)),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: _buildNavigatorDetailPanel(context)),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorTreePanel(BuildContext context) {
|
||
final rootNode = _navigatorNodeById('root');
|
||
return Card(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||
child: Text(
|
||
'菜单树',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
const Divider(height: 1),
|
||
Expanded(
|
||
child: Scrollbar(
|
||
controller: _navigatorTreeScrollController,
|
||
child: ListView(
|
||
controller: _navigatorTreeScrollController,
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: [
|
||
if (rootNode != null)
|
||
_buildNavigatorTreeNode(context, rootNode, depth: 0),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorTreeNode(
|
||
BuildContext context,
|
||
Map<String, dynamic> node, {
|
||
required int depth,
|
||
}) {
|
||
final id = _readValue(node, const ['id']);
|
||
final title = _readValue(node, const ['name'], id);
|
||
final children = _navigatorChildrenOf(id);
|
||
final isExpanded = _navigatorExpandedIds.contains(id);
|
||
final isSelected = id == _navigatorSelectedId;
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
final hasChildren = children.isNotEmpty;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
InkWell(
|
||
onTap: () {
|
||
_selectNavigatorNode(id);
|
||
if (hasChildren) {
|
||
setState(() {
|
||
_navigatorExpandedIds.add(id);
|
||
});
|
||
}
|
||
},
|
||
child: Container(
|
||
color: isSelected
|
||
? colorScheme.primary.withOpacity(0.22)
|
||
: Colors.transparent,
|
||
padding: EdgeInsets.fromLTRB(12 + depth * 18, 8, 12, 8),
|
||
child: Row(
|
||
children: [
|
||
if (hasChildren)
|
||
IconButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
if (isExpanded) {
|
||
_navigatorExpandedIds.remove(id);
|
||
} else {
|
||
_navigatorExpandedIds.add(id);
|
||
}
|
||
});
|
||
},
|
||
icon: Icon(
|
||
isExpanded
|
||
? Icons.keyboard_arrow_down_rounded
|
||
: Icons.keyboard_arrow_right_rounded,
|
||
size: 18,
|
||
),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints.tightFor(
|
||
width: 24,
|
||
height: 24,
|
||
),
|
||
visualDensity: VisualDensity.compact,
|
||
)
|
||
else
|
||
const SizedBox(width: 24),
|
||
Icon(
|
||
_navigatorNodeIcon(node),
|
||
size: 18,
|
||
color: isSelected
|
||
? colorScheme.primary
|
||
: colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
color: isSelected ? colorScheme.primary : null,
|
||
fontWeight: isSelected
|
||
? FontWeight.w700
|
||
: FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (hasChildren && isExpanded)
|
||
...children.map(
|
||
(child) =>
|
||
_buildNavigatorTreeNode(context, child, depth: depth + 1),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
IconData _navigatorNodeIcon(Map<String, dynamic> node) {
|
||
final id = _readValue(node, const ['id']);
|
||
final iconText = _readValue(node, const ['icon']).toLowerCase();
|
||
if (id == 'root') {
|
||
return Icons.home_rounded;
|
||
}
|
||
if (iconText.contains('permission')) {
|
||
return Icons.verified_user_outlined;
|
||
}
|
||
if (iconText.contains('global')) {
|
||
return Icons.public_rounded;
|
||
}
|
||
if (iconText.contains('setting')) {
|
||
return Icons.settings_rounded;
|
||
}
|
||
if (_navigatorChildrenOf(id).isNotEmpty) {
|
||
return Icons.folder_rounded;
|
||
}
|
||
return Icons.description_rounded;
|
||
}
|
||
|
||
Widget _buildNavigatorDetailPanel(BuildContext context) {
|
||
final colorScheme = Theme.of(context).colorScheme;
|
||
return SizedBox.expand(
|
||
child: Card(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'菜单项编辑',
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Wrap(
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: [
|
||
FilledButton.tonalIcon(
|
||
onPressed: _loading || _saving
|
||
? null
|
||
: _createNewNavigatorNode,
|
||
icon: const Icon(Icons.add_rounded),
|
||
label: const Text('添加'),
|
||
),
|
||
FilledButton.tonalIcon(
|
||
onPressed:
|
||
_loading || _saving || !_isNavigatorMode
|
||
? null
|
||
: _navigatorSelectedId.isEmpty
|
||
? null
|
||
: () => _selectNavigatorNode(_navigatorSelectedId),
|
||
icon: const Icon(Icons.refresh_rounded),
|
||
label: const Text('更新'),
|
||
),
|
||
FilledButton.icon(
|
||
onPressed: _loading || _saving || !_canSaveCurrentJson()
|
||
? null
|
||
: _save,
|
||
icon: _saving
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.save_rounded),
|
||
label: const Text('保存'),
|
||
),
|
||
FilledButton.tonalIcon(
|
||
onPressed:
|
||
_loading ||
|
||
_saving ||
|
||
(_isNavigatorMode && _navigatorSelectedId.isEmpty)
|
||
? null
|
||
: _delete,
|
||
icon: const Icon(Icons.delete_rounded),
|
||
label: const Text('删除'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: 'Id',
|
||
controller: _navigatorIdController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('id', value.trim()),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: '界面类型',
|
||
controller: _navigatorTidController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('tid', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: 'ICON',
|
||
controller: _navigatorIconController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('icon', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: '最多额外打开数量',
|
||
controller: _navigatorMaxOpenedController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('maxOpened', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorCheckboxRow(
|
||
context,
|
||
label: '不受权限控制',
|
||
value: _navigatorPublic,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('public', value ? 1 : 0),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 24),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: '名称',
|
||
controller: _navigatorNameController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('name', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: '界面名称',
|
||
controller: _navigatorOidController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('oid', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorTextRow(
|
||
context,
|
||
label: '父节点',
|
||
controller: _navigatorParentController,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('parent', value),
|
||
),
|
||
const SizedBox(height: 10),
|
||
_buildNavigatorCheckboxRow(
|
||
context,
|
||
label: '隐藏',
|
||
value: _navigatorHidden,
|
||
onChanged: (value) =>
|
||
_updateNavigatorField('hidden', value ? 1 : 0),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildNavigatorMultilineRow(
|
||
context,
|
||
label: '页面参数',
|
||
controller: _navigatorParamController,
|
||
minLines: 8,
|
||
onChanged: (value) => _updateNavigatorField('param', value),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: colorScheme.surfaceContainerHighest.withOpacity(0.35),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: colorScheme.outlineVariant),
|
||
),
|
||
child: Text(
|
||
_detail.isEmpty ? '选择左侧菜单项进行编辑' : _detail,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorTextRow(
|
||
BuildContext context, {
|
||
required String label,
|
||
required TextEditingController controller,
|
||
required ValueChanged<String> onChanged,
|
||
}) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
SizedBox(
|
||
width: 124,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Text(
|
||
'$label:',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
onChanged: onChanged,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
border: const OutlineInputBorder(),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderSide: BorderSide(
|
||
color: Theme.of(context).colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 10,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorMultilineRow(
|
||
BuildContext context, {
|
||
required String label,
|
||
required TextEditingController controller,
|
||
required int minLines,
|
||
required ValueChanged<String> onChanged,
|
||
}) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 124,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 12),
|
||
child: Align(
|
||
alignment: Alignment.topRight,
|
||
child: Text(
|
||
'$label:',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
onChanged: onChanged,
|
||
minLines: minLines,
|
||
maxLines: null,
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
border: const OutlineInputBorder(),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderSide: BorderSide(
|
||
color: Theme.of(context).colorScheme.outlineVariant,
|
||
),
|
||
),
|
||
contentPadding: const EdgeInsets.all(10),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigatorCheckboxRow(
|
||
BuildContext context, {
|
||
required String label,
|
||
required bool value,
|
||
required ValueChanged<bool> onChanged,
|
||
}) {
|
||
return Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 124,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: Text(
|
||
'$label:',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Checkbox(
|
||
value: value,
|
||
onChanged: (checked) => onChanged(checked ?? false),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
double _jsonEditorHeight(BuildContext context) {
|
||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||
return (screenHeight * 0.58).clamp(420.0, 760.0);
|
||
}
|
||
|
||
TextSpan _jsonLineNumberTextSpan(BuildContext context) {
|
||
final lineCount = _contentController.text.split('\n').length;
|
||
final baseStyle =
|
||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontFamily: 'monospace',
|
||
fontSize: 13.5,
|
||
height: 1.45,
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
) ??
|
||
const TextStyle(fontFamily: 'monospace', fontSize: 13.5, height: 1.45);
|
||
final errorColor = Theme.of(context).colorScheme.error;
|
||
return TextSpan(
|
||
children: List.generate(lineCount, (index) {
|
||
final lineNumber = index + 1;
|
||
final isErrorLine = lineNumber == _jsonErrorLine;
|
||
return TextSpan(
|
||
text: '$lineNumber${lineNumber == lineCount ? '' : '\n'}',
|
||
style: baseStyle.copyWith(
|
||
color: isErrorLine ? errorColor : baseStyle.color,
|
||
fontWeight: isErrorLine ? FontWeight.w700 : baseStyle.fontWeight,
|
||
backgroundColor: isErrorLine ? errorColor.withOpacity(0.12) : null,
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
TextStyle _jsonSyntaxStyle(
|
||
BuildContext context, {
|
||
required Color color,
|
||
FontWeight? fontWeight,
|
||
}) {
|
||
return Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontFamily: 'monospace',
|
||
fontSize: 13.5,
|
||
height: 1.45,
|
||
color: color,
|
||
fontWeight: fontWeight,
|
||
) ??
|
||
TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 13.5,
|
||
height: 1.45,
|
||
color: color,
|
||
fontWeight: fontWeight,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _JsonDecodeResult {
|
||
const _JsonDecodeResult._({
|
||
required this.isSuccess,
|
||
required this.value,
|
||
required this.errorMessage,
|
||
required this.errorOffset,
|
||
});
|
||
|
||
final bool isSuccess;
|
||
final dynamic value;
|
||
final String errorMessage;
|
||
final int? errorOffset;
|
||
|
||
factory _JsonDecodeResult.success(dynamic value) {
|
||
return _JsonDecodeResult._(
|
||
isSuccess: true,
|
||
value: value,
|
||
errorMessage: '',
|
||
errorOffset: null,
|
||
);
|
||
}
|
||
|
||
factory _JsonDecodeResult.failure(String message, int? offset) {
|
||
return _JsonDecodeResult._(
|
||
isSuccess: false,
|
||
value: null,
|
||
errorMessage: message,
|
||
errorOffset: offset,
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _JsonDiffType { unchanged, added, removed }
|
||
|
||
class _JsonDiffLine {
|
||
const _JsonDiffLine(this.type, this.text);
|
||
|
||
final _JsonDiffType type;
|
||
final String text;
|
||
}
|