import 'package:flutter/material.dart'; import 'package:miaoji_zhang/shared/api/api_client.dart'; import 'package:miaoji_zhang/shared/services/recognition_import_service.dart'; import 'package:miaoji_zhang/shared/services/screenshot_channel.dart'; import 'package:miaoji_zhang/shared/services/shanghai_time.dart'; import 'package:miaoji_zhang/shared/theme/app_theme.dart'; import 'package:miaoji_zhang/shared/widgets/app_controls.dart'; class RecognitionBatchPage extends StatefulWidget { final String? initialBatchId; const RecognitionBatchPage({super.key, this.initialBatchId}); @override State createState() => _RecognitionBatchPageState(); } class _RecognitionBatchPageState extends State { List _batches = const []; List _candidates = const []; bool _loading = true; String? _error; String? _restoringId; String? _confirmingId; String? _ignoringId; @override void initState() { super.initState(); _load(); } Future _load() async { try { final results = await Future.wait([ ScreenshotChannel.listRecognitionCandidates(), ScreenshotChannel.listRecognitionBatches(), ]); if (!mounted) return; setState(() { _candidates = results[0] as List; _batches = results[1] as List; _loading = false; _error = null; }); } catch (error) { if (!mounted) return; setState(() { _loading = false; _error = apiErrorMessage(error); }); } } Future _restore(RecognitionBatchItem item) async { setState(() => _restoringId = item.candidateId); try { final restored = await ScreenshotChannel.restoreDroppedRecognition( item.candidateId, ); if (!restored) throw StateError('这条候选已恢复或已过期'); await RecognitionImportService.importAutomatic(); await _load(); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('候选已恢复并入账'))); } } catch (error) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); } } finally { if (mounted) setState(() => _restoringId = null); } } Future _confirm(RecognitionBatchItem item) async { setState(() => _confirmingId = item.candidateId); try { await RecognitionImportService.handleAction(context, { 'action': 'recognition_confirm', 'candidateId': item.candidateId, }); await _load(); } finally { if (mounted) setState(() => _confirmingId = null); } } Future _confirmCandidate(RecognitionCandidate item) async { setState(() => _confirmingId = item.id); try { await RecognitionImportService.handleAction(context, { 'action': 'recognition_confirm', 'candidateId': item.id, }); await _load(); } finally { if (mounted) setState(() => _confirmingId = null); } } Future _ignoreCandidate(RecognitionCandidate item) async { setState(() => _ignoringId = item.id); try { final ignored = await ScreenshotChannel.acknowledgeRecognitionCandidate( item.id, 'expired', ); if (!ignored) throw StateError('这条识别结果已处理'); await _load(); } catch (error) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(apiErrorMessage(error)))); } } finally { if (mounted) setState(() => _ignoringId = null); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('识别记录')), body: RefreshIndicator(onRefresh: _load, child: _body()), ); } Widget _body() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { return ListView( children: [ SizedBox(height: MediaQuery.sizeOf(context).height * 0.28), Center( child: Text(_error!, style: TextStyle(color: context.jz.text2)), ), const SizedBox(height: 12), Center( child: JzActionButton( label: '重试', secondary: true, onPressed: _load, ), ), ], ); } final pending = _candidates .where((item) => item.state == 'pending_confirm') .toList(growable: false); if (_batches.isEmpty && pending.isEmpty) { return ListView( children: [ SizedBox(height: MediaQuery.sizeOf(context).height * 0.3), Icon(Icons.fact_check_outlined, size: 42, color: context.jz.text3), const SizedBox(height: 12), Center( child: Text('暂无识别记录', style: TextStyle(color: context.jz.text3)), ), ], ); } return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), children: [ if (pending.isNotEmpty) ...[ const Padding( padding: EdgeInsets.fromLTRB(2, 4, 2, 10), child: Text( '待确认', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), ), for (final item in pending) ...[ _candidateCard(item), const SizedBox(height: 10), ], ], if (_batches.isNotEmpty) ...[ const Padding( padding: EdgeInsets.fromLTRB(2, 10, 2, 10), child: Text( 'AI 对账历史', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700), ), ), for (final batch in _batches) ...[ _batchCard(batch), const SizedBox(height: 10), ], ], ], ); } Widget _candidateCard(RecognitionCandidate item) { final confirming = _confirmingId == item.id; final ignoring = _ignoringId == item.id; final ambiguous = item.identityConfidence == 'ambiguous_repeat'; return Card( child: ListTile( leading: Icon( ambiguous ? Icons.content_copy_rounded : Icons.receipt_long_rounded, color: ambiguous ? AppTheme.orange : AppTheme.primary, ), title: Text( item.merchant?.trim().isNotEmpty == true ? item.merchant!.trim() : '${item.appName}账单', maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text(ambiguous ? '疑似连续同额交易,请确认是否为新账单' : '识别证据不足,请确认后入账'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w700), ), IconButton( tooltip: '忽略', onPressed: ignoring || confirming ? null : () => _ignoreCandidate(item), icon: ignoring ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.close_rounded), ), IconButton( tooltip: '确认入账', onPressed: confirming || ignoring ? null : () => _confirmCandidate(item), icon: confirming ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check_rounded), ), ], ), ), ); } Widget _batchCard(RecognitionBatch batch) { final count = batch.kept + batch.updated + batch.created + batch.dropped; return Card( clipBehavior: Clip.antiAlias, child: ExpansionTile( initiallyExpanded: batch.id == widget.initialBatchId, leading: Icon( batch.fallback ? Icons.offline_bolt_outlined : Icons.auto_fix_high_rounded, color: batch.fallback ? AppTheme.orange : AppTheme.primary, ), title: Text( '${ShanghaiTime.formatDateTime(batch.completedAt)} · $count 条候选', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), ), subtitle: Padding( padding: const EdgeInsets.only(top: 4), child: Text( batch.fallback ? 'AI 不可用,已按本地识别结果处理' : '保留 ${batch.kept} · 修正 ${batch.updated} · 补全 ${batch.created} · 剔除 ${batch.dropped}', style: TextStyle(fontSize: 11.5, color: context.jz.text2), ), ), children: [ Divider(height: 1, color: context.jz.line), for (var index = 0; index < batch.items.length; index++) ...[ _batchItem(batch.items[index]), if (index != batch.items.length - 1) Divider(height: 1, indent: 54, color: context.jz.line), ], if (batch.items.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Text( '本批次没有候选明细', style: TextStyle(color: context.jz.text3), ), ), ], ), ); } Widget _batchItem(RecognitionBatchItem item) { final action = _actionDisplay(item.action); final restoring = _restoringId == item.candidateId; final confirming = _confirmingId == item.candidateId; return Padding( padding: const EdgeInsets.fromLTRB(16, 13, 12, 13), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 30, height: 30, child: Icon(action.icon, size: 19, color: action.color), ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( item.merchant?.trim().isNotEmpty == true ? item.merchant!.trim() : '智能识别账单', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, ), ), ), const SizedBox(width: 8), Text( '${item.isIncome ? '+' : '-'}¥${item.amount.toStringAsFixed(2)}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: item.isIncome ? AppTheme.primary : AppTheme.red, ), ), ], ), const SizedBox(height: 4), Text( '${action.label}${item.reason.isEmpty ? '' : ' · ${item.reason}'}', style: TextStyle( fontSize: 11.5, height: 1.45, color: context.jz.text2, ), ), ], ), ), if (item.canRestore) ...[ const SizedBox(width: 8), IconButton( tooltip: '恢复并入账', onPressed: restoring ? null : () => _restore(item), icon: restoring ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.restore_rounded), ), ] else if (item.state == 'pending_confirm') ...[ const SizedBox(width: 8), IconButton( tooltip: '确认入账', onPressed: confirming ? null : () => _confirm(item), icon: confirming ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.check_rounded), ), ], ], ), ); } _ActionDisplay _actionDisplay(String action) => switch (action) { 'update' => const _ActionDisplay( 'AI 已修正', Icons.edit_note_rounded, AppTheme.orange, ), 'create' => const _ActionDisplay( 'AI 已补全', Icons.add_circle_outline_rounded, AppTheme.primary, ), 'drop' => const _ActionDisplay( 'AI 已剔除', Icons.remove_circle_outline_rounded, AppTheme.red, ), 'restored' => const _ActionDisplay( '已手动恢复', Icons.restore_rounded, AppTheme.primary, ), 'fallback' => const _ActionDisplay( '本地结果', Icons.offline_bolt_outlined, AppTheme.orange, ), _ => const _ActionDisplay( 'AI 已保留', Icons.check_circle_outline_rounded, AppTheme.primary, ), }; } class _ActionDisplay { final String label; final IconData icon; final Color color; const _ActionDisplay(this.label, this.icon, this.color); }