主要变更: 1. 重构"国学"相关模块为"经典名句",统一命名规范 2. 重命名"阅读报告"为"使用报告",调整相关文案与配置 3. 修复iOS模拟器图片缓存兼容问题,优化图表渲染逻辑 4. 新增设备活跃状态前端兜底判断,修复在线计数异常 5. 完善登录/注册流程,新增忘记密码路由与账户编辑提示 6. 优化文件传输与字体导入逻辑,废弃过时的bytes属性使用 7. 添加Spotlight全局快捷键支持,更新隐私权限与通知配置 8. 补充数据库迁移脚本与部署文档,修复后端接口兼容问题 9. 调整部分UI交互细节,优化内存占用与应用稳定性
1008 lines
32 KiB
Dart
1008 lines
32 KiB
Dart
// ============================================================
|
|
// 闲言APP — 内容查重页面
|
|
// 创建时间: 2026-04-29
|
|
// 更新时间: 2026-04-30
|
|
// 作用: 输入文本+选择数据源+查重模式+结果展示
|
|
// 上次更新: 全面UI增强 — 交错动画+相似度仪表盘+风险可视化+匹配详情
|
|
// ============================================================
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_animate/flutter_animate.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
|
|
import '../../../core/theme/app_radius.dart';
|
|
import '../../../core/theme/app_spacing.dart';
|
|
import '../../../core/theme/app_typography.dart';
|
|
import '../../../core/theme/app_theme.dart';
|
|
import '../../../shared/widgets/charts/safe_chart_widget.dart';
|
|
import '../../../shared/widgets/containers/glass_container.dart';
|
|
import '../models/check_models.dart';
|
|
import '../providers/check_provider.dart';
|
|
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
|
|
|
class CheckPage extends ConsumerStatefulWidget {
|
|
const CheckPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<CheckPage> createState() => _CheckPageState();
|
|
}
|
|
|
|
class _CheckPageState extends ConsumerState<CheckPage> {
|
|
final _textController = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_textController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = ref.watch(checkProvider);
|
|
final ext = Theme.of(context).extension<AppThemeExtension>()!;
|
|
|
|
return CupertinoPageScaffold(
|
|
navigationBar: const CupertinoNavigationBar(
|
|
leading: AdaptiveBackButton(),
|
|
middle: Text('🔍 内容查重'),
|
|
previousPageTitle: '返回',
|
|
),
|
|
child: SafeArea(
|
|
child: CupertinoScrollbar(
|
|
child: CustomScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: [
|
|
CupertinoSliverRefreshControl(
|
|
onRefresh: () async {
|
|
ref.read(checkProvider.notifier).clearResult();
|
|
},
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: _TextInputSection(controller: _textController, ext: ext)
|
|
.animate()
|
|
.fadeIn(duration: 400.ms)
|
|
.slideY(
|
|
begin: 0.06,
|
|
end: 0,
|
|
duration: 400.ms,
|
|
curve: Curves.easeOutCubic,
|
|
),
|
|
),
|
|
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
|
SliverToBoxAdapter(
|
|
child: _SourceSelector(
|
|
ext: ext,
|
|
).animate().fadeIn(duration: 400.ms, delay: 80.ms),
|
|
),
|
|
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
|
SliverToBoxAdapter(
|
|
child: _ModeSelector(
|
|
ext: ext,
|
|
).animate().fadeIn(duration: 400.ms, delay: 160.ms),
|
|
),
|
|
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
|
|
SliverToBoxAdapter(
|
|
child: _CheckButton(
|
|
ext: ext,
|
|
isChecking: state.isChecking,
|
|
onCheck: () => ref
|
|
.read(checkProvider.notifier)
|
|
.check(text: _textController.text),
|
|
).animate().fadeIn(duration: 400.ms, delay: 240.ms),
|
|
),
|
|
if (state.error != null)
|
|
SliverToBoxAdapter(
|
|
child: _ErrorBanner(error: state.error!, ext: ext)
|
|
.animate()
|
|
.fadeIn(duration: 300.ms)
|
|
.shake(
|
|
hz: 4,
|
|
offset: const Offset(4, 0),
|
|
duration: 400.ms,
|
|
),
|
|
),
|
|
if (state.result != null) ...[
|
|
const SliverToBoxAdapter(
|
|
child: SizedBox(height: AppSpacing.md),
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: _CheckResultDashboard(result: state.result!, ext: ext)
|
|
.animate()
|
|
.fadeIn(duration: 500.ms)
|
|
.slideY(
|
|
begin: 0.08,
|
|
end: 0,
|
|
duration: 500.ms,
|
|
curve: Curves.easeOutCubic,
|
|
),
|
|
),
|
|
],
|
|
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xl)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SimPie {
|
|
const _SimPie(this.label, this.value, this.color);
|
|
final String label;
|
|
final double value;
|
|
final Color color;
|
|
}
|
|
|
|
class _TextInputSection extends StatelessWidget {
|
|
const _TextInputSection({required this.controller, required this.ext});
|
|
final TextEditingController controller;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: GlassContainer(
|
|
depth: GlassDepth.elevated,
|
|
borderRadius: AppRadius.lgBorder,
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: ext.accent.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: const Center(
|
|
child: Text('📝', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'输入待查重文本',
|
|
style: AppTypography.title3.copyWith(color: ext.textPrimary),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
CupertinoTextField(
|
|
controller: controller,
|
|
placeholder: '粘贴或输入需要查重的文本内容...',
|
|
style: AppTypography.body.copyWith(
|
|
color: ext.textPrimary,
|
|
height: 1.6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary.withValues(alpha: 0.5),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
maxLines: 6,
|
|
minLines: 4,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: controller,
|
|
builder: (context, value, _) {
|
|
final count = value.text.length;
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'$count / 5000',
|
|
style: AppTypography.caption2.copyWith(
|
|
color: count > 5000
|
|
? CupertinoColors.systemRed
|
|
: ext.textHint,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SourceSelector extends ConsumerWidget {
|
|
const _SourceSelector({required this.ext});
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final state = ref.watch(checkProvider);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: GlassContainer(
|
|
borderRadius: AppRadius.lgBorder,
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: ext.accent.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: const Center(
|
|
child: Text('📂', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'数据源',
|
|
style: AppTypography.callout.copyWith(
|
|
color: ext.textSecondary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (state.selectedSources.isNotEmpty)
|
|
Text(
|
|
'已选 ${state.selectedSources.length} 项',
|
|
style: AppTypography.caption2.copyWith(color: ext.accent),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Wrap(
|
|
spacing: AppSpacing.sm,
|
|
runSpacing: AppSpacing.sm,
|
|
children: CheckSource.sources.map((src) {
|
|
final isSelected = state.selectedSources.contains(src.id);
|
|
return GestureDetector(
|
|
onTap: () =>
|
|
ref.read(checkProvider.notifier).toggleSource(src.id),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.xs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? ext.accent.withValues(alpha: 0.15)
|
|
: ext.bgSecondary.withValues(alpha: 0.5),
|
|
borderRadius: AppRadius.pillBorder,
|
|
border: isSelected
|
|
? Border.all(color: ext.accent, width: 1.5)
|
|
: null,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(src.emoji, style: const TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
src.name,
|
|
style: AppTypography.caption1.copyWith(
|
|
color: isSelected ? ext.accent : ext.textSecondary,
|
|
fontWeight: isSelected
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModeSelector extends ConsumerWidget {
|
|
const _ModeSelector({required this.ext});
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final state = ref.watch(checkProvider);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: GlassContainer(
|
|
borderRadius: AppRadius.lgBorder,
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: ext.accent.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: const Center(
|
|
child: Text('⚙️', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'查重模式',
|
|
style: AppTypography.callout.copyWith(
|
|
color: ext.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
CupertinoSlidingSegmentedControl<CheckMode>(
|
|
groupValue: state.selectedMode,
|
|
onValueChanged: (mode) {
|
|
if (mode != null) {
|
|
ref.read(checkProvider.notifier).setMode(mode);
|
|
}
|
|
},
|
|
children: {
|
|
CheckMode.exact: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('🎯', style: TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'精确',
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
CheckMode.fuzzy: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('🔍', style: TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'模糊',
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
CheckMode.similar: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('📊', style: TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'相似度',
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
CheckMode.report: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('📋', style: TextStyle(fontSize: 12)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'综合',
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
},
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
_modeDescription(state.selectedMode),
|
|
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _modeDescription(CheckMode mode) {
|
|
switch (mode) {
|
|
case CheckMode.exact:
|
|
return '精确匹配,查找完全相同的内容';
|
|
case CheckMode.fuzzy:
|
|
return '模糊匹配,查找近似内容';
|
|
case CheckMode.similar:
|
|
return '计算相似度,量化匹配程度';
|
|
case CheckMode.report:
|
|
return '综合分析,生成完整查重报告';
|
|
}
|
|
}
|
|
}
|
|
|
|
class _CheckButton extends StatelessWidget {
|
|
const _CheckButton({
|
|
required this.ext,
|
|
required this.isChecking,
|
|
required this.onCheck,
|
|
});
|
|
final AppThemeExtension ext;
|
|
final bool isChecking;
|
|
final VoidCallback onCheck;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: CupertinoButton(
|
|
color: ext.accent,
|
|
borderRadius: AppRadius.lgBorder,
|
|
onPressed: isChecking ? null : onCheck,
|
|
child: isChecking
|
|
? const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CupertinoActivityIndicator(
|
|
color: CupertinoColors.white,
|
|
),
|
|
SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'正在查重...',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: CupertinoColors.white,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'🔍',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: CupertinoColors.white,
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'开始查重',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: CupertinoColors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorBanner extends StatelessWidget {
|
|
const _ErrorBanner({required this.error, required this.ext});
|
|
final String error;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.sm,
|
|
),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.mdBorder,
|
|
border: Border.all(
|
|
color: CupertinoColors.systemRed.withValues(alpha: 0.2),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: CupertinoColors.systemRed.withValues(alpha: 0.15),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: const Center(
|
|
child: Text('❌', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Text(
|
|
error,
|
|
style: AppTypography.subhead.copyWith(
|
|
color: CupertinoColors.systemRed,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CheckResultDashboard extends StatelessWidget {
|
|
const _CheckResultDashboard({required this.result, required this.ext});
|
|
final CheckResult result;
|
|
final AppThemeExtension ext;
|
|
|
|
Color _riskColor() {
|
|
switch (result.riskLevel) {
|
|
case 'high':
|
|
return CupertinoColors.systemRed;
|
|
case 'medium':
|
|
return CupertinoColors.systemOrange;
|
|
default:
|
|
return CupertinoColors.systemGreen;
|
|
}
|
|
}
|
|
|
|
String _riskLabel() {
|
|
switch (result.riskLevel) {
|
|
case 'high':
|
|
return '高风险';
|
|
case 'medium':
|
|
return '中风险';
|
|
default:
|
|
return '低风险';
|
|
}
|
|
}
|
|
|
|
String _riskEmoji() {
|
|
switch (result.riskLevel) {
|
|
case 'high':
|
|
return '🔴';
|
|
case 'medium':
|
|
return '🟡';
|
|
default:
|
|
return '🟢';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final riskColor = _riskColor();
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
|
child: Column(
|
|
children: [
|
|
GlassContainer(
|
|
depth: GlassDepth.elevated,
|
|
borderRadius: AppRadius.lgBorder,
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: riskColor.withValues(alpha: 0.15),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
_riskEmoji(),
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'查重结果',
|
|
style: AppTypography.title3.copyWith(
|
|
color: ext.textPrimary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: riskColor.withValues(alpha: 0.15),
|
|
borderRadius: AppRadius.pillBorder,
|
|
),
|
|
child: Text(
|
|
_riskLabel(),
|
|
style: AppTypography.caption1.copyWith(
|
|
color: riskColor,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Center(
|
|
child: _SimilarityGauge(
|
|
similarity: result.maxSimilarity,
|
|
riskScore: result.riskScore,
|
|
riskColor: riskColor,
|
|
ext: ext,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_ResultStatItem(
|
|
emoji: '⚠️',
|
|
label: '风险分',
|
|
value: '${result.riskScore}',
|
|
color: riskColor,
|
|
ext: ext,
|
|
),
|
|
_ResultStatItem(
|
|
emoji: '📊',
|
|
label: '最大相似度',
|
|
value:
|
|
'${(result.maxSimilarity * 100).toStringAsFixed(1)}%',
|
|
color: riskColor,
|
|
ext: ext,
|
|
),
|
|
_ResultStatItem(
|
|
emoji: '🔗',
|
|
label: '匹配源',
|
|
value: '${result.matches.length}',
|
|
color: ext.accent,
|
|
ext: ext,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (result.matches.isNotEmpty) ...[
|
|
const SizedBox(height: AppSpacing.md),
|
|
_MatchDetailSection(
|
|
matches: result.matches,
|
|
riskColor: riskColor,
|
|
ext: ext,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SimilarityGauge extends StatelessWidget {
|
|
const _SimilarityGauge({
|
|
required this.similarity,
|
|
required this.riskScore,
|
|
required this.riskColor,
|
|
required this.ext,
|
|
});
|
|
final double similarity;
|
|
final int riskScore;
|
|
final Color riskColor;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final simPercent = (similarity * 100).clamp(0.0, 100.0);
|
|
return SizedBox(
|
|
width: 160,
|
|
height: 160,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 160,
|
|
height: 160,
|
|
child: SafeChartWidget(
|
|
chartName: '相似度',
|
|
chartBuilder: (context) => SfCircularChart(
|
|
margin: EdgeInsets.zero,
|
|
series: [
|
|
DoughnutSeries<_SimPie, String>(
|
|
animationDuration: 0,
|
|
dataSource: [
|
|
_SimPie('相似', simPercent, riskColor),
|
|
_SimPie('差异', 100 - simPercent, ext.bgSecondary),
|
|
],
|
|
xValueMapper: (d, _) => d.label,
|
|
yValueMapper: (d, _) => d.value,
|
|
pointColorMapper: (d, _) => d.color,
|
|
innerRadius: '68%',
|
|
radius: '82%',
|
|
strokeWidth: 0,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'${simPercent.toStringAsFixed(1)}%',
|
|
style: AppTypography.title1.copyWith(
|
|
color: riskColor,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'相似度',
|
|
style: AppTypography.caption1.copyWith(color: ext.textHint),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ResultStatItem extends StatelessWidget {
|
|
const _ResultStatItem({
|
|
required this.emoji,
|
|
required this.label,
|
|
required this.value,
|
|
required this.color,
|
|
required this.ext,
|
|
});
|
|
final String emoji;
|
|
final String label;
|
|
final String value;
|
|
final Color color;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
Text(emoji, style: const TextStyle(fontSize: 18)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: AppTypography.headline.copyWith(
|
|
color: color,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MatchDetailSection extends StatelessWidget {
|
|
const _MatchDetailSection({
|
|
required this.matches,
|
|
required this.riskColor,
|
|
required this.ext,
|
|
});
|
|
final List<CheckMatch> matches;
|
|
final Color riskColor;
|
|
final AppThemeExtension ext;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GlassContainer(
|
|
borderRadius: AppRadius.lgBorder,
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: ext.accent.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.mdBorder,
|
|
),
|
|
child: const Center(
|
|
child: Text('📋', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Text(
|
|
'匹配详情',
|
|
style: AppTypography.callout.copyWith(color: ext.textSecondary),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'${matches.length} 条匹配',
|
|
style: AppTypography.caption2.copyWith(color: ext.textHint),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
...matches.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final match = entry.value;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
|
child: _MatchCard(match: match, riskColor: riskColor, ext: ext)
|
|
.animate()
|
|
.fadeIn(duration: 300.ms, delay: (index * 60).ms)
|
|
.slideX(
|
|
begin: 0.06,
|
|
end: 0,
|
|
duration: 300.ms,
|
|
curve: Curves.easeOutCubic,
|
|
delay: (index * 60).ms,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MatchCard extends StatelessWidget {
|
|
const _MatchCard({
|
|
required this.match,
|
|
required this.riskColor,
|
|
required this.ext,
|
|
});
|
|
final CheckMatch match;
|
|
final Color riskColor;
|
|
final AppThemeExtension ext;
|
|
|
|
String _sourceEmoji() {
|
|
switch (match.sourceType) {
|
|
case 'poetry':
|
|
return '📜';
|
|
case 'chengyu':
|
|
return '🔗';
|
|
case 'story':
|
|
return '📖';
|
|
case 'wisdom':
|
|
return '💡';
|
|
case 'article':
|
|
return '📝';
|
|
default:
|
|
return '📄';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final simPercent = (match.similarity * 100).toStringAsFixed(1);
|
|
return Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgSecondary.withValues(alpha: 0.5),
|
|
borderRadius: AppRadius.mdBorder,
|
|
border: Border.all(color: riskColor.withValues(alpha: 0.1)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: riskColor.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
_sourceEmoji(),
|
|
style: const TextStyle(fontSize: 14),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Text(
|
|
match.sourceTitle,
|
|
style: AppTypography.callout.copyWith(
|
|
color: ext.textPrimary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: riskColor.withValues(alpha: 0.1),
|
|
borderRadius: AppRadius.pillBorder,
|
|
),
|
|
child: Text(
|
|
'$simPercent%',
|
|
style: AppTypography.caption1.copyWith(
|
|
color: riskColor,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
ClipRRect(
|
|
borderRadius: AppRadius.smBorder,
|
|
child: LinearProgressIndicator(
|
|
value: match.similarity.clamp(0.0, 1.0),
|
|
backgroundColor: ext.bgSecondary,
|
|
valueColor: AlwaysStoppedAnimation<Color>(riskColor),
|
|
minHeight: 4,
|
|
),
|
|
),
|
|
if (match.matchedText.isNotEmpty) ...[
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Container(
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
decoration: BoxDecoration(
|
|
color: ext.bgPrimary.withValues(alpha: 0.5),
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: Text(
|
|
match.matchedText,
|
|
style: AppTypography.caption1.copyWith(
|
|
color: ext.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|