SHA256
552 lines
16 KiB
Dart
552 lines
16 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> {
|
|
late final TextEditingController _backendUrlController;
|
|
late final TextEditingController _workspaceController;
|
|
late final TextEditingController _usernameController;
|
|
late final TextEditingController _passwordController;
|
|
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();
|
|
_loadLoginConfig();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_backendUrlController.dispose();
|
|
_workspaceController.dispose();
|
|
_usernameController.dispose();
|
|
_passwordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|