import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../api/oa_client.dart'; import '../session/session.dart'; import '../desktop/desk_theme.dart'; import '../desktop/widgets/desk_widgets.dart'; import '../widgets/beian_footer.dart'; class DesktopLoginPage extends StatefulWidget { const DesktopLoginPage({super.key, required this.session, required this.api}); final SessionStore session; final OaClient api; @override State createState() => _DesktopLoginPageState(); } class _DesktopLoginPageState extends State { final _codeCtrl = TextEditingController(); String _ticket = ''; String _payload = ''; String _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'; String _error = ''; bool _submitting = false; bool _starting = true; Timer? _poll; @override void initState() { super.initState(); unawaited(_start()); } @override void dispose() { _poll?.cancel(); _codeCtrl.dispose(); super.dispose(); } Future _start() async { setState(() { _starting = true; _error = ''; _codeCtrl.clear(); _hint = '打开手机端「消息」右上角 + → 扫一扫,扫描此二维码'; }); _poll?.cancel(); try { final raw = await widget.api.post('/auth/qr/start', {}); final data = raw is Map ? Map.from(raw) : {}; final ticket = '${data['ticket'] ?? ''}'.trim(); if (ticket.isEmpty) throw Exception('无法生成登录码'); if (!mounted) return; setState(() { _ticket = ticket; _payload = '${data['payload'] ?? 'fengyingoa://qr?ticket=$ticket'}'; _starting = false; }); _poll = Timer.periodic(const Duration(milliseconds: 1600), (_) => _pollStatus()); } catch (e) { if (!mounted) return; setState(() { _starting = false; _error = '$e'; }); } } Future _pollStatus() async { if (_ticket.isEmpty || _submitting) return; try { final raw = await widget.api.get('/auth/qr/status', query: {'ticket': _ticket}); final data = raw is Map ? Map.from(raw) : {}; final status = '${data['status'] ?? ''}'; if (!mounted) return; if (status == 'code_required') { setState(() => _hint = '手机已确认,请输入手机屏幕显示的六位登录码'); return; } if (status == 'expired' || status == 'consumed') { _poll?.cancel(); setState(() => _hint = '登录码已过期,正在刷新…'); await _start(); } } catch (e) { if (!mounted) return; setState(() => _error = '$e'); } } Future _complete() async { final code = _codeCtrl.text.trim(); if (!RegExp(r'^\d{6}$').hasMatch(code)) { setState(() => _error = '请输入手机端显示的六位登录码'); return; } setState(() { _submitting = true; _error = ''; }); try { final raw = await widget.api.post('/auth/qr/complete', {'ticket': _ticket, 'code': code}); final data = raw is Map ? Map.from(raw) : {}; if ('${data['accessToken'] ?? ''}'.isEmpty) throw Exception('电脑登录未完成'); await widget.session.applyLogin(data); try { final menus = await widget.api.get('/system/menus'); await widget.session.setMenus(asMaps(menus).map(MenuNode.fromJson).toList()); } catch (_) {} } catch (e) { if (mounted) setState(() => _error = '$e'); } finally { if (mounted) setState(() => _submitting = false); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: kDeskBg, body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Material( color: kDeskPane, borderRadius: BorderRadius.circular(12), elevation: 0, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: kDeskLine), ), padding: const EdgeInsets.fromLTRB(36, 40, 36, 32), child: Column( mainAxisSize: MainAxisSize.min, children: [ DeskAvatar(label: '风', api: widget.api, size: 64, color: kDeskBind, icon: Icons.apartment), const SizedBox(height: 14), const Text('风影办公', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: kDeskInk)), const SizedBox(height: 6), const Text( '请使用手机 App 扫码,并输入六位登录码', textAlign: TextAlign.center, style: TextStyle(color: kDeskMute, fontSize: 13, height: 1.5), ), const SizedBox(height: 20), if (_error.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(_error, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFFFA5151))), ), if (_starting) const Padding( padding: EdgeInsets.symmetric(vertical: 48), child: CircularProgressIndicator(color: kDeskBind), ) else if (_payload.isNotEmpty) ...[ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: kDeskLine), ), child: QrImageView(data: _payload, size: 200, backgroundColor: Colors.white), ), const SizedBox(height: 10), Text(_hint, textAlign: TextAlign.center, style: const TextStyle(color: kDeskMute, fontSize: 12)), const SizedBox(height: 18), TextField( controller: _codeCtrl, onChanged: (v) { final next = v.replaceAll(RegExp(r'\D'), ''); final clipped = next.length > 6 ? next.substring(0, 6) : next; if (clipped != v) { _codeCtrl.value = TextEditingValue( text: clipped, selection: TextSelection.collapsed(offset: clipped.length), ); } }, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 6, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], decoration: const InputDecoration(hintText: '六位登录码', counterText: ''), style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: 8), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton( style: FilledButton.styleFrom(backgroundColor: kDeskBind, minimumSize: const Size.fromHeight(40)), onPressed: _submitting || _ticket.isEmpty ? null : _complete, child: Text(_submitting ? '登录中…' : '确认登录'), ), ), const SizedBox(width: 8), OutlinedButton(onPressed: _starting ? null : _start, child: const Text('刷新')), ], ), ], ], ), ), ), ), ), bottomNavigationBar: const BeianFooter(), ); } }