feat(flutter): add Feishu OAuth binding UI — mirrors DingTalk flow

- AgentInstance model: add feishuUserId field
- Instance card: show 飞书 binding badge (blue #3370FF) alongside DingTalk badge
- Context menu: add 绑定飞书 / 重新绑定飞书 / 解绑飞书 options
- _FeishuBindSheet: full OAuth-first binding sheet with polling, code fallback,
  countdown timer, success/expired/error states — same UX pattern as DingTalk

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 05:44:11 -07:00
parent bbcf1d742d
commit 83ed55ce1c
1 changed files with 471 additions and 16 deletions

View File

@ -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<void> _handleFeishuUnbind(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<void> _handleUnbind(BuildContext context) async {
// Find Dio via context (we use a builder trick below)
final confirmed = await showDialog<bool>(
@ -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<void> _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<String, dynamic>)['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<void> _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<String, dynamic>;
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<String, dynamic>)['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
// ---------------------------------------------------------------------------