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

612 lines
18 KiB
Dart

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<StudioBootstrap> createState() => _StudioBootstrapState();
}
class _StudioBootstrapState extends State<StudioBootstrap> {
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<StudioRuntime> onLogin;
@override
State<StudioLoginView> createState() => _StudioLoginViewState();
}
class _StudioLoginViewState extends State<StudioLoginView> {
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<String, dynamic> _loginConfig = <String, dynamic>{};
@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<void> _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<void> _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<void> _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<void> _loadLoginConfig() async {
try {
final client = StudioBackendClient(
baseUrl: _backendUrlController.text,
languageCode: widget.languageCode,
);
final config = await client.fetchLoginPageConfig();
if (!mounted) {
return;
}
setState(() {
_loginConfig = config ?? <String, dynamic>{};
});
_applyLoginConfig(_loginConfig);
} catch (error, stackTrace) {
_printFullError('加载登录配置失败', error, stackTrace);
if (!mounted) {
return;
}
setState(() {
// 登录页的配置读取只做“增强体验”;就算后端暂时不可达,用户仍然可以手动输入地址继续登录。
_errorMessage = '后端配置暂时不可用,仍可继续尝试登录';
});
}
}
Future<void> _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 <String, dynamic>{},
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<String, dynamic> 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<String, dynamic> 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,
),
),
],
),
),
],
),
);
}
}