part of 'studio_app.dart'; class StudioBootstrap extends StatefulWidget { const StudioBootstrap({ super.key, required this.languageCode, required this.themeMode, required this.onToggleTheme, }); final String languageCode; final ThemeMode themeMode; final VoidCallback onToggleTheme; @override State createState() => _StudioBootstrapState(); } class _StudioBootstrapState extends State { StudioRuntime? _runtime; void _handleLogin(StudioRuntime runtime) { setState(() { _runtime = runtime; }); } void _handleLogout() { final runtime = _runtime; if (runtime == null) { return; } runtime.client .logout(session: runtime.session) .catchError((_) {}) .whenComplete(() { if (!mounted) { return; } setState(() { _runtime = null; }); }); } @override Widget build(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 260), child: _runtime == null ? StudioLoginView( key: const ValueKey('studio-login'), languageCode: widget.languageCode, themeMode: widget.themeMode, onToggleTheme: widget.onToggleTheme, onLogin: _handleLogin, ) : StudioShell( key: const ValueKey('studio-shell'), runtime: _runtime!, languageCode: widget.languageCode, themeMode: widget.themeMode, onToggleTheme: widget.onToggleTheme, onLogout: _handleLogout, ), ); } } class StudioLoginView extends StatefulWidget { const StudioLoginView({ super.key, required this.languageCode, required this.themeMode, required this.onToggleTheme, required this.onLogin, }); final String languageCode; final ThemeMode themeMode; final VoidCallback onToggleTheme; final ValueChanged onLogin; @override State createState() => _StudioLoginViewState(); } class _StudioLoginViewState extends State { static const String _prefsBackendUrlKey = 'studio_login_backend_url'; static const String _prefsWorkspaceKey = 'studio_login_workspace'; static const String _prefsUsernameKey = 'studio_login_username'; late final TextEditingController _backendUrlController; late final TextEditingController _workspaceController; late final TextEditingController _usernameController; late final TextEditingController _passwordController; Timer? _rememberTimer; bool _loading = false; String? _errorMessage; Map _loginConfig = {}; @override void initState() { super.initState(); _backendUrlController = TextEditingController( text: 'http://localhost:9001/', ); _workspaceController = TextEditingController(text: _defaultWorkspace); _usernameController = TextEditingController(); _passwordController = TextEditingController(); _backendUrlController.addListener(_queueRememberInputs); _workspaceController.addListener(_queueRememberInputs); _usernameController.addListener(_queueRememberInputs); unawaited(_initializeLoginForm()); } @override void dispose() { _rememberTimer?.cancel(); _backendUrlController.dispose(); _workspaceController.dispose(); _usernameController.dispose(); _passwordController.dispose(); super.dispose(); } Future _restoreRememberedInputs() async { final prefs = await SharedPreferences.getInstance(); if (!mounted) { return; } final backendUrl = prefs.getString(_prefsBackendUrlKey)?.trim() ?? ''; final workspace = prefs.getString(_prefsWorkspaceKey)?.trim() ?? ''; final username = prefs.getString(_prefsUsernameKey)?.trim() ?? ''; if (backendUrl.isNotEmpty) { _backendUrlController.text = backendUrl; } if (workspace.isNotEmpty) { _workspaceController.text = _normalizeWorkspace(workspace); } if (username.isNotEmpty) { _usernameController.text = username; } } Future _initializeLoginForm() async { await _restoreRememberedInputs(); await _loadLoginConfig(); } void _queueRememberInputs() { if (!mounted) { return; } _rememberTimer?.cancel(); _rememberTimer = Timer(const Duration(milliseconds: 250), () { if (!mounted) { return; } unawaited(_rememberInputs()); }); } Future _rememberInputs() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_prefsBackendUrlKey, _backendUrlController.text.trim()); await prefs.setString( _prefsWorkspaceKey, _normalizeWorkspace(_workspaceController.text), ); await prefs.setString(_prefsUsernameKey, _usernameController.text.trim()); } Future _loadLoginConfig() async { try { final client = StudioBackendClient( baseUrl: _backendUrlController.text, languageCode: widget.languageCode, ); final config = await client.fetchLoginPageConfig(); if (!mounted) { return; } setState(() { _loginConfig = config ?? {}; }); _applyLoginConfig(_loginConfig); } catch (error, stackTrace) { _printFullError('加载登录配置失败', error, stackTrace); if (!mounted) { return; } setState(() { // 登录页的配置读取只做“增强体验”;就算后端暂时不可达,用户仍然可以手动输入地址继续登录。 _errorMessage = '后端配置暂时不可用,仍可继续尝试登录'; }); } } Future _submit() async { final workspace = _normalizeWorkspace(_workspaceController.text); final username = _usernameController.text.trim(); final password = _passwordController.text; final backendUrl = _backendUrlController.text.trim(); if (workspace.isEmpty || username.isEmpty || password.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('请先填写工作区、用户名和密码'))); return; } if (_workspaceController.text.trim() != workspace) { _workspaceController.text = workspace; } if (_loading) { return; } setState(() { _loading = true; _errorMessage = null; }); try { final client = StudioBackendClient( baseUrl: backendUrl, languageCode: widget.languageCode, ); final loginResult = await client.login( workspace: workspace, username: username, password: password, authType: '', ); if (!loginResult.success || loginResult.session == null) { final fallbackMessage = loginResult.success ? '登录成功但未返回会话信息' : '后端登录失败,code=${loginResult.code.isEmpty ? '空' : loginResult.code}'; throw _LoginFailureException( loginResult.message.isNotEmpty ? loginResult.message : fallbackMessage, code: loginResult.code, raw: loginResult.raw, ); } final session = loginResult.session!; final profile = await client.fetchUserProfile(session); final org = await client.fetchOrgByUser(session); final menuIds = await client.fetchPermissionIds(session); final appName = _extractAppName(loginResult.appConfig) ?? _extractAppName(loginResult.systemConfig) ?? _extractAppName(_loginConfig); final organizationName = org?.name ?? ''; final backendAppName = appName ?? 'TTX Studio'; if (!mounted) { return; } unawaited(_rememberInputs()); widget.onLogin( StudioRuntime( backendUrl: backendUrl, client: client, session: session, user: StudioUser( workspace: session.db.isNotEmpty ? session.db : workspace, displayName: (profile?.name.isNotEmpty ?? false) ? profile!.name : username, role: profile?.isDeveloper == true ? '开发者' : '管理员', languageCode: widget.languageCode, backendAppName: backendAppName, organizationName: organizationName, ), loginConfig: loginResult.appConfig ?? _loginConfig, systemConfig: loginResult.systemConfig ?? const {}, orgName: organizationName, allowedMenuIds: menuIds, profile: profile, ), ); } catch (error, stackTrace) { _printFullError('登录失败', error, stackTrace); if (!mounted) { return; } setState(() { _errorMessage = _normalizeErrorMessage(error); }); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(_errorMessage ?? '登录失败'))); } finally { if (mounted) { setState(() { _loading = false; }); } } } void _applyLoginConfig(Map config) { final backendUrl = _extractStringFromConfig(config, const [ 'server', 'url', 'loginPageServer', ]); final workspace = _extractStringFromConfig(config, const [ 'db', 'customer', 'workspace', ]); if (backendUrl.isNotEmpty && _backendUrlController.text.trim() == 'http://localhost:9001/') { _backendUrlController.text = backendUrl; } if (workspace.isNotEmpty && _workspaceController.text.trim() == _defaultWorkspace) { _workspaceController.text = _normalizeWorkspace(workspace); } } String _normalizeWorkspace(String value) { final workspace = value.trim(); if (workspace == 'ttx-xwms-tes') { return _defaultWorkspace; } return workspace; } String _normalizeErrorMessage(Object error) { if (error is _LoginFailureException) { return error.message; } final text = error.toString(); const marker = 'Bad state: '; if (text.startsWith(marker)) { return text.substring(marker.length); } return text; } void _printFullError(String title, Object error, StackTrace stackTrace) { final buffer = StringBuffer() ..writeln('========== $title ==========') ..writeln('error: $error') ..writeln('stackTrace:') ..writeln(stackTrace); if (error is _LoginFailureException) { buffer ..writeln('code: ${error.code.isEmpty ? '空' : error.code}') ..writeln('raw response:') ..writeln(const JsonEncoder.withIndent(' ').convert(error.raw)); } buffer.writeln('========== $title end ==========='); _debugPrintLong(buffer.toString()); } void _debugPrintLong(String text) { const chunkSize = 900; for (var start = 0; start < text.length; start += chunkSize) { final end = (start + chunkSize).clamp(0, text.length); debugPrint(text.substring(start, end)); } } @override Widget build(BuildContext context) { final backendHint = _backendUrlController.text.trim().isEmpty ? 'http://localhost:9001/' : _backendUrlController.text.trim(); return Scaffold( body: Container( decoration: BoxDecoration( gradient: LinearGradient( colors: _loginGradientColors(context), begin: Alignment.topLeft, end: Alignment.bottomRight, ), ), child: Stack( children: [ Positioned( right: 20, top: 20, child: SafeArea( child: _ThemeToggleButton( themeMode: widget.themeMode, onPressed: widget.onToggleTheme, ), ), ), Positioned( left: -120, top: -120, child: _GlowCircle( color: const Color(0xFF1AA6A6).withOpacity(0.20), ), ), Positioned( right: -100, bottom: -120, child: _GlowCircle( color: const Color(0xFFFFA94D).withOpacity(0.16), ), ), SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Card( clipBehavior: Clip.antiAlias, child: Padding( padding: const EdgeInsets.all(24), child: _LoginForm( backendUrlController: _backendUrlController, workspaceController: _workspaceController, usernameController: _usernameController, passwordController: _passwordController, loading: _loading, backendHint: backendHint, errorMessage: _errorMessage, onSubmit: _submit, ), ), ), ), ), ), ), ], ), ), ); } } class _LoginFailureException implements Exception { const _LoginFailureException( this.message, { required this.code, required this.raw, }); final String message; final String code; final Map raw; @override String toString() => message; } class _LoginForm extends StatelessWidget { const _LoginForm({ required this.backendUrlController, required this.workspaceController, required this.usernameController, required this.passwordController, required this.loading, required this.backendHint, required this.errorMessage, required this.onSubmit, }); final TextEditingController backendUrlController; final TextEditingController workspaceController; final TextEditingController usernameController; final TextEditingController passwordController; final bool loading; final String backendHint; final String? errorMessage; final VoidCallback onSubmit; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: _isDarkTheme(context) ? const Color(0xFF0C1524) : Colors.white, borderRadius: BorderRadius.circular(24), border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '后端登录', style: Theme.of(context).textTheme.headlineSmall ?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Text( '连接真实后端后,登录、权限菜单、用户信息和退出都会走服务端。', style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: _mutedColor(context)), ), const SizedBox(height: 16), TextField( controller: backendUrlController, decoration: InputDecoration( labelText: '后端地址', hintText: backendHint, prefixIcon: const Icon(Icons.link_rounded), ), keyboardType: TextInputType.url, onChanged: (_) {}, ), if (errorMessage != null && errorMessage!.isNotEmpty) ...[ const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.withOpacity(0.12), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.red.withOpacity(0.25)), ), child: Text( errorMessage!, style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: Colors.redAccent), ), ), ], const SizedBox(height: 20), TextField( controller: workspaceController, decoration: const InputDecoration( labelText: '工作区', prefixIcon: Icon(Icons.domain_rounded), ), ), const SizedBox(height: 14), TextField( controller: usernameController, decoration: const InputDecoration( labelText: '用户名', prefixIcon: Icon(Icons.person_rounded), ), ), const SizedBox(height: 14), TextField( controller: passwordController, obscureText: true, decoration: const InputDecoration( labelText: '密码', prefixIcon: Icon(Icons.lock_rounded), ), onSubmitted: (_) => loading ? null : onSubmit(), ), const SizedBox(height: 18), FilledButton.icon( onPressed: loading ? null : onSubmit, icon: loading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.login_rounded), label: Text(loading ? '正在连接...' : '进入 Studio'), ), const SizedBox(height: 14), Text( '默认连接到后端服务,登录后会进入桌面工作台。', style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: _mutedColor(context, 0.58)), ), const SizedBox(height: 10), Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: scheme.primary.withOpacity(0.12), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Icon(Icons.desktop_windows_rounded, color: scheme.primary), const SizedBox(width: 12), Expanded( child: Text( '打开后会进入桌面工作台,支持导航、标签页和编辑器。', style: Theme.of(context).textTheme.bodyMedium, ), ), ], ), ), ], ), ); } }