xworkmate-app/lib/features/settings/settings_account_panel.dart
Haitao Pan 5851196fc7 refactor(bridge): implement Single Source of Truth for bridge config and fix login persistence
- Refactor bridge configuration to resolve co-existing Manual and Cloud sources into a single persistent 'effective' state
- Implement deterministic priority resolution: Manual Bridge > Cloud Sync > Default Fallback
- Fix login issues by preserving account base URL and identifier during sync and session restoration
- Streamline config/settings.yaml by removing redundant fields and adopting YAML format
- Update documentation with new architecture guide for bridge-cloud coexistence and priority logic
- Verify functional connectivity for codex, gemini, opencode, and openclaw bridge services
2026-04-19 12:22:02 +08:00

619 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
class SettingsAccountPanel extends StatelessWidget {
const SettingsAccountPanel({
super.key,
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
this.accountStatus = '',
required this.accountSignedIn,
required this.accountMfaRequired,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountPasswordController,
required this.accountMfaCodeController,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
required this.onLogin,
required this.onVerifyMfa,
required this.onCancelMfa,
required this.onSync,
required this.onLogout,
});
final SettingsSnapshot settings;
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final bool accountSignedIn;
final bool accountMfaRequired;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final TextEditingController accountMfaCodeController;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveAccountProfile;
final Future<void> Function() onLogin;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
@override
Widget build(BuildContext context) {
if (!accountSignedIn && !accountMfaRequired) {
return DefaultTabController(
length: 2,
initialIndex: settings.acpBridgeServerModeConfig.effective.source == 'bridge'
? 1
: 0,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
// Switching tabs saves the profile, which triggers a resolution of the effective config.
// We don't need a boolean flag anymore; the presence/validity of sources determines the source.
// But we still want to save on tap to persist the user's intent.
onSaveAccountProfile();
},
),
const SizedBox(height: 24),
SizedBox(
height: 480,
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
_SignedOutAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountPasswordController: accountPasswordController,
onSaveAccountProfile: onSaveAccountProfile,
onLogin: onLogin,
),
_ManualBridgePanel(
settings: settings,
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveAccountProfile: onSaveAccountProfile,
),
],
),
),
],
),
);
}
if (accountMfaRequired) {
return _PendingMfaAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountMfaCodeController: accountMfaCodeController,
onVerifyMfa: onVerifyMfa,
onCancelMfa: onCancelMfa,
);
}
return _SignedInAccountPanel(
settings: settings,
accountSession: accountSession,
accountState: accountState,
accountBusy: accountBusy,
accountStatus: accountStatus,
onSync: onSync,
onLogout: onLogout,
);
}
}
class _ManualBridgePanel extends StatelessWidget {
const _ManualBridgePanel({
required this.settings,
required this.accountBusy,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
});
final SettingsSnapshot settings;
final bool accountBusy;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveAccountProfile;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.link_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('手动 Bridge 配置', 'Manual Bridge Config'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'直接配置本地或私有 xworkmate-bridge 地址与令牌。',
'Configure local or private xworkmate-bridge address and token directly.',
),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.8,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
TextFormField(
key: const ValueKey('settings-manual-bridge-url-field'),
controller: bridgeUrlController,
decoration: InputDecoration(
labelText: appText('Bridge 地址', 'Bridge URL'),
prefixIcon: const Icon(Icons.dns_outlined),
hintText: 'https://xworkmate-bridge.svc.plus',
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-manual-bridge-token-field'),
controller: bridgeTokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('鉴权令牌 (TOKEN)', 'Auth Token'),
prefixIcon: const Icon(Icons.key_outlined),
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('settings-manual-bridge-save-button'),
onPressed: accountBusy ? null : () => onSaveAccountProfile(),
child: Text(appText('保存配置', 'Save Configuration')),
),
),
],
),
),
);
}
}
class _SignedOutAccountPanel extends StatelessWidget {
const _SignedOutAccountPanel({
required this.accountBusy,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountPasswordController,
required this.onSaveAccountProfile,
required this.onLogin,
});
final bool accountBusy;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final Future<void> Function() onSaveAccountProfile;
final Future<void> Function() onLogin;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.cloud_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('账号登录', 'Account Sign In'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'登录后可直接同步 svc.plus 托管连接配置。',
'Sign in to sync the managed svc.plus connection profile.',
),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.8,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
TextFormField(
key: const ValueKey('settings-account-base-url-field'),
controller: accountBaseUrlController,
decoration: InputDecoration(
labelText: appText('服务地址', 'Service URL'),
prefixIcon: const Icon(Icons.dns_outlined),
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-identifier-field'),
controller: accountIdentifierController,
decoration: InputDecoration(
labelText: appText('邮箱或账号', 'Email or Username'),
prefixIcon: const Icon(Icons.person_outline_rounded),
),
onFieldSubmitted: (_) => onSaveAccountProfile(),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-password-field'),
controller: accountPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('密码', 'Password'),
prefixIcon: const Icon(Icons.lock_outline_rounded),
),
onFieldSubmitted: (_) => onLogin(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('settings-account-login-button'),
onPressed: accountBusy ? null : () => onLogin(),
child: Text(appText('登录', 'Sign In')),
),
),
],
),
),
);
}
}
class _PendingMfaAccountPanel extends StatelessWidget {
const _PendingMfaAccountPanel({
required this.accountBusy,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountMfaCodeController,
required this.onVerifyMfa,
required this.onCancelMfa,
});
final bool accountBusy;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountMfaCodeController;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.verified_user_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('双重验证', 'Multi-Factor Authentication'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'请输入验证码完成登录并同步设置。',
'Enter your code to finish signing in and sync settings.',
),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.8,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
TextFormField(
key: const ValueKey('settings-account-base-url-field'),
controller: accountBaseUrlController,
readOnly: true,
decoration: InputDecoration(
labelText: appText('服务地址', 'Service URL'),
prefixIcon: const Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-identifier-field'),
controller: accountIdentifierController,
readOnly: true,
decoration: InputDecoration(
labelText: appText('邮箱或账号', 'Email or Username'),
prefixIcon: const Icon(Icons.person_outline_rounded),
),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-mfa-code-field'),
controller: accountMfaCodeController,
decoration: InputDecoration(
labelText: appText('双重验证代码', 'MFA Code'),
prefixIcon: const Icon(Icons.key_outlined),
),
onFieldSubmitted: (_) => onVerifyMfa(),
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
FilledButton(
key: const ValueKey('settings-account-mfa-verify-button'),
onPressed: accountBusy ? null : () => onVerifyMfa(),
child: Text(appText('验证并同步', 'Verify & Sync')),
),
FilledButton.tonal(
key: const ValueKey('settings-account-mfa-cancel-button'),
onPressed: accountBusy ? null : () => onCancelMfa(),
child: Text(appText('返回编辑', 'Back to Edit')),
),
],
),
],
),
),
);
}
}
class _SignedInAccountPanel extends StatelessWidget {
const _SignedInAccountPanel({
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
required this.accountStatus,
required this.onSync,
required this.onLogout,
});
final SettingsSnapshot settings;
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
@override
Widget build(BuildContext context) {
final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced;
final serviceUrl = cloudSync.accountBaseUrl.trim().isNotEmpty
? cloudSync.accountBaseUrl.trim()
: settings.accountBaseUrl.trim();
final accountIdentifier = cloudSync.accountIdentifier.trim().isNotEmpty
? cloudSync.accountIdentifier.trim()
: settings.accountUsername.trim().isNotEmpty
? settings.accountUsername.trim()
: (accountSession?.email.trim() ?? '');
final remoteSummary = cloudSync.remoteServerSummary.endpoint.trim();
final syncScope = accountState?.profileScope.trim().isNotEmpty == true
? accountState!.profileScope.trim()
: appText('待同步', 'Pending sync');
final syncState = accountState?.syncState.trim().isNotEmpty == true
? accountState!.syncState.trim()
: 'idle';
final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: appText('尚未同步远端配置', 'Remote config not synced yet');
final effectiveSyncState = accountBusy ? 'syncing' : syncState;
final effectiveSyncMessage =
accountBusy && accountStatus.trim().isNotEmpty
? accountStatus.trim()
: syncMessage;
final mfaEnabled =
accountSession?.totpEnabled == true ||
accountSession?.mfaEnabled == true;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('账号登录与同步', 'Account Sign In & Sync'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'登录身份和 svc.plus 托管连接同步已合并到这里,直接查看状态并执行重新同步或断开。',
'Identity and managed svc.plus connection sync now live together here.',
),
),
const SizedBox(height: 16),
Text(
accountSession?.email.trim().isNotEmpty == true
? accountSession!.email.trim()
: appText('当前账号', 'Current account'),
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'${appText('同步状态', 'Sync Status')}: $effectiveSyncState · $effectiveSyncMessage',
key: const ValueKey('settings-account-sync-status'),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('登录与同步状态', 'Login and Sync Status'),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}',
key: const ValueKey('settings-account-summary-service-url'),
),
const SizedBox(height: 6),
Text(
'${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}',
key: const ValueKey(
'settings-account-summary-account-identifier',
),
),
const SizedBox(height: 6),
Text(
'${appText('连接来源', 'Connection Source')}: ${appText('svc.plus 托管配置', 'svc.plus managed profile')}',
key: const ValueKey(
'settings-account-summary-connection-source',
),
),
const SizedBox(height: 6),
Text(
'${appText('远端摘要', 'Remote Summary')}: ${remoteSummary.isEmpty ? appText('待同步', 'Pending sync') : remoteSummary}',
key: const ValueKey('settings-account-summary-remote-summary'),
),
const SizedBox(height: 6),
Text(
'${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}',
key: const ValueKey('settings-account-summary-last-sync'),
),
const SizedBox(height: 6),
Text(
'${appText('MFA 状态', 'MFA Status')}: ${mfaEnabled ? appText('已启用', 'Enabled') : appText('未启用', 'Disabled')}',
key: const ValueKey('settings-account-summary-mfa-status'),
),
const SizedBox(height: 6),
Text(
'${appText('同步范围', 'Sync Scope')}: $syncScope',
key: const ValueKey('settings-account-summary-sync-scope'),
),
const SizedBox(height: 6),
_TokenConfiguredSummary(accountState: accountState),
],
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonal(
key: const ValueKey('settings-account-sync-button'),
onPressed: accountBusy ? null : () => onSync(),
child: accountBusy
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
key: const ValueKey(
'settings-account-sync-progress',
),
strokeWidth: 2,
),
),
const SizedBox(width: 8),
Text(appText('同步中', 'Syncing')),
],
)
: Text(appText('重新同步', 'Sync Again')),
),
TextButton(
key: const ValueKey('settings-account-logout-button'),
onPressed: accountBusy ? null : () => onLogout(),
child: Text(appText('退出登录', 'Log Out')),
),
],
),
],
);
}
}
class _TokenConfiguredSummary extends StatelessWidget {
const _TokenConfiguredSummary({required this.accountState});
final AccountSyncState? accountState;
@override
Widget build(BuildContext context) {
final configured = <String>[
if (accountState?.tokenConfigured.bridge == true)
appText('Bridge Token', 'Bridge Token'),
if (accountState?.tokenConfigured.apisix == true)
appText('AI Gateway Token', 'AI Gateway Token'),
if (accountState?.tokenConfigured.vault == true) 'Vault Token',
];
final summary = configured.isEmpty
? appText('未配置', 'Not configured')
: configured.join(' / ');
return Text(
'${appText('已同步令牌', 'Synced Tokens')}: $summary',
key: const ValueKey('settings-account-summary-token-configured'),
);
}
}
String _formatSyncTime(int lastSyncAtMs) {
if (lastSyncAtMs <= 0) {
return appText('尚未同步', 'Not synced yet');
}
return DateTime.fromMillisecondsSinceEpoch(
lastSyncAtMs,
).toLocal().toIso8601String();
}