diff --git a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart index 5bd51c5..2cf3e47 100644 --- a/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart +++ b/it0_app/lib/features/my_agents/presentation/pages/my_agents_page.dart @@ -27,6 +27,7 @@ class AgentInstance { final String status; // deploying | running | stopped | error | removed final String? errorMessage; final String? dingTalkUserId; + final String? feishuUserId; final DateTime createdAt; const AgentInstance({ @@ -39,6 +40,7 @@ class AgentInstance { required this.status, this.errorMessage, this.dingTalkUserId, + this.feishuUserId, required this.createdAt, }); @@ -52,6 +54,7 @@ class AgentInstance { status: j['status'] as String? ?? 'unknown', errorMessage: j['errorMessage'] as String?, dingTalkUserId: j['dingTalkUserId'] as String?, + feishuUserId: j['feishuUserId'] as String?, createdAt: DateTime.tryParse(j['createdAt'] as String? ?? '') ?? DateTime.now(), ); } @@ -379,6 +382,34 @@ class _InstanceCard extends StatelessWidget { ), ], const Divider(height: 1), + // Feishu binding + ListTile( + leading: Icon( + Icons.flight_takeoff_outlined, + color: instance.feishuUserId != null + ? const Color(0xFF3370FF) + : AppColors.textMuted, + ), + title: Text( + instance.feishuUserId != null ? '重新绑定飞书' : '绑定飞书', + ), + subtitle: instance.feishuUserId != null + ? const Text('已绑定,点击可解绑或重新绑定', style: TextStyle(fontSize: 11)) + : null, + onTap: () { + Navigator.pop(ctx); + _showFeishuBindSheet(context); + }, + ), + if (instance.feishuUserId != null) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.link_off, color: AppColors.textMuted), + title: const Text('解绑飞书'), + onTap: () { Navigator.pop(ctx); _handleFeishuUnbind(context); }, + ), + ], + const Divider(height: 1), ListTile( leading: const Icon(Icons.person_remove_outlined, color: AppColors.error), title: Text(AppLocalizations.of(ctx).dismissButton, style: const TextStyle(color: AppColors.error)), @@ -400,6 +431,52 @@ class _InstanceCard extends StatelessWidget { ); } + void _showFeishuBindSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _FeishuBindSheet(instance: instance, onBound: onRefresh), + ); + } + + Future _handleFeishuUnbind(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text('解绑飞书'), + content: Text('确定要解除「${instance.name}」与飞书的绑定吗?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: AppColors.error), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('解绑'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + try { + final container = ProviderScope.containerOf(context); + final dio = container.read(dioClientProvider); + await dio.post('${ApiEndpoints.agent}/channels/feishu/unbind/${instance.id}'); + onRefresh?.call(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('飞书已解绑')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('解绑失败:${ErrorHandler.friendlyMessage(e)}'), backgroundColor: AppColors.error), + ); + } + } + } + Future _handleUnbind(BuildContext context) async { // Find Dio via context (we use a builder trick below) final confirmed = await showDialog( @@ -544,23 +621,46 @@ class _InstanceCard extends StatelessWidget { ), ), - // DingTalk binding indicator - if (instance.dingTalkUserId != null) ...[ + // Channel binding indicators + if (instance.dingTalkUserId != null || instance.feishuUserId != null) ...[ const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFF1A73E8).withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.chat_bubble_outline, size: 13, color: Color(0xFF1A73E8)), - SizedBox(width: 5), - Text('已绑定钉钉', style: TextStyle(fontSize: 11, color: Color(0xFF1A73E8), fontWeight: FontWeight.w500)), - ], - ), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (instance.dingTalkUserId != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF1A73E8).withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.chat_bubble_outline, size: 13, color: Color(0xFF1A73E8)), + SizedBox(width: 5), + Text('已绑定钉钉', style: TextStyle(fontSize: 11, color: Color(0xFF1A73E8), fontWeight: FontWeight.w500)), + ], + ), + ), + if (instance.feishuUserId != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF3370FF).withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.flight_takeoff_outlined, size: 13, color: Color(0xFF3370FF)), + SizedBox(width: 5), + Text('已绑定飞书', style: TextStyle(fontSize: 11, color: Color(0xFF3370FF), fontWeight: FontWeight.w500)), + ], + ), + ), + ], ), ], @@ -1109,6 +1209,361 @@ class _DingTalkBindSheetState extends State<_DingTalkBindSheet> { ]); } +// --------------------------------------------------------------------------- +// Feishu bind bottom sheet — OAuth-first (one-tap Authorize) +// --------------------------------------------------------------------------- + +class _FeishuBindSheet extends StatefulWidget { + final AgentInstance instance; + final VoidCallback? onBound; + + const _FeishuBindSheet({required this.instance, this.onBound}); + + @override + State<_FeishuBindSheet> createState() => _FeishuBindSheetState(); +} + +class _FeishuBindSheetState extends State<_FeishuBindSheet> { + _BindPhase _phase = _BindPhase.idle; + String? _errorMsg; + Timer? _pollTimer; + + String _code = ''; + DateTime? _expiresAt; + Timer? _countdownTimer; + int _secondsLeft = 0; + + static const _feishuBlue = Color(0xFF3370FF); + + @override + void dispose() { + _pollTimer?.cancel(); + _countdownTimer?.cancel(); + super.dispose(); + } + + Future _startOAuth() async { + setState(() { _phase = _BindPhase.loadingOAuth; _errorMsg = null; }); + try { + final dio = ProviderScope.containerOf(context).read(dioClientProvider); + final res = await dio.get( + '${ApiEndpoints.agent}/channels/feishu/oauth/init', + queryParameters: {'instanceId': widget.instance.id}, + ); + final oauthUrl = (res.data as Map)['oauthUrl'] as String; + final uri = Uri.parse(oauthUrl); + try { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration( + avAudioSessionCategory: AVAudioSessionCategory.playback, + avAudioSessionCategoryOptions: AVAudioSessionCategoryOptions.mixWithOthers, + avAudioSessionMode: AVAudioSessionMode.defaultMode, + androidAudioAttributes: AndroidAudioAttributes( + contentType: AndroidAudioContentType.music, + usage: AndroidAudioUsage.media, + ), + androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransientMayDuck, + )); + await session.setActive(true); + } catch (_) {} + await launchUrl(uri, mode: LaunchMode.externalApplication); + setState(() { _phase = _BindPhase.waitingOAuth; }); + _startPolling(); + } catch (e) { + setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); }); + } + } + + Future _fetchCode() async { + setState(() { _phase = _BindPhase.loadingCode; _errorMsg = null; }); + try { + final dio = ProviderScope.containerOf(context).read(dioClientProvider); + final res = await dio.post( + '${ApiEndpoints.agent}/channels/feishu/bind/${widget.instance.id}', + ); + final data = res.data as Map; + final expiresAt = DateTime.fromMillisecondsSinceEpoch(data['expiresAt'] as int); + setState(() { + _code = data['code'] as String; + _expiresAt = expiresAt; + _secondsLeft = expiresAt.difference(DateTime.now()).inSeconds.clamp(0, 900); + _phase = _BindPhase.waitingCode; + }); + _startCountdown(); + _startPolling(); + } catch (e) { + setState(() { _phase = _BindPhase.error; _errorMsg = ErrorHandler.friendlyMessage(e); }); + } + } + + void _startCountdown() { + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + final left = _expiresAt!.difference(DateTime.now()).inSeconds; + if (left <= 0) { + _countdownTimer?.cancel(); + _pollTimer?.cancel(); + setState(() { _phase = _BindPhase.expired; }); + } else { + setState(() { _secondsLeft = left; }); + } + }); + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) async { + if (!mounted) return; + try { + final dio = ProviderScope.containerOf(context).read(dioClientProvider); + final res = await dio.get( + '${ApiEndpoints.agent}/channels/feishu/status/${widget.instance.id}', + ); + final bound = (res.data as Map)['bound'] as bool? ?? false; + if (bound && mounted) { + _pollTimer?.cancel(); + _countdownTimer?.cancel(); + try { await (await AudioSession.instance).setActive(false); } catch (_) {} + setState(() { _phase = _BindPhase.success; }); + widget.onBound?.call(); + } + } catch (_) {} + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFF1A1D2E), + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: AppColors.textMuted.withOpacity(0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + Row( + children: [ + const Icon(Icons.flight_takeoff_outlined, color: _feishuBlue, size: 22), + const SizedBox(width: 10), + const Text('绑定飞书', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const Spacer(), + Text('「${widget.instance.name}」', style: const TextStyle(fontSize: 12, color: AppColors.textMuted)), + ], + ), + const SizedBox(height: 24), + _buildPhaseContent(), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildPhaseContent() => switch (_phase) { + _BindPhase.idle => _buildIdle(), + _BindPhase.loadingOAuth => _buildSpinner('正在获取授权地址…'), + _BindPhase.waitingOAuth => _buildWaitingOAuth(), + _BindPhase.loadingCode => _buildSpinner('正在获取验证码…'), + _BindPhase.waitingCode => _buildWaitingCode(), + _BindPhase.success => _buildSuccess(), + _BindPhase.expired => _buildExpired(), + _BindPhase.error => _buildError(), + }; + + Widget _buildIdle() { + return Column( + children: [ + Container( + width: 72, height: 72, + decoration: BoxDecoration( + color: _feishuBlue.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.flight_takeoff_outlined, color: _feishuBlue, size: 36), + ), + const SizedBox(height: 16), + const Text('一键连接飞书', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 8), + const Text( + '点击下方按钮,飞书自动打开并请求授权\n点一下"同意"即完成绑定', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('用飞书授权绑定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + onPressed: _startOAuth, + style: FilledButton.styleFrom( + backgroundColor: _feishuBlue, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: _fetchCode, + child: const Text('没有自动打开?使用验证码手动绑定', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ), + ], + ); + } + + Widget _buildWaitingOAuth() { + return Column( + children: [ + const SizedBox(height: 12), + const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3, color: _feishuBlue)), + const SizedBox(height: 20), + const Text('飞书已打开', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 8), + const Text( + '请在飞书中点击"同意授权"\n授权完成后此页面会自动跳转', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _startOAuth, + style: OutlinedButton.styleFrom( + foregroundColor: _feishuBlue, + side: const BorderSide(color: _feishuBlue), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('重新打开飞书'), + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: _fetchCode, + child: const Text('切换到验证码方式', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ), + ], + ); + } + + Widget _buildWaitingCode() { + final mm = (_secondsLeft ~/ 60).toString().padLeft(2, '0'); + final ss = (_secondsLeft % 60).toString().padLeft(2, '0'); + return Column( + children: [ + const Text('在飞书中找到 iAgent 机器人,发送以下验证码:', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary)), + const SizedBox(height: 16), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: _code)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已复制'), duration: Duration(seconds: 1))); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + decoration: BoxDecoration( + color: _feishuBlue.withOpacity(0.12), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _feishuBlue.withOpacity(0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_code, style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, + color: _feishuBlue, letterSpacing: 6, fontFamily: 'monospace')), + const SizedBox(width: 12), + const Icon(Icons.copy_outlined, size: 16, color: _feishuBlue), + ], + ), + ), + ), + const SizedBox(height: 8), + Text('有效期 $mm:$ss', + style: TextStyle(fontSize: 12, color: _secondsLeft < 60 ? AppColors.error : AppColors.textMuted)), + const SizedBox(height: 16), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: _feishuBlue)), + SizedBox(width: 8), + Text('等待绑定完成…', style: TextStyle(fontSize: 12, color: AppColors.textMuted)), + ], + ), + ], + ); + } + + Widget _buildSpinner(String label) => Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column(children: [ + const CircularProgressIndicator(color: _feishuBlue), + const SizedBox(height: 16), + Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textMuted)), + ]), + ); + + Widget _buildSuccess() => Column(children: [ + const Icon(Icons.check_circle_rounded, color: Color(0xFF22C55E), size: 64), + const SizedBox(height: 16), + const Text('绑定成功!', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 8), + Text('「${widget.instance.name}」已与飞书绑定\n现在可以直接在飞书中与它对话了', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppColors.textMuted, height: 1.6)), + const SizedBox(height: 24), + SizedBox(width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(context), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('完成'), + )), + ]); + + Widget _buildExpired() => Column(children: [ + const Icon(Icons.timer_off_outlined, color: AppColors.textMuted, size: 48), + const SizedBox(height: 12), + const Text('验证码已过期', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, + child: FilledButton( + onPressed: () => setState(() { _phase = _BindPhase.idle; }), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('重新开始'), + )), + ]); + + Widget _buildError() => Column(children: [ + const Icon(Icons.error_outline, color: AppColors.error, size: 48), + const SizedBox(height: 12), + Text(_errorMsg ?? '操作失败', + style: const TextStyle(fontSize: 13, color: AppColors.error), textAlign: TextAlign.center), + const SizedBox(height: 20), + SizedBox(width: double.infinity, + child: OutlinedButton( + onPressed: () => setState(() { _phase = _BindPhase.idle; }), + child: const Text('重试'), + )), + ]); +} + // --------------------------------------------------------------------------- // Dismiss confirm dialog // ---------------------------------------------------------------------------