diff --git a/lib/backend/studio_backend_client.dart b/lib/backend/studio_backend_client.dart index 6a33d91..cddb9dc 100644 --- a/lib/backend/studio_backend_client.dart +++ b/lib/backend/studio_backend_client.dart @@ -535,6 +535,7 @@ class StudioBackendClient { responseType: ResponseType.bytes, followRedirects: true, receiveTimeout: const Duration(seconds: 60), + extra: const {'withCredentials': true}, ), ); _captureCookies(response); @@ -594,6 +595,8 @@ class StudioBackendClient { session: session, includeSessionCookies: includeSessionCookies, ), + // Web 端需要浏览器带上 cookie,避免登录态和会话丢失。 + extra: const {'withCredentials': true}, ), ); _captureCookies(response); @@ -623,6 +626,7 @@ class StudioBackendClient { ? (request, options) => utf8.encode(request) : null, followRedirects: followRedirects, + extra: const {'withCredentials': true}, ), ); _captureCookies(response); @@ -668,6 +672,7 @@ class StudioBackendClient { includeSessionCookies: true, ), followRedirects: true, + extra: const {'withCredentials': true}, ), ); _captureCookies(response); diff --git a/lib/studio_app.dart b/lib/studio_app.dart index 3c7dd5b..0d80798 100644 --- a/lib/studio_app.dart +++ b/lib/studio_app.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_highlight/themes/atom-one-dark.dart'; import 'package:flutter_highlight/themes/atom-one-light.dart'; import 'package:code_text_field/code_text_field.dart'; -import 'package:highlight/languages/json.dart' as json_highlight; +import 'package:json_field_editor/json_field_editor.dart'; +import 'package:linked_scroll_controller/linked_scroll_controller.dart'; import 'package:highlight/languages/sql.dart' as sql_highlight; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/lib/studio_app_login.dart b/lib/studio_app_login.dart index 2d15cf9..a20e276 100644 --- a/lib/studio_app_login.dart +++ b/lib/studio_app_login.dart @@ -194,6 +194,7 @@ class _StudioLoginViewState extends State { return; } setState(() { + // 登录页的配置读取只做“增强体验”;就算后端暂时不可达,用户仍然可以手动输入地址继续登录。 _errorMessage = '后端配置暂时不可用,仍可继续尝试登录'; }); } diff --git a/lib/studio_app_models.dart b/lib/studio_app_models.dart index 3926d1c..339696d 100644 --- a/lib/studio_app_models.dart +++ b/lib/studio_app_models.dart @@ -161,43 +161,6 @@ final List studioNavTree = [ ), ], ), - StudioNavNode( - id: 'json-center', - title: '模型 JSON', - summary: '直写 JSON 与脚本化编辑', - icon: Icons.data_object_rounded, - pageType: StudioPageType.dashboard, - children: [ - StudioNavNode( - id: 'TableJsonEditor', - title: '数据模型 Json', - summary: 'JSON 结构和字段定义', - icon: Icons.data_object_rounded, - pageType: StudioPageType.jsonEditor, - ), - StudioNavNode( - id: 'BillJsonEditor', - title: '单据模型 Json', - summary: '单据配置的 JSON 编辑器', - icon: Icons.code_rounded, - pageType: StudioPageType.jsonEditor, - ), - StudioNavNode( - id: 'ViewJsonEditor', - title: '界面模型 Json', - summary: '界面配置的 JSON 编辑器', - icon: Icons.article_rounded, - pageType: StudioPageType.jsonEditor, - ), - StudioNavNode( - id: 'NavigatorEditor', - title: '菜单维护 Json', - summary: '导航树的 JSON 配置', - icon: Icons.list_alt_rounded, - pageType: StudioPageType.navigationEditor, - ), - ], - ), StudioNavNode( id: 'history', title: '模型日志', @@ -235,36 +198,6 @@ final List studioNavTree = [ ), ], ), - StudioNavNode( - id: 'manage', - title: '模型管理', - summary: '在线维护与发布', - icon: Icons.tune_rounded, - pageType: StudioPageType.dashboard, - children: [ - StudioNavNode( - id: 'WsoDataManage', - title: '界面模型管理', - summary: '界面模型维护', - icon: Icons.preview_rounded, - pageType: StudioPageType.manage, - ), - StudioNavNode( - id: 'BillModelManage', - title: '单据模型管理', - summary: '单据模型维护', - icon: Icons.inventory_2_rounded, - pageType: StudioPageType.manage, - ), - StudioNavNode( - id: 'TableModelManage', - title: '数据模型管理', - summary: '数据模型维护', - icon: Icons.storage_rounded, - pageType: StudioPageType.manage, - ), - ], - ), StudioNavNode( id: 'ModelSync', title: '配置下载', diff --git a/lib/studio_app_pages.dart b/lib/studio_app_pages.dart index 1b9f51d..21a0c4a 100644 --- a/lib/studio_app_pages.dart +++ b/lib/studio_app_pages.dart @@ -6,15 +6,11 @@ class StudioDashboardPage extends StatelessWidget { required this.tab, required this.user, required this.activities, - required this.rootNodes, - required this.onOpenNode, }); final StudioTab tab; final StudioUser user; final List activities; - final List rootNodes; - final ValueChanged onOpenNode; @override Widget build(BuildContext context) { @@ -126,58 +122,12 @@ class StudioDashboardPage extends StatelessWidget { }, ), const SizedBox(height: 10), - LayoutBuilder( - builder: (context, constraints) { - final leftWidth = constraints.maxWidth >= 980 - ? (constraints.maxWidth - 16) * 0.58 - : constraints.maxWidth; - final rightWidth = constraints.maxWidth >= 980 - ? (constraints.maxWidth - 16) * 0.42 - : constraints.maxWidth; - - if (constraints.maxWidth >= 980) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: leftWidth, - child: _ActivityPanel(activities: activities), - ), - const SizedBox(width: 12), - SizedBox( - width: rightWidth, - child: _FeaturePanel(onOpenNode: onOpenNode), - ), - ], - ); - } - - return Column( - children: [ - _ActivityPanel(activities: activities), - const SizedBox(height: 10), - _FeaturePanel(onOpenNode: onOpenNode), - ], - ); - }, - ), + _ActivityPanel(activities: activities), ], ), ), ); } - - List _allLeaves(List nodes) { - final result = []; - for (final node in nodes) { - if (node.children.isEmpty) { - result.add(node); - } else { - result.addAll(_allLeaves(node.children)); - } - } - return result; - } } class _InfoPanel extends StatelessWidget { @@ -216,20 +166,23 @@ class _InfoPanel extends StatelessWidget { children: [ Text( title, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: _mutedColor(context, 0.68)), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _mutedColor(context, 0.68), + ), ), const SizedBox(height: 4), Text( value, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 2), Text( subtitle, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: _mutedColor(context, 0.72)), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _mutedColor(context, 0.72), + ), ), ], ), @@ -256,102 +209,48 @@ class _ActivityPanel extends StatelessWidget { children: [ Text( '最近活动', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), - ...activities.take(8).map( - (item) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.bolt_rounded, size: 16), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w600), + ...activities + .take(8) + .map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.bolt_rounded, size: 16), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + item.detail, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: _mutedColor(context, 0.70), + ), + ), + ], ), - const SizedBox(height: 2), - Text( - item.detail, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: _mutedColor(context, 0.70)), - ), - ], - ), + ), + ], ), - ], + ), ), - ), - ), ], ), ), ); } } - -class _FeaturePanel extends StatelessWidget { - const _FeaturePanel({required this.onOpenNode}); - - final ValueChanged onOpenNode; - - @override - Widget build(BuildContext context) { - final nodes = _allLeaves(studioNavTree); - - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '功能覆盖', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 8), - Text( - '当前 Flutter 桌面端实现了 Studio 页面的核心交互:登录、导航、标签页、JSON/SQL 编辑、日志和运维任务。', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: _mutedColor(context, 0.72)), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: nodes - .take(5) - .map( - (node) => FilledButton.tonal( - onPressed: () => onOpenNode(node), - child: Text(node.title), - ), - ) - .toList(), - ), - ], - ), - ), - ); - } - - List _allLeaves(List nodes) { - final result = []; - for (final node in nodes) { - if (node.children.isEmpty) { - result.add(node); - } else { - result.addAll(_allLeaves(node.children)); - } - } - return result; - } -} diff --git a/lib/studio_app_pages_form_editor.dart b/lib/studio_app_pages_form_editor.dart index 6f23d08..d118286 100644 --- a/lib/studio_app_pages_form_editor.dart +++ b/lib/studio_app_pages_form_editor.dart @@ -21,7 +21,13 @@ class _StudioFormEditorPageState extends State { late final TextEditingController _codeController; late final TextEditingController _versionController; late final TextEditingController _remarkController; - late final CodeController _contentController; + 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; @@ -29,16 +35,28 @@ class _StudioFormEditorPageState extends State { String _originalSavePreview = ''; String _jsonErrorMessage = ''; int? _jsonErrorOffset; + int? _jsonErrorLine; String _status = '加载中'; String _detail = ''; List> _options = >[]; String _selectedKey = ''; + List> _navigatorStructure = >[]; + String _navigatorSelectedId = ''; + final Set _navigatorExpandedIds = {}; + 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 _isTableMode => widget.tab.id.toLowerCase().contains('table'); - bool get _isBillMode => - widget.tab.id.toLowerCase().contains('bill'); + bool get _isBillMode => widget.tab.id.toLowerCase().contains('bill'); bool get _isViewMode => widget.tab.id.toLowerCase().contains('view') || @@ -55,10 +73,21 @@ class _StudioFormEditorPageState extends State { _codeController = TextEditingController(text: widget.tab.id); _versionController = TextEditingController(text: '1'); _remarkController = TextEditingController(text: widget.tab.summary); - _contentController = CodeController( - text: '{}', - language: json_highlight.json, - ); + _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(); @@ -71,18 +100,39 @@ class _StudioFormEditorPageState extends State { _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() { - if (_jsonErrorMessage.isEmpty && _jsonErrorOffset == null) { - return; - } + final result = _decodeContent(); setState(() { - _jsonErrorMessage = ''; - _jsonErrorOffset = null; + if (result.isSuccess) { + _jsonErrorMessage = ''; + _jsonErrorOffset = null; + _jsonErrorLine = null; + } else { + _jsonErrorMessage = result.errorMessage; + _jsonErrorOffset = result.errorOffset; + _jsonErrorLine = _jsonLineFromOffset(result.errorOffset); + } }); + if (!result.isSuccess) { + _highlightJsonError(); + } } Future _loadInitialData() async { @@ -193,9 +243,8 @@ class _StudioFormEditorPageState extends State { Future _loadNavigator() async { try { - final navigator = await widget.runtime.client.fetchCreationNavigatorRecord( - session: widget.runtime.session, - ); + final navigator = await widget.runtime.client + .fetchCreationNavigatorRecord(session: widget.runtime.session); if (!mounted) { return; } @@ -205,26 +254,165 @@ class _StudioFormEditorPageState extends State { ) : >[]; setState(() { - _nameController.text = widget.tab.title; - _codeController.text = widget.tab.id; + _navigatorStructure = structure; + _navigatorExpandedIds.clear(); + _navigatorSelectedId = ''; _versionController.text = _readValue(navigator, const ['version'], '1'); - _remarkController.text = widget.tab.summary; - _contentController.text = _prettyJson(navigator.isNotEmpty - ? navigator - : {'structure': structure}); + _contentController.text = _prettyJson( + navigator.isNotEmpty + ? navigator + : {'structure': structure}, + ); _status = '导航数据已加载'; _detail = 'root items: ${structure.length}'; _loadedExisting = true; _loading = false; }); - _captureOriginalSavePreview(navigator.isNotEmpty - ? navigator - : {'structure': structure}); + final defaultSelectedId = + _navigatorDefaultSelectedId() ?? + (structure.isNotEmpty + ? _readValue(structure.first, const ['id']) + : ''); + if (defaultSelectedId.isNotEmpty) { + _selectNavigatorNode(defaultSelectedId, scrollIntoView: false); + } + _refreshNavigatorContent(); + _captureOriginalSavePreview( + navigator.isNotEmpty + ? navigator + : {'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? _navigatorNodeById(String id) { + for (final item in _navigatorStructure) { + if (_readValue(item, const ['id']) == id) { + return item; + } + } + return null; + } + + List> _navigatorChildrenOf(String parentId) { + return _navigatorStructure + .where((item) => _readValue(item, const ['parent']) == parentId) + .toList(); + } + + List _navigatorAncestorIds(String id) { + final ancestors = []; + 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 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 = { + '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 _loadTableLikeItem() async { final code = _selectedKey; if (code.isEmpty) { @@ -331,10 +519,18 @@ class _StudioFormEditorPageState extends State { String? version, }) { setState(() { - _nameController.text = _readValue(item, const ['name', 'title'], widget.tab.title); + _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); + _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; @@ -365,10 +561,7 @@ class _StudioFormEditorPageState extends State { ) : >[]; final version = int.tryParse(_versionController.text.trim()) ?? 1; - return { - 'version': version, - 'structure': structure, - }; + return {'version': version, 'structure': structure}; } final payload = parsed is Map @@ -430,7 +623,11 @@ class _StudioFormEditorPageState extends State { return '$tid::$oid'; } - String _readValue(Map item, List keys, [String fallback = '']) { + String _readValue( + Map item, + List keys, [ + String fallback = '', + ]) { for (final key in keys) { final value = item[key]; if (value != null && value.toString().trim().isNotEmpty) { @@ -499,10 +696,17 @@ class _StudioFormEditorPageState extends State { if (!mounted) { return; } + final resultMessage = result.message.isNotEmpty + ? result.message + : result.success + ? '保存已完成' + : '保存未成功'; setState(() { _loadedExisting = true; - _status = result.success ? '保存成功' : '保存失败'; - _detail = _prettyJson(result.raw); + _status = result.success + ? '保存成功:$resultMessage' + : '保存失败:$resultMessage'; + _detail = _buildActionResultDetail(result); if (result.success) { _jsonErrorMessage = ''; _jsonErrorOffset = null; @@ -511,7 +715,12 @@ class _StudioFormEditorPageState extends State { if (result.success) { _originalSavePreview = previewText; } - widget.onAction(wasExisting ? '更新' : '创建', widget.tab.title); + widget.onAction( + result.success + ? (wasExisting ? '更新成功' : '创建成功') + : '保存失败', + '${widget.tab.title} / $resultMessage', + ); } catch (error, stackTrace) { _reportError('保存失败', error, stackTrace); } finally { @@ -590,13 +799,29 @@ class _StudioFormEditorPageState extends State { ); } + 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 _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; + final addedCount = diffLines + .where((line) => line.type == _JsonDiffType.added) + .length; + final removedCount = diffLines + .where((line) => line.type == _JsonDiffType.removed) + .length; return await showDialog( context: context, barrierDismissible: false, @@ -635,11 +860,16 @@ class _StudioFormEditorPageState extends State { ), ), 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]); + return _buildJsonDiffLine( + context, + diffLines[index], + ); }, ), ), @@ -683,7 +913,9 @@ class _StudioFormEditorPageState extends State { 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]; + lcs[i][j] = lcs[i + 1][j] >= lcs[i][j + 1] + ? lcs[i + 1][j] + : lcs[i][j + 1]; } } } @@ -753,6 +985,18 @@ class _StudioFormEditorPageState extends State { } } + 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) { @@ -760,6 +1004,7 @@ class _StudioFormEditorPageState extends State { _status = 'JSON 格式错误,无法格式化'; _jsonErrorMessage = result.errorMessage; _jsonErrorOffset = result.errorOffset; + _jsonErrorLine = _jsonLineFromOffset(result.errorOffset); _detail = result.errorMessage; }); _highlightJsonError(); @@ -770,22 +1015,16 @@ class _StudioFormEditorPageState extends State { _status = 'JSON 已格式化'; _jsonErrorMessage = ''; _jsonErrorOffset = null; + _jsonErrorLine = null; _detail = ''; }); widget.onAction('格式化 JSON', widget.tab.title); } void _highlightJsonError() { - final offset = _jsonErrorOffset; - if (offset == null) { + if (!mounted || _jsonErrorMessage.isEmpty) { return; } - final text = _contentController.text; - if (text.isEmpty) { - return; - } - final clamped = offset.clamp(0, text.length - 1) as int; - _contentController.selection = TextSelection(baseOffset: clamped, extentOffset: clamped + 1); } void _clearJsonError() { @@ -795,6 +1034,7 @@ class _StudioFormEditorPageState extends State { setState(() { _jsonErrorMessage = ''; _jsonErrorOffset = null; + _jsonErrorLine = null; }); } @@ -830,6 +1070,44 @@ class _StudioFormEditorPageState extends State { } Future _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 = '当前内容还没有保存过,无需删除'; @@ -873,78 +1151,139 @@ class _StudioFormEditorPageState extends State { } } + 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 SingleChildScrollView( - child: LayoutBuilder( - builder: (context, constraints) { - final leftPanel = _buildEditorPanel(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: [ - 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('删除'), - ), - ], - ), - ], - ), - ), - ), + headerCard, const SizedBox(height: 12), - leftPanel, + Expanded(child: _buildNavigatorWorkspace(context)), ], ); - }, - ), + } + + final bodyPanel = _buildEditorPanel(context); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [headerCard, const SizedBox(height: 12), bodyPanel], + ), + ); + }, ); } @@ -957,8 +1296,9 @@ class _StudioFormEditorPageState extends State { children: [ Text( '属性编辑', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 10), if (_options.isNotEmpty && !_isNavigatorMode) ...[ @@ -972,17 +1312,23 @@ class _StudioFormEditorPageState extends State { hintText: _isViewMode ? '搜索并选择 tid / oid' : '搜索并选择模型', inputDecorationTheme: const InputDecorationTheme( isDense: true, - contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), ), expandedInsets: EdgeInsets.zero, dropdownMenuEntries: _options.map((item) { - final value = _isViewMode ? _viewItemKey(item) : _itemKey(item); + final value = _isViewMode + ? _viewItemKey(item) + : _itemKey(item); return DropdownMenuEntry( value: value, label: _itemLabel(item), ); }).toList(), - filterCallback: (entries, filter) => _filterModelEntries(entries, filter), + filterCallback: (entries, filter) => + _filterModelEntries(entries, filter), onSelected: (value) { if (value == null || value.isEmpty) { return; @@ -995,142 +1341,178 @@ class _StudioFormEditorPageState extends State { ), const SizedBox(height: 10), ], - LayoutBuilder( - builder: (context, constraints) { - final useTwoColumns = constraints.maxWidth >= 620; - - final nameField = TextField( - controller: _nameController, - decoration: const InputDecoration( - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: Icon(Icons.title_rounded), - ), - ); - final codeField = TextField( - controller: _codeController, - decoration: InputDecoration( - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: const Icon(Icons.code_rounded), - ), - ); - final versionField = TextField( - controller: _versionController, - decoration: InputDecoration( - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: const Icon(Icons.sell_rounded), - ), - ); - final remarkField = TextField( - controller: _remarkController, - maxLines: 2, - decoration: const InputDecoration( - isDense: true, - floatingLabelBehavior: FloatingLabelBehavior.never, - prefixIcon: Icon(Icons.notes_rounded), - ), - ); - - if (!useTwoColumns) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - nameField, - const SizedBox(height: 10), - codeField, - const SizedBox(height: 10), - versionField, - const SizedBox(height: 10), - remarkField, - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - nameField, - const SizedBox(height: 10), - codeField, - ], - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - versionField, - const SizedBox(height: 10), - remarkField, - ], - ), - ), - ], - ); - }, - ), const SizedBox(height: 10), Text( 'JSON 内容', - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), SizedBox( - height: 300, - child: _isNavigatorMode - ? _buildNavigatorJsonTree(context) - : CodeTheme( - data: CodeThemeData( - styles: _jsonCodeTheme(context), - ), - child: CodeField( - controller: _contentController, - expands: true, - wrap: true, - lineNumbers: false, - isDense: true, - keyboardType: TextInputType.multiline, - textStyle: const TextStyle( - fontFamily: 'monospace', - fontSize: 13.5, - height: 1.45, + 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), ), - cursorColor: Theme.of(context).colorScheme.primary, - background: Colors.transparent, - decoration: BoxDecoration( - color: _softPanelColor(context), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _jsonErrorMessage.isEmpty - ? Theme.of(context).colorScheme.outlineVariant - : Theme.of(context).colorScheme.error, - width: _jsonErrorMessage.isEmpty ? 1 : 1.5, + ), + 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), ), ), - padding: const EdgeInsets.all(12), - textSelectionTheme: TextSelectionThemeData( + 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, - selectionColor: Theme.of( + decoration: const InputDecoration( + border: InputBorder.none, + isCollapsed: true, + contentPadding: EdgeInsets.fromLTRB(12, 10, 12, 12), + ), + keyHighlightStyle: _jsonSyntaxStyle( context, - ).colorScheme.primary.withOpacity(0.20), + 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( - _jsonErrorMessage, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.error, + 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, + ), + ), + ], ), ), ], @@ -1140,167 +1522,514 @@ class _StudioFormEditorPageState extends State { ); } - Widget _buildNavigatorJsonTree(BuildContext context) { - final parsed = _decodeContent(); - return Container( - decoration: BoxDecoration( - color: _softPanelColor(context), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - child: !parsed.isSuccess - ? const Center( - child: SelectableText('JSON 格式错误,无法显示树节点'), - ) - : Scrollbar( - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: _buildJsonTreeNode( - context, - value: parsed.value, - label: 'JSON', - isRoot: true, - ), + 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 _buildJsonTreeNode( - BuildContext context, { - required dynamic value, - String? label, - bool isRoot = false, + Widget _buildNavigatorTreeNode( + BuildContext context, + Map node, { + required int depth, }) { - if (value is Map) { - final entries = value.entries.toList(); - return ExpansionTile( - key: PageStorageKey( - 'json-map-${label ?? 'root'}-${entries.length}-${isRoot ? 'root' : 'node'}', - ), - initiallyExpanded: isRoot, - tilePadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), - childrenPadding: const EdgeInsets.only(left: 16, right: 4, bottom: 4), - leading: CircleAvatar( - radius: 14, - backgroundColor: Theme.of(context).colorScheme.primaryContainer - .withOpacity(_isDarkTheme(context) ? 0.16 : 0.55), - child: const Icon(Icons.data_object_rounded, size: 16), - ), - title: Text(label ?? '对象'), - subtitle: Text('${entries.length} 项'), - children: entries - .map( - (entry) => _buildJsonEntryNode( - context, - key: entry.key.toString(), - value: entry.value, - ), - ) - .toList(), - ); - } + 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; - if (value is List) { - return ExpansionTile( - key: PageStorageKey( - 'json-list-${label ?? 'root'}-${value.length}-${isRoot ? 'root' : 'node'}', - ), - initiallyExpanded: isRoot, - tilePadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), - childrenPadding: const EdgeInsets.only(left: 16, right: 4, bottom: 4), - leading: CircleAvatar( - radius: 14, - backgroundColor: Theme.of(context).colorScheme.primaryContainer - .withOpacity(_isDarkTheme(context) ? 0.16 : 0.55), - child: const Icon(Icons.list_alt_rounded, size: 16), - ), - title: Text(label ?? '数组'), - subtitle: Text('${value.length} 项'), - children: List.generate( - value.length, - (index) => _buildJsonListItemNode( - context, - index: index, - value: value[index], + 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, + ), + ), + ), + ], + ), ), ), - ); - } - - return _buildJsonLeafNode(context, label: label ?? '值', value: value); + if (hasChildren && isExpanded) + ...children.map( + (child) => + _buildNavigatorTreeNode(context, child, depth: depth + 1), + ), + ], + ); } - Widget _buildJsonEntryNode( - BuildContext context, { - required String key, - required dynamic value, - }) { - if (value is Map || value is List) { - return Padding( - padding: const EdgeInsets.only(left: 4), - child: _buildJsonTreeNode( - context, - value: value, - label: key, - ), - ); + IconData _navigatorNodeIcon(Map node) { + final id = _readValue(node, const ['id']); + final iconText = _readValue(node, const ['icon']).toLowerCase(); + if (id == 'root') { + return Icons.home_rounded; } - return _buildJsonLeafNode(context, label: key, value: value); + 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 _buildJsonListItemNode( - BuildContext context, { - required int index, - required dynamic value, - }) { - final label = '[$index]'; - if (value is Map || value is List) { - return Padding( - padding: const EdgeInsets.only(left: 4), - child: _buildJsonTreeNode( - context, - value: value, - label: label, - ), - ); - } - return _buildJsonLeafNode(context, label: label, value: value); - } - - Widget _buildJsonLeafNode( - BuildContext context, { - required String label, - required dynamic value, - }) { - final theme = Theme.of(context); - return ListTile( - dense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), - leading: Icon( - Icons.label_outline_rounded, - size: 18, - color: theme.colorScheme.primary, - ), - title: Text(label), - trailing: SelectableText( - _jsonScalarText(value), - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', + 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, + ), + ), + ), + ], + ), ), ), ); } - String _jsonScalarText(dynamic value) { - return jsonEncode(value); + Widget _buildNavigatorTextRow( + BuildContext context, { + required String label, + required TextEditingController controller, + required ValueChanged 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, + ), + ), + ), + ), + ], + ); } - Map _jsonCodeTheme(BuildContext context) { - final isDark = _isDarkTheme(context); - return isDark ? atomOneDarkTheme : atomOneLightTheme; + Widget _buildNavigatorMultilineRow( + BuildContext context, { + required String label, + required TextEditingController controller, + required int minLines, + required ValueChanged 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 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, + ); } } @@ -1344,4 +2073,3 @@ class _JsonDiffLine { final _JsonDiffType type; final String text; } - diff --git a/lib/studio_app_pages_portal.dart b/lib/studio_app_pages_portal.dart index 9d60f78..74c01ee 100644 --- a/lib/studio_app_pages_portal.dart +++ b/lib/studio_app_pages_portal.dart @@ -6,11 +6,13 @@ class StudioPortalMaintainPage extends StatefulWidget { required this.tab, required this.runtime, required this.onAction, + required this.onOpenMenu, }); final StudioTab tab; final StudioRuntime runtime; final void Function(String title, String detail) onAction; + final VoidCallback onOpenMenu; @override State createState() => _StudioPortalMaintainPageState(); @@ -108,6 +110,12 @@ class _StudioPortalMaintainPageState extends State { icon: const Icon(Icons.refresh_rounded), label: const Text('刷新'), ), + const SizedBox(width: 10), + FilledButton.tonalIcon( + onPressed: widget.onOpenMenu, + icon: const Icon(Icons.menu_rounded), + label: const Text('设置菜单'), + ), ], ), const SizedBox(height: 12), diff --git a/lib/studio_app_pages_sql.dart b/lib/studio_app_pages_sql.dart index 1c62433..3753f4a 100644 --- a/lib/studio_app_pages_sql.dart +++ b/lib/studio_app_pages_sql.dart @@ -39,8 +39,7 @@ class _StudioSqlPageState extends State { void initState() { super.initState(); _sqlController = CodeController( - text: - 'select id, code, name from item order by id desc limit 20', + text: 'select id, code, name from item order by id desc limit 20', language: sql_highlight.sql, ); _limitController = TextEditingController(text: '100'); @@ -157,10 +156,20 @@ class _StudioSqlPageState extends State { 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'], '{}'); + _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'; }); @@ -319,7 +328,11 @@ class _StudioSqlPageState extends State { return text; } - String _readValue(Map map, List keys, [String fallback = '']) { + String _readValue( + Map map, + List keys, [ + String fallback = '', + ]) { for (final key in keys) { final value = map[key]; if (value != null && value.toString().trim().isNotEmpty) { @@ -354,58 +367,40 @@ class _StudioSqlPageState extends State { } Widget _buildStatusGrid(BuildContext context) { - final rows = [ - DataRow( - cells: [ - const DataCell(Text('状态')), - DataCell(SelectableText(_status)), - ], - ), - DataRow( - cells: [ - const DataCell(Text('详情')), - DataCell( - SelectableText( - _detail.isEmpty ? '-' : _detail, - style: const TextStyle(fontFamily: 'monospace', height: 1.45), - ), - ), - ], - ), - DataRow( - cells: [ - const DataCell(Text('结果')), - DataCell( - SelectableText( - _resultRows.isEmpty - ? '无结果' - : '共 ${_resultRows.length} 行,${_resultColumns.length} 列', - ), - ), - ], + final items = <_StatusItem>[ + _StatusItem('状态', _status), + _StatusItem('详情', _detail.isEmpty ? '-' : _detail), + _StatusItem( + '结果', + _resultRows.isEmpty + ? '无结果' + : '共 ${_resultRows.length} 行,${_resultColumns.length} 列', ), + if (_isTemplateMode) + _StatusItem('模板', '${_selectedTid()} / ${_selectedOid()}'), ]; - if (_isTemplateMode) { - rows.add( - DataRow( - cells: [ - const DataCell(Text('模板')), - DataCell(SelectableText('${_selectedTid()} / ${_selectedOid()}')), - ], - ), - ); - } - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: const [ - DataColumn(label: Text('字段')), - DataColumn(label: Text('内容')), - ], - rows: rows, - ), + 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(), + ); + }, ); } @@ -428,7 +423,9 @@ class _StudioSqlPageState extends State { value: tids.contains(selectedTid) ? selectedTid : null, decoration: const InputDecoration(labelText: 'tid'), items: tids - .map((tid) => DropdownMenuItem(value: tid, child: Text(tid))) + .map( + (tid) => DropdownMenuItem(value: tid, child: Text(tid)), + ) .toList(), onChanged: (value) { if (value == null) { @@ -436,7 +433,8 @@ class _StudioSqlPageState extends State { } setState(() { _selectedTidValue = value; - _selectedOidValue = (_templateIndex[value] ?? const []).isNotEmpty + _selectedOidValue = + (_templateIndex[value] ?? const []).isNotEmpty ? _templateIndex[value]!.first : ''; }); @@ -449,7 +447,9 @@ class _StudioSqlPageState extends State { value: oids.contains(_selectedOid()) ? _selectedOid() : null, decoration: const InputDecoration(labelText: 'oid'), items: oids - .map((oid) => DropdownMenuItem(value: oid, child: Text(oid))) + .map( + (oid) => DropdownMenuItem(value: oid, child: Text(oid)), + ) .toList(), onChanged: (value) { if (value == null) { @@ -511,114 +511,188 @@ class _StudioSqlPageState extends State { @override Widget build(BuildContext context) { - if (_isTemplateMode && _selectedTidValue.isEmpty && _templateIndex.isNotEmpty) { + if (_isTemplateMode && + _selectedTidValue.isEmpty && + _templateIndex.isNotEmpty) { _selectedTidValue = _templateIndex.keys.first; _selectedOidValue = _templateIndex[_selectedTidValue]!.first; } - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_loading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: CircularProgressIndicator()), - ) - else if (_isTemplateMode) - _buildTemplateBody(context) - else - _buildSqlEditors(context, includeTemplateFields: false), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + 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: [ - Row( - children: [ - Expanded( - child: Text( - '执行状态', - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), + 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), + ], + ], ), - 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: 8), - _buildStatusGrid(context), ], ), ), - ), - 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 _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 _buildSqlEditors(BuildContext context, {required bool includeTemplateFields}) { + 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), @@ -642,7 +716,9 @@ class _StudioSqlPageState extends State { width: 200, child: TextField( controller: _summarySqlController, - decoration: const InputDecoration(labelText: 'summary SQL'), + decoration: const InputDecoration( + labelText: 'summary SQL', + ), maxLines: 3, ), ), @@ -671,9 +747,7 @@ class _StudioSqlPageState extends State { SizedBox( height: 240, child: CodeTheme( - data: CodeThemeData( - styles: _sqlCodeTheme(context), - ), + data: CodeThemeData(styles: _sqlCodeTheme(context)), child: CodeField( controller: _sqlController, expands: true, @@ -743,3 +817,42 @@ class _StudioSqlPageState extends State { } } +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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/studio_app_shell.dart b/lib/studio_app_shell.dart index 276c961..3e9140a 100644 --- a/lib/studio_app_shell.dart +++ b/lib/studio_app_shell.dart @@ -230,7 +230,6 @@ class _StudioShellState extends State { activities: List.unmodifiable( _activities, ), - rootNodes: visibleRoots, refreshToken: _refreshRevision, onSelectTab: _selectTab, onCloseTab: _closeTab, @@ -250,7 +249,6 @@ class _StudioShellState extends State { selectedTab: _selectedTab, tabs: List.unmodifiable(_tabs), activities: List.unmodifiable(_activities), - rootNodes: visibleRoots, refreshToken: _refreshRevision, onSelectTab: _selectTab, onCloseTab: _closeTab, @@ -266,4 +264,3 @@ class _StudioShellState extends State { ); } } - diff --git a/lib/studio_app_shell_sidebar.dart b/lib/studio_app_shell_sidebar.dart index b681562..6163433 100644 --- a/lib/studio_app_shell_sidebar.dart +++ b/lib/studio_app_shell_sidebar.dart @@ -186,13 +186,24 @@ class _SidebarHero extends StatelessWidget { ], ), const SizedBox(height: 14), - FilledButton.tonalIcon( - onPressed: onLogout, - icon: const Icon(Icons.logout_rounded), - label: const Text('退出登录'), + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: onLogout, + icon: const Icon(Icons.logout_rounded), + label: const Text('退出登录'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ThemeToggleButton( + themeMode: themeMode, + onPressed: onToggleTheme, + ), + ), + ], ), - const SizedBox(height: 8), - _ThemeToggleButton(themeMode: themeMode, onPressed: onToggleTheme), ], ), ); diff --git a/lib/studio_app_shell_workspace.dart b/lib/studio_app_shell_workspace.dart index 0ea446d..5425d6a 100644 --- a/lib/studio_app_shell_workspace.dart +++ b/lib/studio_app_shell_workspace.dart @@ -8,7 +8,6 @@ class StudioWorkspaceView extends StatelessWidget { required this.selectedTab, required this.tabs, required this.activities, - required this.rootNodes, required this.refreshToken, required this.onSelectTab, required this.onCloseTab, @@ -25,7 +24,6 @@ class StudioWorkspaceView extends StatelessWidget { final StudioTab selectedTab; final List tabs; final List activities; - final List rootNodes; final int refreshToken; final ValueChanged onSelectTab; final ValueChanged onCloseTab; @@ -66,20 +64,12 @@ class StudioWorkspaceView extends StatelessWidget { key: ValueKey('${selectedTab.id}:$refreshToken'), tab: selectedTab, activities: activities, - rootNodes: rootNodes, runtime: runtime, onPageAction: onPageAction, onOpenNode: onOpenNode, ), ), ), - const SizedBox(height: 8), - _StudioStatusBar( - user: user, - selectedTab: selectedTab, - tabsCount: tabs.length, - activitiesCount: activities.length, - ), ], ), ); @@ -89,7 +79,6 @@ class StudioWorkspaceView extends StatelessWidget { required Key key, required StudioTab tab, required List activities, - required List rootNodes, required StudioRuntime runtime, required void Function(String title, String detail) onPageAction, required ValueChanged onOpenNode, @@ -101,8 +90,6 @@ class StudioWorkspaceView extends StatelessWidget { tab: tab, user: user, activities: activities, - rootNodes: rootNodes, - onOpenNode: onOpenNode, ); case StudioPageType.history: return StudioHistoryPage( @@ -133,6 +120,11 @@ class StudioWorkspaceView extends StatelessWidget { tab: tab, runtime: runtime, onAction: onPageAction, + onOpenMenu: () => onOpenNode( + studioNavTree + .expand((node) => [node, ...node.children]) + .firstWhere((node) => node.id == 'NavigationEditor'), + ), ); default: return StudioFormEditorPage( @@ -239,42 +231,3 @@ class _TabStrip extends StatelessWidget { ); } } - -class _StudioStatusBar extends StatelessWidget { - const _StudioStatusBar({ - required this.user, - required this.selectedTab, - required this.tabsCount, - required this.activitiesCount, - }); - - final StudioUser user; - final StudioTab selectedTab; - final int tabsCount; - final int activitiesCount; - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Wrap( - spacing: 10, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _MiniBadge(text: '用户 ${user.displayName}'), - _MiniBadge(text: '工作区 ${user.workspace}'), - if (user.backendAppName.isNotEmpty) - _MiniBadge(text: '应用 ${user.backendAppName}'), - if (user.organizationName.isNotEmpty) - _MiniBadge(text: '机构 ${user.organizationName}'), - _MiniBadge(text: '当前页 ${selectedTab.title}'), - _MiniBadge(text: '标签 ${tabsCount}'), - _MiniBadge(text: '活动 ${activitiesCount}'), - ], - ), - ), - ); - } -} diff --git a/lib/studio_app_theme.dart b/lib/studio_app_theme.dart index 5417bb4..c35a3df 100644 --- a/lib/studio_app_theme.dart +++ b/lib/studio_app_theme.dart @@ -28,7 +28,9 @@ List _shellGradientColors(BuildContext context) { ThemeData buildStudioTheme(Brightness brightness) { final colorScheme = ColorScheme.fromSeed( - seedColor: const Color(0xFF1AA6A6), + seedColor: brightness == Brightness.light + ? const Color(0xFF1275DA) + : const Color(0xFF1AA6A6), brightness: brightness, ); final isDark = brightness == Brightness.dark; @@ -45,7 +47,9 @@ ThemeData buildStudioTheme(Brightness brightness) { : const Color(0xFFF6F8FB), appBarTheme: AppBarTheme( centerTitle: false, - backgroundColor: isDark ? const Color(0xFF07111A) : const Color(0xFFF8FBFF), + backgroundColor: isDark + ? const Color(0xFF07111A) + : const Color(0xFFF8FBFF), foregroundColor: colorScheme.onSurface, elevation: 0, ), @@ -77,8 +81,14 @@ ThemeData buildStudioTheme(Brightness brightness) { borderSide: BorderSide(color: colorScheme.primary), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - prefixIconConstraints: const BoxConstraints.tightFor(width: 34, height: 34), - suffixIconConstraints: const BoxConstraints.tightFor(width: 34, height: 34), + prefixIconConstraints: const BoxConstraints.tightFor( + width: 34, + height: 34, + ), + suffixIconConstraints: const BoxConstraints.tightFor( + width: 34, + height: 34, + ), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( @@ -117,8 +127,9 @@ class _MiniBadge extends StatelessWidget { ), child: Text( text, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: _mutedColor(context, 0.78)), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: _mutedColor(context, 0.78)), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 9c2f323..b62f191 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,9 +37,13 @@ dependencies: crypto: ^3.0.6 dio: ^5.8.0 file_selector: ^1.0.3 + flutter_highlight: ^0.7.0 pointycastle: ^4.0.0 shared_preferences: ^2.3.2 code_text_field: ^1.1.0 + highlight: ^0.7.0 + json_field_editor: ^1.2.1 + linked_scroll_controller: ^0.2.0 dev_dependencies: flutter_test: