马良AI写作初始化仓库
This commit is contained in:
253
AINoval/lib/widgets/analytics/analytics_card.dart
Normal file
253
AINoval/lib/widgets/analytics/analytics_card.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
class AnalyticsCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final double? changeValue;
|
||||
final bool? isUpTrend;
|
||||
final Widget? child;
|
||||
final String? className;
|
||||
|
||||
const AnalyticsCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.changeValue,
|
||||
this.isUpTrend,
|
||||
this.child,
|
||||
this.className,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.02),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 16),
|
||||
_buildContent(context),
|
||||
if (child != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
child!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
letterSpacing: 0.5,
|
||||
).copyWith(
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (changeValue != null && isUpTrend != null)
|
||||
_buildTrendIndicator(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrendIndicator(BuildContext context) {
|
||||
final isUp = isUpTrend ?? true;
|
||||
final color = isUp ? Colors.green[600] : Colors.red[600];
|
||||
final backgroundColor = isUp ? Colors.green[50] : Colors.red[50];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isUp ? Icons.trending_up : Icons.trending_down,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${changeValue!.abs().toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (value.isNotEmpty)
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
height: 1.0,
|
||||
).copyWith(
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnalyticsOverviewCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final double? changeValue;
|
||||
final bool? isUpTrend;
|
||||
final IconData icon;
|
||||
final String subtitle;
|
||||
|
||||
const AnalyticsOverviewCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.changeValue,
|
||||
this.isUpTrend,
|
||||
required this.icon,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnalyticsCard(
|
||||
title: title,
|
||||
value: value,
|
||||
changeValue: changeValue,
|
||||
isUpTrend: isUpTrend,
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnalyticsInsightCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color iconColor;
|
||||
final Color backgroundColor;
|
||||
|
||||
const AnalyticsInsightCard({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.iconColor,
|
||||
required this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
AINoval/lib/widgets/analytics/date_range_picker.dart
Normal file
180
AINoval/lib/widgets/analytics/date_range_picker.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
class AnalyticsDateRangePicker extends StatelessWidget {
|
||||
final DateTimeRange? dateRange;
|
||||
final Function(DateTimeRange?)? onDateRangeChanged;
|
||||
final String? placeholder;
|
||||
|
||||
const AnalyticsDateRangePicker({
|
||||
super.key,
|
||||
this.dateRange,
|
||||
this.onDateRangeChanged,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => _showDateRangePicker(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_getDisplayText(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDisplayText() {
|
||||
if (dateRange == null) {
|
||||
return placeholder ?? '选择日期范围';
|
||||
}
|
||||
|
||||
final startDate = _formatDate(dateRange!.start);
|
||||
final endDate = _formatDate(dateRange!.end);
|
||||
return '$startDate ~ $endDate';
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _showDateRangePicker(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final firstDate = DateTime(now.year - 1);
|
||||
final lastDate = DateTime(now.year + 1);
|
||||
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
initialDateRange: dateRange ?? DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 7)),
|
||||
end: now,
|
||||
),
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
surface: WebTheme.getCardColor(context),
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onDateRangeChanged?.call(picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AnalyticsDatePicker extends StatelessWidget {
|
||||
final DateTime? selectedDate;
|
||||
final Function(DateTime?)? onDateChanged;
|
||||
final String? placeholder;
|
||||
|
||||
const AnalyticsDatePicker({
|
||||
super.key,
|
||||
this.selectedDate,
|
||||
this.onDateChanged,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => _showDatePicker(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
selectedDate != null
|
||||
? _formatDate(selectedDate!)
|
||||
: placeholder ?? '选择日期',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<void> _showDatePicker(BuildContext context) async {
|
||||
final now = DateTime.now();
|
||||
final firstDate = DateTime(now.year - 1);
|
||||
final lastDate = DateTime(now.year + 1);
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate ?? now,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
builder: (context, child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
primary: Theme.of(context).primaryColor,
|
||||
surface: WebTheme.getCardColor(context),
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onDateChanged?.call(picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
395
AINoval/lib/widgets/analytics/function_usage_chart.dart
Normal file
395
AINoval/lib/widgets/analytics/function_usage_chart.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/analytics/date_range_picker.dart';
|
||||
|
||||
class FunctionUsageChart extends StatefulWidget {
|
||||
final List<FunctionUsageData> data;
|
||||
final AnalyticsViewMode viewMode;
|
||||
final Function(AnalyticsViewMode)? onViewModeChanged;
|
||||
final DateTimeRange? dateRange;
|
||||
final Function(DateTimeRange?)? onDateRangeChanged;
|
||||
|
||||
const FunctionUsageChart({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.viewMode = AnalyticsViewMode.daily,
|
||||
this.onViewModeChanged,
|
||||
this.dateRange,
|
||||
this.onDateRangeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FunctionUsageChart> createState() => _FunctionUsageChartState();
|
||||
}
|
||||
|
||||
class _FunctionUsageChartState extends State<FunctionUsageChart> {
|
||||
int touchedIndex = -1;
|
||||
|
||||
static const List<Color> colors = [
|
||||
Color(0xFF3B82F6), // blue
|
||||
Color(0xFF8B5CF6), // purple
|
||||
Color(0xFF10B981), // green
|
||||
Color(0xFFF59E0B), // yellow
|
||||
Color(0xFFEF4444), // red
|
||||
Color(0xFF06B6D4), // cyan
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildControls(),
|
||||
const SizedBox(height: 24),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 24),
|
||||
_buildLegend(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildViewModeButtons(),
|
||||
const Spacer(),
|
||||
if (widget.viewMode == AnalyticsViewMode.range)
|
||||
AnalyticsDateRangePicker(
|
||||
dateRange: widget.dateRange,
|
||||
onDateRangeChanged: widget.onDateRangeChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeButtons() {
|
||||
final modes = [
|
||||
AnalyticsViewMode.daily,
|
||||
AnalyticsViewMode.monthly,
|
||||
AnalyticsViewMode.range,
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: modes.map((mode) {
|
||||
final isSelected = widget.viewMode == mode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onViewModeChanged?.call(mode),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
mode.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChart() {
|
||||
if (widget.data.isEmpty) {
|
||||
return Container(
|
||||
height: 260,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final double maxY = _getMaxY();
|
||||
final double yInterval = _getNiceGridInterval(maxY);
|
||||
|
||||
return Container(
|
||||
height: 260,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: maxY,
|
||||
barTouchData: BarTouchData(
|
||||
enabled: true,
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipColor: (group) => WebTheme.getCardColor(context),
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
if (groupIndex >= 0 && groupIndex < widget.data.length) {
|
||||
final data = widget.data[groupIndex];
|
||||
return BarTooltipItem(
|
||||
'${data.name}\n',
|
||||
TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '使用次数: ${data.value.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (match) => '${match[1]},')}',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (data.growth != 0) TextSpan(
|
||||
text: '\n增长率: ${data.growth > 0 ? '+' : ''}${data.growth.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
color: data.growth > 0 ? Colors.green[600] : Colors.red[600],
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
touchCallback: (FlTouchEvent event, barTouchResponse) {
|
||||
setState(() {
|
||||
if (event is FlTapUpEvent &&
|
||||
barTouchResponse != null &&
|
||||
barTouchResponse.spot != null) {
|
||||
touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
|
||||
} else {
|
||||
touchedIndex = -1;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < widget.data.length) {
|
||||
final name = widget.data[index].name;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
name.length > 4 ? '${name.substring(0, 4)}...' : name,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: yInterval,
|
||||
reservedSize: 50,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
_formatYAxisLabel(value),
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: _buildBarGroups(),
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: yInterval,
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.3),
|
||||
strokeWidth: 1,
|
||||
dashArray: [3, 3],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Wrap(
|
||||
spacing: 24,
|
||||
runSpacing: 12,
|
||||
children: widget.data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final data = entry.value;
|
||||
final color = colors[index % colors.length];
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 24, maxWidth: 260),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
data.value.toString().replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(match) => '${match[1]},',
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
if (data.growth != 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: data.growth > 0
|
||||
? Colors.green[50]
|
||||
: Colors.red[50],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'${data.growth > 0 ? '+' : ''}${data.growth.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: data.growth > 0
|
||||
? Colors.green[600]
|
||||
: Colors.red[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<BarChartGroupData> _buildBarGroups() {
|
||||
return widget.data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final data = entry.value;
|
||||
final color = colors[index % colors.length];
|
||||
final isTouched = index == touchedIndex;
|
||||
|
||||
return BarChartGroupData(
|
||||
x: index,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: data.value.toDouble(),
|
||||
color: color,
|
||||
width: isTouched ? 20 : 16,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
backDrawRodData: BackgroundBarChartRodData(
|
||||
show: true,
|
||||
toY: _getMaxY(),
|
||||
color: color.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
double _getMaxY() {
|
||||
if (widget.data.isEmpty) return 1000;
|
||||
|
||||
final maxValue = widget.data
|
||||
.map((d) => d.value)
|
||||
.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
// 添加20%的padding
|
||||
final maxWithPadding = maxValue * 1.2;
|
||||
return maxWithPadding;
|
||||
}
|
||||
|
||||
// 计算漂亮的网格间隔(1/2/5 x 10^k)
|
||||
double _getNiceGridInterval(double maxY) {
|
||||
final double roughStep = (maxY <= 0 ? 1000.0 : maxY) / 5.0;
|
||||
final double magnitude = math.pow(10, (math.log(roughStep) / math.ln10).floor()).toDouble();
|
||||
final double residual = roughStep / magnitude;
|
||||
double nice;
|
||||
if (residual >= 5) {
|
||||
nice = 5;
|
||||
} else if (residual >= 2) {
|
||||
nice = 2;
|
||||
} else {
|
||||
nice = 1;
|
||||
}
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
String _formatYAxisLabel(double value) {
|
||||
final double absVal = value.abs();
|
||||
if (absVal >= 1000000) {
|
||||
return '${(value / 1000000).toStringAsFixed(1)}M';
|
||||
}
|
||||
if (absVal >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(0)}K';
|
||||
}
|
||||
return value.toInt().toString();
|
||||
}
|
||||
}
|
||||
343
AINoval/lib/widgets/analytics/model_usage_chart.dart
Normal file
343
AINoval/lib/widgets/analytics/model_usage_chart.dart
Normal file
@@ -0,0 +1,343 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/analytics/date_range_picker.dart';
|
||||
|
||||
class ModelUsageChart extends StatefulWidget {
|
||||
final List<ModelUsageData> data;
|
||||
final AnalyticsViewMode viewMode;
|
||||
final Function(AnalyticsViewMode)? onViewModeChanged;
|
||||
final DateTimeRange? dateRange;
|
||||
final Function(DateTimeRange?)? onDateRangeChanged;
|
||||
|
||||
const ModelUsageChart({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.viewMode = AnalyticsViewMode.daily,
|
||||
this.onViewModeChanged,
|
||||
this.dateRange,
|
||||
this.onDateRangeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ModelUsageChart> createState() => _ModelUsageChartState();
|
||||
}
|
||||
|
||||
class _ModelUsageChartState extends State<ModelUsageChart> {
|
||||
int touchedIndex = -1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildControls(),
|
||||
const SizedBox(height: 24),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 24),
|
||||
_buildLegend(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildViewModeButtons(),
|
||||
const Spacer(),
|
||||
if (widget.viewMode == AnalyticsViewMode.range)
|
||||
AnalyticsDateRangePicker(
|
||||
dateRange: widget.dateRange,
|
||||
onDateRangeChanged: widget.onDateRangeChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeButtons() {
|
||||
final modes = [
|
||||
AnalyticsViewMode.daily,
|
||||
AnalyticsViewMode.monthly,
|
||||
AnalyticsViewMode.range,
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: modes.map((mode) {
|
||||
final isSelected = widget.viewMode == mode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onViewModeChanged?.call(mode),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
mode.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChart() {
|
||||
if (widget.data.isEmpty) {
|
||||
return Container(
|
||||
height: 260,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 260,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
pieTouchData: PieTouchData(
|
||||
enabled: true,
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
setState(() {
|
||||
if (event is FlTapUpEvent &&
|
||||
pieTouchResponse != null &&
|
||||
pieTouchResponse.touchedSection != null) {
|
||||
touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||
} else {
|
||||
touchedIndex = -1;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: _buildPieSections(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final int crossAxisCount = constraints.maxWidth < 480 ? 1 : 2;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
mainAxisExtent: 70, // 增加高度,确保有足够空间
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16, // 增加间距,避免溢出
|
||||
),
|
||||
itemCount: widget.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final data = widget.data[index];
|
||||
final color = Color(int.parse(data.color.substring(1, 7), radix: 16) + 0xFF000000);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context).withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
data.modelName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13, // 稍微减小字体,确保不溢出
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4), // 增加间距
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${data.percentage}%',
|
||||
style: TextStyle(
|
||||
fontSize: 15, // 稍微减小字体
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${(data.totalTokens / 1000).toStringAsFixed(0)}K',
|
||||
style: TextStyle(
|
||||
fontSize: 11, // 稍微减小字体
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<PieChartSectionData> _buildPieSections() {
|
||||
return widget.data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final data = entry.value;
|
||||
final color = Color(int.parse(data.color.substring(1, 7), radix: 16) + 0xFF000000);
|
||||
final isTouched = index == touchedIndex;
|
||||
final fontSize = isTouched ? 16.0 : 14.0;
|
||||
final radius = isTouched ? 85.0 : 80.0;
|
||||
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: data.percentage.toDouble(),
|
||||
title: '${data.percentage}%',
|
||||
radius: radius,
|
||||
titleStyle: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
offset: const Offset(1, 1),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
// 将标题放置在更靠内的位置,避免与边缘碰撞
|
||||
titlePositionPercentageOffset: 0.55,
|
||||
borderSide: isTouched
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
: BorderSide.none,
|
||||
showTitle: data.percentage >= 5, // 只有大于5%的才显示标题
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// 数据聚合工具类
|
||||
class ModelUsageAnalytics {
|
||||
/// 根据Token使用数据按模型名聚合统计
|
||||
static List<ModelUsageData> aggregateModelUsage(List<TokenUsageData> tokenData) {
|
||||
final Map<String, int> modelTotals = {};
|
||||
int totalTokens = 0;
|
||||
|
||||
// 聚合所有模型的token使用量
|
||||
for (final data in tokenData) {
|
||||
for (final entry in data.modelTokens.entries) {
|
||||
final modelName = entry.key;
|
||||
final tokens = entry.value;
|
||||
modelTotals[modelName] = (modelTotals[modelName] ?? 0) + tokens;
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTokens == 0) return [];
|
||||
|
||||
// 按使用量排序并生成ModelUsageData列表
|
||||
final sortedEntries = modelTotals.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
final List<ModelUsageData> result = [];
|
||||
final colors = ['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#06B6D4'];
|
||||
|
||||
for (int i = 0; i < sortedEntries.length; i++) {
|
||||
final entry = sortedEntries[i];
|
||||
final percentage = ((entry.value / totalTokens) * 100).round();
|
||||
|
||||
result.add(ModelUsageData(
|
||||
modelName: entry.key,
|
||||
percentage: percentage,
|
||||
totalTokens: entry.value,
|
||||
color: colors[i % colors.length],
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 获取模型使用的颜色
|
||||
static String getModelColor(String modelName) {
|
||||
switch (modelName) {
|
||||
case 'GPT-4':
|
||||
return '#3B82F6';
|
||||
case 'Claude-3.5':
|
||||
return '#8B5CF6';
|
||||
case 'Gemini Pro':
|
||||
return '#10B981';
|
||||
case '其他模型':
|
||||
return '#F59E0B';
|
||||
default:
|
||||
return '#6B7280';
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取模型的显示名称
|
||||
static String getModelDisplayName(String modelName) {
|
||||
switch (modelName) {
|
||||
case 'gpt-4':
|
||||
case 'gpt-4-turbo':
|
||||
return 'GPT-4';
|
||||
case 'claude-3-5-sonnet':
|
||||
case 'claude-3.5':
|
||||
return 'Claude-3.5';
|
||||
case 'gemini-pro':
|
||||
case 'gemini-1.5-pro':
|
||||
return 'Gemini Pro';
|
||||
default:
|
||||
return modelName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
470
AINoval/lib/widgets/analytics/token_usage_chart.dart
Normal file
470
AINoval/lib/widgets/analytics/token_usage_chart.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/analytics/date_range_picker.dart';
|
||||
|
||||
class TokenUsageChart extends StatefulWidget {
|
||||
final List<TokenUsageData> data;
|
||||
final AnalyticsViewMode viewMode;
|
||||
final Function(AnalyticsViewMode)? onViewModeChanged;
|
||||
final DateTimeRange? dateRange;
|
||||
final Function(DateTimeRange?)? onDateRangeChanged;
|
||||
|
||||
const TokenUsageChart({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.viewMode = AnalyticsViewMode.monthly,
|
||||
this.onViewModeChanged,
|
||||
this.dateRange,
|
||||
this.onDateRangeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TokenUsageChart> createState() => _TokenUsageChartState();
|
||||
}
|
||||
|
||||
class _TokenUsageChartState extends State<TokenUsageChart> {
|
||||
int touchedIndex = -1;
|
||||
|
||||
List<TokenUsageData> get _sortedData {
|
||||
final List<TokenUsageData> copy = List<TokenUsageData>.from(widget.data);
|
||||
copy.sort((a, b) {
|
||||
final DateTime? da = _parseDate(a.date);
|
||||
final DateTime? db = _parseDate(b.date);
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return -1;
|
||||
if (db == null) return 1;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
DateTime? _parseDate(String raw) {
|
||||
final DateTime? direct = DateTime.tryParse(raw);
|
||||
if (direct != null) return DateTime(direct.year, direct.month, direct.day);
|
||||
if (RegExp(r'^\d{1,2}-\d{1,2}$').hasMatch(raw)) {
|
||||
final parts = raw.split('-');
|
||||
final m = int.tryParse(parts[0]) ?? 1;
|
||||
final d = int.tryParse(parts[1]) ?? 1;
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, m, d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildControls(),
|
||||
const SizedBox(height: 24),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 24),
|
||||
_buildLegend(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Token 使用趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildViewModeButtons(),
|
||||
const SizedBox(width: 16),
|
||||
if (widget.viewMode == AnalyticsViewMode.range)
|
||||
AnalyticsDateRangePicker(
|
||||
dateRange: widget.dateRange,
|
||||
onDateRangeChanged: widget.onDateRangeChanged,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeButtons() {
|
||||
return Row(
|
||||
children: AnalyticsViewMode.values.map((mode) {
|
||||
final isSelected = widget.viewMode == mode;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: InkWell(
|
||||
onTap: () => widget.onViewModeChanged?.call(mode),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
mode.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChart() {
|
||||
if (widget.data.isEmpty) {
|
||||
return Container(
|
||||
height: 320,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final double maxY = _getMaxY();
|
||||
final double yInterval = _getNiceGridInterval(maxY);
|
||||
final double xInterval = _computeXLabelInterval(_sortedData.length).toDouble();
|
||||
|
||||
return Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: yInterval,
|
||||
getDrawingHorizontalLine: (value) => FlLine(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.3),
|
||||
strokeWidth: 1,
|
||||
dashArray: [3, 3],
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: xInterval,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final index = value.toInt();
|
||||
if (index >= 0 && index < _sortedData.length) {
|
||||
final date = _sortedData[index].date;
|
||||
final label = _formatXAxisLabel(widget.viewMode, date);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: yInterval,
|
||||
reservedSize: 50,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
_formatYAxisLabel(value),
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: (_sortedData.length - 1).toDouble(),
|
||||
minY: 0,
|
||||
maxY: maxY,
|
||||
lineBarsData: [
|
||||
// 输入Token线
|
||||
LineChartBarData(
|
||||
spots: _getInputSpots(),
|
||||
isCurved: true,
|
||||
color: const Color(0xFF3B82F6),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFF3B82F6).withOpacity(0.3),
|
||||
const Color(0xFF3B82F6).withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 输出Token线
|
||||
LineChartBarData(
|
||||
spots: _getOutputSpots(),
|
||||
isCurved: true,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFF8B5CF6).withOpacity(0.3),
|
||||
const Color(0xFF8B5CF6).withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
enabled: true,
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipColor: (touchedSpot) => WebTheme.getCardColor(context),
|
||||
getTooltipItems: (touchedSpots) {
|
||||
return touchedSpots.map((spot) {
|
||||
final dataIndex = spot.x.toInt();
|
||||
if (dataIndex >= 0 && dataIndex < _sortedData.length) {
|
||||
final data = _sortedData[dataIndex];
|
||||
|
||||
return LineTooltipItem(
|
||||
'${data.date}\n输入: ${data.inputTokens.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (match) => '${match[1]},')} tokens\n输出: ${data.outputTokens.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+(?!\d))'), (match) => '${match[1]},')} tokens',
|
||||
TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}).where((item) => item != null).cast<LineTooltipItem>().toList();
|
||||
},
|
||||
),
|
||||
touchCallback: (FlTouchEvent event, LineTouchResponse? response) {
|
||||
setState(() {
|
||||
if (response == null || response.lineBarSpots == null) {
|
||||
touchedIndex = -1;
|
||||
} else {
|
||||
touchedIndex = response.lineBarSpots!.first.x.toInt();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
final List<TokenUsageData> dataList = _sortedData;
|
||||
final currentData = dataList.isNotEmpty ? dataList.last : null;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFF3B82F6),
|
||||
label: '输入Token',
|
||||
value: currentData != null ? '${(currentData.inputTokens / 1000).toStringAsFixed(0)}K' : '0K',
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_buildLegendItem(
|
||||
color: const Color(0xFF8B5CF6),
|
||||
label: '输出Token',
|
||||
value: currentData != null ? '${(currentData.outputTokens / 1000).toStringAsFixed(0)}K' : '0K',
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
_buildLegendItem(
|
||||
color: Theme.of(context).primaryColor,
|
||||
label: '总计',
|
||||
value: currentData != null ? '${(currentData.totalTokens / 1000).toStringAsFixed(0)}K' : '0K',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegendItem({
|
||||
required Color color,
|
||||
required String label,
|
||||
required String value,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _getInputSpots() {
|
||||
final List<TokenUsageData> dataList = _sortedData;
|
||||
return dataList.asMap().entries.map((entry) {
|
||||
return FlSpot(entry.key.toDouble(), entry.value.inputTokens.toDouble());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<FlSpot> _getOutputSpots() {
|
||||
final List<TokenUsageData> dataList = _sortedData;
|
||||
return dataList.asMap().entries.map((entry) {
|
||||
return FlSpot(entry.key.toDouble(), entry.value.outputTokens.toDouble());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
double _getMaxY() {
|
||||
final List<TokenUsageData> dataList = _sortedData;
|
||||
if (dataList.isEmpty) return 100000;
|
||||
|
||||
final maxInput = dataList.map((d) => d.inputTokens).reduce((a, b) => a > b ? a : b);
|
||||
final maxOutput = dataList.map((d) => d.outputTokens).reduce((a, b) => a > b ? a : b);
|
||||
final max = maxInput > maxOutput ? maxInput : maxOutput;
|
||||
|
||||
// 添加20%的padding,并保证最小正数上限,避免全零导致maxY=0
|
||||
final withPadding = (max * 1.2).ceilToDouble();
|
||||
return withPadding <= 0 ? 1000 : withPadding;
|
||||
}
|
||||
|
||||
// 计算漂亮的网格间隔(1/2/5 x 10^k)
|
||||
double _getNiceGridInterval(double maxY) {
|
||||
final double roughStep = (maxY <= 0 ? 1000.0 : maxY) / 5.0;
|
||||
final double magnitude = math.pow(10, (math.log(roughStep) / math.ln10).floor()).toDouble();
|
||||
final double residual = roughStep / magnitude;
|
||||
double nice;
|
||||
if (residual >= 5) {
|
||||
nice = 5;
|
||||
} else if (residual >= 2) {
|
||||
nice = 2;
|
||||
} else {
|
||||
nice = 1;
|
||||
}
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
// 控制底部x轴标签密度,避免挤在一起
|
||||
int _computeXLabelInterval(int length) {
|
||||
if (length <= 10) return 1;
|
||||
if (length <= 20) return 2;
|
||||
if (length <= 40) return 4;
|
||||
return (length / 10).ceil();
|
||||
}
|
||||
|
||||
String _formatYAxisLabel(double value) {
|
||||
final double absVal = value.abs();
|
||||
if (absVal >= 1000000) {
|
||||
return '${(value / 1000000).toStringAsFixed(1)}M';
|
||||
}
|
||||
if (absVal >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(0)}K';
|
||||
}
|
||||
return value.toInt().toString();
|
||||
}
|
||||
|
||||
String _formatXAxisLabel(AnalyticsViewMode mode, String raw) {
|
||||
switch (mode) {
|
||||
case AnalyticsViewMode.monthly:
|
||||
// 期望显示 MM
|
||||
final parts = raw.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return parts[1];
|
||||
}
|
||||
return raw;
|
||||
case AnalyticsViewMode.daily:
|
||||
case AnalyticsViewMode.range:
|
||||
case AnalyticsViewMode.cumulative:
|
||||
// 期望显示 MM-dd
|
||||
// 支持 'yyyy-MM-dd' / 'yyyy-MM' / 'MM-dd'
|
||||
if (RegExp(r'^\d{4}-\d{1,2}-\d{1,2}$').hasMatch(raw)) {
|
||||
return raw.substring(raw.length - 5);
|
||||
}
|
||||
if (RegExp(r'^\d{4}-\d{1,2}$').hasMatch(raw)) {
|
||||
final parts = raw.split('-');
|
||||
return '${parts[1].padLeft(2, '0')}-01';
|
||||
}
|
||||
if (RegExp(r'^\d{1,2}-\d{1,2}$').hasMatch(raw)) {
|
||||
final parts = raw.split('-');
|
||||
return '${parts[0].padLeft(2, '0')}-${parts[1].padLeft(2, '0')}';
|
||||
}
|
||||
final parts2 = raw.split('-');
|
||||
if (parts2.length >= 3) {
|
||||
return '${parts2[1]}-${parts2[2].padLeft(2, '0')}';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
466
AINoval/lib/widgets/analytics/token_usage_list.dart
Normal file
466
AINoval/lib/widgets/analytics/token_usage_list.dart
Normal file
@@ -0,0 +1,466 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/analytics_data.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TokenUsageList extends StatelessWidget {
|
||||
final List<TokenUsageRecord> records;
|
||||
final Map<String, dynamic>? todaySummary;
|
||||
|
||||
const TokenUsageList({
|
||||
super.key,
|
||||
required this.records,
|
||||
this.todaySummary,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSummaryStats(),
|
||||
const SizedBox(height: 24),
|
||||
_buildRecordsList(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryStats() {
|
||||
// 从records数据中计算统计,不依赖后端汇总接口
|
||||
final stats = _calculateStats();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
title: stats['isToday'] ? '今日调用次数' : '最近调用次数',
|
||||
value: stats['totalRecords'].toString(),
|
||||
color: const Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
title: stats['isToday'] ? '今日 Token 消耗' : '最近 Token 消耗',
|
||||
value: _formatNumber(stats['totalTokens']),
|
||||
color: const Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildSummaryCard(
|
||||
title: stats['isToday'] ? '今日成本' : '最近成本',
|
||||
value: '\$${stats['totalCost'].toStringAsFixed(4)}',
|
||||
color: const Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard({
|
||||
required String title,
|
||||
required String value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context).withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordsList(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Token 使用记录',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'最近 ${records.length} 条记录',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: records.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) => _buildRecordItem(context, records[index]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordItem(BuildContext context, TokenUsageRecord record) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context).withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFeatureAvatar(context, record.taskType),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildRecordContent(context, record),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildTokenStats(context, record),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建功能类型头像,使用图标替代文字
|
||||
Widget _buildFeatureAvatar(BuildContext context, String taskType) {
|
||||
final color = _getFeatureTypeColor(taskType);
|
||||
final icon = _getFeatureTypeIcon(taskType);
|
||||
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordContent(BuildContext context, TokenUsageRecord record) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
record.taskType,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
record.model,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDateTime(record.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTokenStats(BuildContext context, TokenUsageRecord record) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTokenStatItem(
|
||||
context: context,
|
||||
icon: Icons.unfold_more, // Text Expansion 风格,代表输入
|
||||
value: record.inputTokens,
|
||||
color: const Color(0xFF3B82F6),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildTokenStatItem(
|
||||
context: context,
|
||||
icon: Icons.notes, // Text Summary 风格,代表输出
|
||||
value: record.outputTokens,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'成本: \$${record.cost.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTokenStatItem({
|
||||
required BuildContext context,
|
||||
required IconData icon,
|
||||
required int value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatNumber(value),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
return number.toString().replaceAllMapped(
|
||||
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
|
||||
(match) => '${match[1]},',
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays}天前';
|
||||
} else {
|
||||
return DateFormat('MM-dd HH:mm').format(dateTime);
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型中文名称获取对应的AI功能图标
|
||||
IconData _getFeatureTypeIcon(String taskType) {
|
||||
switch (taskType) {
|
||||
case '场景摘要':
|
||||
return Icons.summarize;
|
||||
case '摘要扩写':
|
||||
return Icons.expand_more;
|
||||
case '文本扩写':
|
||||
return Icons.unfold_more;
|
||||
case '文本重构':
|
||||
return Icons.edit;
|
||||
case '文本总结':
|
||||
return Icons.notes;
|
||||
case 'AI聊天':
|
||||
return Icons.chat;
|
||||
case '小说生成':
|
||||
return Icons.create;
|
||||
case '设定编排':
|
||||
return Icons.dashboard_customize;
|
||||
case '专业续写':
|
||||
return Icons.auto_stories;
|
||||
case '场景节拍生成':
|
||||
return Icons.timeline;
|
||||
case '设定树生成':
|
||||
return Icons.account_tree;
|
||||
case '设定生成':
|
||||
return Icons.settings_applications; // 设定生成专用图标
|
||||
// 兼容其他功能类型
|
||||
case '智能续写':
|
||||
return Icons.unfold_more;
|
||||
case 'AI对话':
|
||||
return Icons.chat;
|
||||
case '内容优化':
|
||||
return Icons.tune;
|
||||
case '语法检查':
|
||||
return Icons.spellcheck;
|
||||
case '风格改进':
|
||||
return Icons.auto_fix_high;
|
||||
default:
|
||||
return Icons.smart_toy; // 默认AI图标
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型中文名称获取对应的颜色
|
||||
Color _getFeatureTypeColor(String taskType) {
|
||||
switch (taskType) {
|
||||
case '场景摘要':
|
||||
return const Color(0xFF3B82F6); // 蓝色
|
||||
case '摘要扩写':
|
||||
return const Color(0xFF8B5CF6); // 紫色
|
||||
case '文本扩写':
|
||||
return const Color(0xFF10B981); // 绿色
|
||||
case '文本重构':
|
||||
return const Color(0xFFF59E0B); // 黄色
|
||||
case '文本总结':
|
||||
return const Color(0xFFEF4444); // 红色
|
||||
case 'AI聊天':
|
||||
return const Color(0xFF06B6D4); // 青色
|
||||
case '小说生成':
|
||||
return const Color(0xFF8B5CF6); // 紫色
|
||||
case '设定编排':
|
||||
return const Color(0xFF059669); // 深绿色
|
||||
case '专业续写':
|
||||
return const Color(0xFFDC2626); // 深红色
|
||||
case '场景节拍生成':
|
||||
return const Color(0xFF7C3AED); // 深紫色
|
||||
case '设定树生成':
|
||||
return const Color(0xFF0891B2); // 深青色
|
||||
case '设定生成':
|
||||
return const Color(0xFF6366F1); // 靛蓝色
|
||||
// 兼容其他功能类型
|
||||
case '智能续写':
|
||||
return const Color(0xFF10B981); // 绿色
|
||||
case 'AI对话':
|
||||
return const Color(0xFF06B6D4); // 青色
|
||||
case '内容优化':
|
||||
return const Color(0xFF8B5CF6); // 紫色
|
||||
case '语法检查':
|
||||
return const Color(0xFFF59E0B); // 黄色
|
||||
case '风格改进':
|
||||
return const Color(0xFFEF4444); // 红色
|
||||
default:
|
||||
return const Color(0xFF6B7280); // 灰色
|
||||
}
|
||||
}
|
||||
|
||||
/// 从records数据中计算统计,不依赖后端汇总接口
|
||||
Map<String, dynamic> _calculateStats() {
|
||||
// 如果没有记录数据,返回空统计
|
||||
if (records.isEmpty) {
|
||||
return {
|
||||
'totalRecords': 0,
|
||||
'totalTokens': 0,
|
||||
'totalCost': 0.0,
|
||||
'isToday': false,
|
||||
};
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final todayDate = DateTime(now.year, now.month, now.day);
|
||||
|
||||
// 筛选今日的记录
|
||||
final todayRecords = records.where((record) {
|
||||
final recordDate = DateTime(record.timestamp.year, record.timestamp.month, record.timestamp.day);
|
||||
return recordDate.isAtSameMomentAs(todayDate);
|
||||
}).toList();
|
||||
|
||||
// 如果有今日记录,返回今日统计
|
||||
if (todayRecords.isNotEmpty) {
|
||||
int totalRecords = todayRecords.length;
|
||||
int totalTokens = todayRecords.fold(0, (sum, record) => sum + record.totalTokens);
|
||||
double totalCost = todayRecords.fold(0.0, (sum, record) => sum + record.cost);
|
||||
|
||||
return {
|
||||
'totalRecords': totalRecords,
|
||||
'totalTokens': totalTokens,
|
||||
'totalCost': totalCost,
|
||||
'isToday': true,
|
||||
};
|
||||
}
|
||||
|
||||
// 没有今日记录,使用所有可见记录的统计
|
||||
int totalRecords = records.length;
|
||||
int totalTokens = records.fold(0, (sum, record) => sum + record.totalTokens);
|
||||
double totalCost = records.fold(0.0, (sum, record) => sum + record.cost);
|
||||
|
||||
return {
|
||||
'totalRecords': totalRecords,
|
||||
'totalTokens': totalTokens,
|
||||
'totalCost': totalCost,
|
||||
'isToday': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
AINoval/lib/widgets/common/animated_container_widget.dart
Normal file
123
AINoval/lib/widgets/common/animated_container_widget.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AnimationType {
|
||||
fadeIn,
|
||||
scaleIn,
|
||||
slideInRight,
|
||||
}
|
||||
|
||||
class AnimatedContainerWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final AnimationType animationType;
|
||||
final Duration duration;
|
||||
final Duration? delay;
|
||||
final Curve curve;
|
||||
|
||||
const AnimatedContainerWidget({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.animationType = AnimationType.fadeIn,
|
||||
this.duration = const Duration(milliseconds: 300),
|
||||
this.delay,
|
||||
this.curve = Curves.easeOut,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AnimatedContainerWidget> createState() => _AnimatedContainerWidgetState();
|
||||
}
|
||||
|
||||
class _AnimatedContainerWidgetState extends State<AnimatedContainerWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
switch (widget.animationType) {
|
||||
case AnimationType.fadeIn:
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
break;
|
||||
case AnimationType.scaleIn:
|
||||
_animation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
break;
|
||||
case AnimationType.slideInRight:
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
));
|
||||
break;
|
||||
}
|
||||
|
||||
if (widget.delay != null) {
|
||||
Future.delayed(widget.delay!, () {
|
||||
if (!mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (widget.animationType) {
|
||||
case AnimationType.fadeIn:
|
||||
return FadeTransition(
|
||||
opacity: _animation,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 10 * (1 - _animation.value)),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
case AnimationType.scaleIn:
|
||||
return ScaleTransition(
|
||||
scale: _animation,
|
||||
child: FadeTransition(
|
||||
opacity: _animation,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
case AnimationType.slideInRight:
|
||||
return SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
AINoval/lib/widgets/common/app_filter_button.dart
Normal file
138
AINoval/lib/widgets/common/app_filter_button.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 通用过滤器按钮组件
|
||||
class AppFilterButton extends StatelessWidget {
|
||||
const AppFilterButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isSelected = false,
|
||||
this.size = AppFilterButtonSize.medium,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final bool isSelected;
|
||||
final AppFilterButtonSize size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
// 根据尺寸设置不同的参数
|
||||
final buttonConfig = _getButtonConfig(size);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(buttonConfig.borderRadius),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: buttonConfig.horizontalPadding,
|
||||
vertical: buttonConfig.verticalPadding,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(buttonConfig.borderRadius),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
width: isSelected ? 1.5 : 1.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: buttonConfig.iconSize,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: (isDark ? WebTheme.darkGrey800 : WebTheme.grey800),
|
||||
),
|
||||
if (label.isNotEmpty) ...[
|
||||
SizedBox(width: buttonConfig.spacing),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: buttonConfig.fontSize,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: (isDark ? WebTheme.darkGrey800 : WebTheme.grey800),
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_FilterButtonConfig _getButtonConfig(AppFilterButtonSize size) {
|
||||
switch (size) {
|
||||
case AppFilterButtonSize.small:
|
||||
return const _FilterButtonConfig(
|
||||
horizontalPadding: 8,
|
||||
verticalPadding: 4,
|
||||
iconSize: 14,
|
||||
fontSize: 11,
|
||||
spacing: 3,
|
||||
borderRadius: 4,
|
||||
);
|
||||
case AppFilterButtonSize.medium:
|
||||
return const _FilterButtonConfig(
|
||||
horizontalPadding: 10,
|
||||
verticalPadding: 6,
|
||||
iconSize: 16,
|
||||
fontSize: 12,
|
||||
spacing: 4,
|
||||
borderRadius: 6,
|
||||
);
|
||||
case AppFilterButtonSize.large:
|
||||
return const _FilterButtonConfig(
|
||||
horizontalPadding: 12,
|
||||
verticalPadding: 8,
|
||||
iconSize: 18,
|
||||
fontSize: 14,
|
||||
spacing: 6,
|
||||
borderRadius: 8,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 过滤器按钮尺寸枚举
|
||||
enum AppFilterButtonSize {
|
||||
small,
|
||||
medium,
|
||||
large,
|
||||
}
|
||||
|
||||
/// 按钮配置数据类
|
||||
class _FilterButtonConfig {
|
||||
const _FilterButtonConfig({
|
||||
required this.horizontalPadding,
|
||||
required this.verticalPadding,
|
||||
required this.iconSize,
|
||||
required this.fontSize,
|
||||
required this.spacing,
|
||||
required this.borderRadius,
|
||||
});
|
||||
|
||||
final double horizontalPadding;
|
||||
final double verticalPadding;
|
||||
final double iconSize;
|
||||
final double fontSize;
|
||||
final double spacing;
|
||||
final double borderRadius;
|
||||
}
|
||||
149
AINoval/lib/widgets/common/app_search_field.dart
Normal file
149
AINoval/lib/widgets/common/app_search_field.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 通用搜索框组件
|
||||
class AppSearchField extends StatefulWidget {
|
||||
const AppSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onClear,
|
||||
this.hintText = '搜索...',
|
||||
this.height,
|
||||
this.width,
|
||||
this.enabled = true,
|
||||
this.borderRadius = 6.0,
|
||||
this.showClearButton = true,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.dense = true,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.fillColor,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final VoidCallback? onClear;
|
||||
final String hintText;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final bool enabled;
|
||||
final double borderRadius;
|
||||
final bool showClearButton;
|
||||
final Widget? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool dense;
|
||||
final TextAlign textAlign;
|
||||
final Color? fillColor;
|
||||
|
||||
@override
|
||||
State<AppSearchField> createState() => _AppSearchFieldState();
|
||||
}
|
||||
|
||||
class _AppSearchFieldState extends State<AppSearchField> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
Widget searchField = TextField(
|
||||
controller: widget.controller,
|
||||
enabled: widget.enabled,
|
||||
onChanged: widget.onChanged,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
textAlign: widget.textAlign,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
fontSize: 13,
|
||||
),
|
||||
prefixIcon: widget.prefixIcon ?? Icon(
|
||||
Icons.search,
|
||||
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
size: 16,
|
||||
),
|
||||
suffixIcon: widget.showClearButton && widget.controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
onPressed: widget.onClear ?? () {
|
||||
widget.controller.clear();
|
||||
widget.onChanged('');
|
||||
},
|
||||
splashRadius: 16,
|
||||
tooltip: '清除',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
),
|
||||
)
|
||||
: widget.suffixIcon,
|
||||
filled: true,
|
||||
fillColor: widget.fillColor ?? WebTheme.getBackgroundColor(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
contentPadding: widget.dense
|
||||
? const EdgeInsets.symmetric(horizontal: 8, vertical: 6)
|
||||
: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
isDense: widget.dense,
|
||||
),
|
||||
);
|
||||
|
||||
// 如果指定了宽度或高度,则包装在Container中
|
||||
if (widget.width != null || widget.height != null) {
|
||||
searchField = Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
child: searchField,
|
||||
);
|
||||
}
|
||||
|
||||
return searchField;
|
||||
}
|
||||
}
|
||||
346
AINoval/lib/widgets/common/app_sidebar.dart
Normal file
346
AINoval/lib/widgets/common/app_sidebar.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
class AppSidebar extends StatefulWidget {
|
||||
final bool isExpanded;
|
||||
final Function(bool)? onExpandedChanged;
|
||||
final Function(String)? onNavigate; // 添加导航回调
|
||||
final String? currentRoute; // 可选的当前路由高亮
|
||||
final bool isAuthed; // 是否已登录
|
||||
final VoidCallback? onRequireAuth; // 触发登录
|
||||
|
||||
const AppSidebar({
|
||||
Key? key,
|
||||
this.isExpanded = true,
|
||||
this.onExpandedChanged,
|
||||
this.onNavigate,
|
||||
this.currentRoute,
|
||||
this.isAuthed = true,
|
||||
this.onRequireAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AppSidebar> createState() => _AppSidebarState();
|
||||
}
|
||||
|
||||
class _AppSidebarState extends State<AppSidebar> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _widthAnimation;
|
||||
bool _isExpanded = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isExpanded = widget.isExpanded;
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_widthAnimation = Tween<double>(
|
||||
begin: 60,
|
||||
end: 240,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
if (_isExpanded) {
|
||||
_animationController.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSidebar() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
widget.onExpandedChanged?.call(_isExpanded);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _widthAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: _widthAnimation.value,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey50,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 24,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
if (_isExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'AI小说创作',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Navigation Items
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.home,
|
||||
label: '首页',
|
||||
isSelected: widget.currentRoute == 'home',
|
||||
onTap: () => widget.onNavigate?.call('home'),
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.settings_applications,
|
||||
label: '我的设定',
|
||||
isSelected: widget.currentRoute == 'settings',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
widget.onNavigate?.call('settings');
|
||||
},
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.book,
|
||||
label: '我的小说',
|
||||
isSelected: widget.currentRoute == 'novels',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
widget.onNavigate?.call('novels');
|
||||
},
|
||||
),
|
||||
|
||||
// _buildNavItem(
|
||||
// context,
|
||||
// icon: Icons.edit,
|
||||
// label: '创作中心',
|
||||
// onTap: () {
|
||||
// if (!widget.isAuthed) {
|
||||
// widget.onRequireAuth?.call();
|
||||
// return;
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// _buildNavItem(
|
||||
// context,
|
||||
// icon: Icons.auto_awesome,
|
||||
// label: 'AI助手',
|
||||
// onTap: () {
|
||||
// if (!widget.isAuthed) {
|
||||
// widget.onRequireAuth?.call();
|
||||
// return;
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
// _buildNavItem(
|
||||
// context,
|
||||
// icon: Icons.group,
|
||||
// label: '社区',
|
||||
// onTap: () {
|
||||
// if (!widget.isAuthed) {
|
||||
// widget.onRequireAuth?.call();
|
||||
// return;
|
||||
// }
|
||||
// },
|
||||
// ),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.analytics,
|
||||
label: '数据分析',
|
||||
isSelected: widget.currentRoute == 'analytics',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
widget.onNavigate?.call('analytics');
|
||||
},
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.workspace_premium,
|
||||
label: '我的订阅',
|
||||
isSelected: widget.currentRoute == 'my_subscription',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
widget.onNavigate?.call('my_subscription');
|
||||
},
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
label: '设置',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
widget.onNavigate?.call('account_settings');
|
||||
},
|
||||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
icon: Icons.help_outline,
|
||||
label: '帮助',
|
||||
onTap: () {
|
||||
if (!widget.isAuthed) {
|
||||
widget.onRequireAuth?.call();
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Toggle Button
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: _toggleSidebar,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_isExpanded ? Icons.chevron_left : Icons.chevron_right,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
bool isSelected = false,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: _isExpanded ? 16 : 12,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey200 : WebTheme.grey200)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
if (_isExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context)
|
||||
: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 游客模式在展开时显示小提示“需登录”
|
||||
// 访客提示徽标已移除,保持简洁
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
AINoval/lib/widgets/common/app_toolbar.dart
Normal file
60
AINoval/lib/widgets/common/app_toolbar.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 通用工具栏组件
|
||||
class AppToolbar extends StatelessWidget {
|
||||
const AppToolbar({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
this.showTopBorder = true,
|
||||
this.showBottomBorder = true,
|
||||
this.showShadow = true,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final bool showTopBorder;
|
||||
final bool showBottomBorder;
|
||||
final bool showShadow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.white,
|
||||
border: Border(
|
||||
top: showTopBorder
|
||||
? BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey200,
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
bottom: showBottomBorder
|
||||
? BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey200,
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
boxShadow: showShadow
|
||||
? [
|
||||
BoxShadow(
|
||||
color: (isDark ? WebTheme.black : WebTheme.grey300)
|
||||
.withOpacity(0.1),
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
AINoval/lib/widgets/common/app_view_toggle.dart
Normal file
113
AINoval/lib/widgets/common/app_view_toggle.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 通用视图切换组件
|
||||
class AppViewToggle extends StatelessWidget {
|
||||
const AppViewToggle({
|
||||
super.key,
|
||||
required this.isGridView,
|
||||
required this.onViewTypeChanged,
|
||||
this.gridIcon = Icons.grid_view,
|
||||
this.listIcon = Icons.view_list,
|
||||
this.size = 18,
|
||||
});
|
||||
|
||||
final bool isGridView;
|
||||
final ValueChanged<bool> onViewTypeChanged;
|
||||
final IconData gridIcon;
|
||||
final IconData listIcon;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ToggleButton(
|
||||
icon: gridIcon,
|
||||
isSelected: isGridView,
|
||||
isFirst: true,
|
||||
onTap: () => onViewTypeChanged(true),
|
||||
size: size,
|
||||
),
|
||||
_ToggleButton(
|
||||
icon: listIcon,
|
||||
isSelected: !isGridView,
|
||||
isFirst: false,
|
||||
onTap: () => onViewTypeChanged(false),
|
||||
size: size,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换按钮内部组件
|
||||
class _ToggleButton extends StatelessWidget {
|
||||
const _ToggleButton({
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.isFirst,
|
||||
required this.onTap,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final bool isFirst;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isFirst ? 5 : 0),
|
||||
bottomLeft: Radius.circular(isFirst ? 5 : 0),
|
||||
topRight: Radius.circular(isFirst ? 0 : 5),
|
||||
bottomRight: Radius.circular(isFirst ? 0 : 5),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
border: isFirst
|
||||
? Border(
|
||||
right: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey300,
|
||||
width: 0.5,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: size,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
AINoval/lib/widgets/common/badge.dart
Normal file
183
AINoval/lib/widgets/common/badge.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
enum BadgeVariant {
|
||||
solid,
|
||||
outline,
|
||||
secondary,
|
||||
destructive,
|
||||
success,
|
||||
warning,
|
||||
}
|
||||
|
||||
class Badge extends StatefulWidget {
|
||||
final String text;
|
||||
final BadgeVariant variant;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double? fontSize;
|
||||
final FontWeight? fontWeight;
|
||||
final int? animationDelay; // In milliseconds
|
||||
|
||||
const Badge({
|
||||
Key? key,
|
||||
required this.text,
|
||||
this.variant = BadgeVariant.solid,
|
||||
this.onTap,
|
||||
this.padding,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.animationDelay,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<Badge> createState() => _BadgeState();
|
||||
}
|
||||
|
||||
class _BadgeState extends State<Badge> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
if (widget.animationDelay != null) {
|
||||
Future.delayed(Duration(milliseconds: widget.animationDelay!), () {
|
||||
if (!mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_animationController.forward();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_animationController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_animationController.isAnimating) {
|
||||
_animationController.stop();
|
||||
}
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _getBackgroundColor(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
switch (widget.variant) {
|
||||
case BadgeVariant.solid:
|
||||
return WebTheme.getPrimaryColor(context);
|
||||
case BadgeVariant.outline:
|
||||
return Colors.transparent;
|
||||
case BadgeVariant.secondary:
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
case BadgeVariant.destructive:
|
||||
return WebTheme.error.withOpacity(0.1);
|
||||
case BadgeVariant.success:
|
||||
return WebTheme.success.withOpacity(0.1);
|
||||
case BadgeVariant.warning:
|
||||
return WebTheme.warning.withOpacity(0.1);
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTextColor(BuildContext context) {
|
||||
switch (widget.variant) {
|
||||
case BadgeVariant.solid:
|
||||
return WebTheme.white;
|
||||
case BadgeVariant.outline:
|
||||
return WebTheme.getTextColor(context, isPrimary: false);
|
||||
case BadgeVariant.secondary:
|
||||
return WebTheme.getTextColor(context, isPrimary: false);
|
||||
case BadgeVariant.destructive:
|
||||
return WebTheme.error;
|
||||
case BadgeVariant.success:
|
||||
return WebTheme.success;
|
||||
case BadgeVariant.warning:
|
||||
return WebTheme.warning;
|
||||
}
|
||||
}
|
||||
|
||||
Color? _getBorderColor(BuildContext context) {
|
||||
switch (widget.variant) {
|
||||
case BadgeVariant.outline:
|
||||
return WebTheme.getBorderColor(context);
|
||||
case BadgeVariant.destructive:
|
||||
return WebTheme.error.withOpacity(0.3);
|
||||
case BadgeVariant.success:
|
||||
return WebTheme.success.withOpacity(0.3);
|
||||
case BadgeVariant.warning:
|
||||
return WebTheme.warning.withOpacity(0.3);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isClickable = widget.onTap != null;
|
||||
|
||||
Widget badge = ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transform: Matrix4.identity()
|
||||
..scale(_isHovered && isClickable ? 1.05 : 1.0),
|
||||
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBackgroundColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _getBorderColor(context) != null
|
||||
? Border.all(
|
||||
color: _getBorderColor(context)!,
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
widget.text,
|
||||
style: TextStyle(
|
||||
fontSize: widget.fontSize ?? 12,
|
||||
fontWeight: widget.fontWeight ?? FontWeight.w500,
|
||||
color: _getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (isClickable) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: badge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
}
|
||||
}
|
||||
217
AINoval/lib/widgets/common/bottom_action_bar.dart
Normal file
217
AINoval/lib/widgets/common/bottom_action_bar.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 底部操作栏组件
|
||||
/// 包含模型选择器和主要操作按钮
|
||||
class BottomActionBar extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const BottomActionBar({
|
||||
super.key,
|
||||
this.modelSelector,
|
||||
required this.primaryAction,
|
||||
this.secondaryActions = const [],
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.spacing = 16,
|
||||
});
|
||||
|
||||
/// 模型选择器组件
|
||||
final Widget? modelSelector;
|
||||
|
||||
/// 主要操作按钮
|
||||
final Widget primaryAction;
|
||||
|
||||
/// 次要操作按钮列表
|
||||
final List<Widget> secondaryActions;
|
||||
|
||||
/// 内边距
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// 按钮间距
|
||||
final double spacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.white,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 模型选择器(如果提供)
|
||||
if (modelSelector != null) ...[
|
||||
Expanded(child: modelSelector!),
|
||||
SizedBox(width: spacing),
|
||||
],
|
||||
|
||||
// 次要操作按钮
|
||||
...secondaryActions.map((action) => Padding(
|
||||
padding: EdgeInsets.only(right: spacing),
|
||||
child: action,
|
||||
)).toList(),
|
||||
|
||||
// 主要操作按钮
|
||||
primaryAction,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型选择器组件
|
||||
/// 显示当前选中的AI模型和相关信息
|
||||
class ModelSelector extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const ModelSelector({
|
||||
super.key,
|
||||
required this.modelName,
|
||||
required this.onTap,
|
||||
this.providerIcon,
|
||||
this.maxOutput,
|
||||
this.isModerated = false,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 模型名称
|
||||
final String modelName;
|
||||
|
||||
/// 点击回调
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 提供商图标
|
||||
final Widget? providerIcon;
|
||||
|
||||
/// 最大输出
|
||||
final String? maxOutput;
|
||||
|
||||
/// 是否受监管
|
||||
final bool isModerated;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? WebTheme.darkGrey300.withValues(alpha: 0.5)
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: enabled
|
||||
? Colors.transparent
|
||||
: (isDark ? WebTheme.darkGrey200 : WebTheme.grey100),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 提供商图标
|
||||
if (providerIcon != null) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: providerIcon!,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
|
||||
// 模型信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 模型名称
|
||||
Flexible(
|
||||
child: Text(
|
||||
modelName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: enabled
|
||||
? (isDark ? WebTheme.darkGrey900 : WebTheme.grey900)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 附加信息
|
||||
if (isModerated || maxOutput != null) ...[
|
||||
const SizedBox(height: 1),
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
if (isModerated) ...[
|
||||
Flexible(
|
||||
child: Text(
|
||||
'受监管',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark
|
||||
? WebTheme.warning.withValues(alpha: 0.8)
|
||||
: WebTheme.warning,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (maxOutput != null) const SizedBox(width: 6),
|
||||
],
|
||||
if (maxOutput != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'最大输出: $maxOutput',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: enabled
|
||||
? (isDark ? WebTheme.darkGrey500 : WebTheme.grey500)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 下拉箭头
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: enabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
486
AINoval/lib/widgets/common/compact_novel_card.dart
Normal file
486
AINoval/lib/widgets/common/compact_novel_card.dart
Normal file
@@ -0,0 +1,486 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/models/novel_summary.dart';
|
||||
import 'package:ainoval/services/image_cache_service.dart';
|
||||
|
||||
class CompactNovelCard extends StatefulWidget {
|
||||
final NovelSummary novel;
|
||||
final VoidCallback? onContinueWriting;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onShare;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const CompactNovelCard({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.onContinueWriting,
|
||||
this.onEdit,
|
||||
this.onShare,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CompactNovelCard> createState() => _CompactNovelCardState();
|
||||
}
|
||||
|
||||
class _CompactNovelCardState extends State<CompactNovelCard> with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _getNovelStatus() {
|
||||
if (widget.novel.wordCount < 1000) {
|
||||
return '草稿';
|
||||
} else if (widget.novel.completionPercentage >= 100.0) {
|
||||
return '已完结';
|
||||
} else {
|
||||
return '连载中';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
case '连载中':
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
case '已完结':
|
||||
return Theme.of(context).colorScheme.secondary;
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusBackgroundColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
case '连载中':
|
||||
return Theme.of(context).colorScheme.primaryContainer.withOpacity(isDark ? 0.2 : 1.0);
|
||||
case '已完结':
|
||||
return Theme.of(context).colorScheme.secondaryContainer.withOpacity(isDark ? 0.2 : 1.0);
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCoverImageUrl() {
|
||||
if (widget.novel.coverUrl.isNotEmpty) {
|
||||
return widget.novel.coverUrl;
|
||||
}
|
||||
// Use Picsum Photos as fallback with unique ID based on novel ID
|
||||
final randomId = widget.novel.id.hashCode.abs() % 1000;
|
||||
return 'https://picsum.photos/400/300?random=$randomId';
|
||||
}
|
||||
|
||||
String _formatLastEditTime() {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(widget.novel.lastEditTime);
|
||||
|
||||
if (diff.inDays > 30) {
|
||||
return '${(diff.inDays / 30).floor()}个月前';
|
||||
} else if (diff.inDays > 0) {
|
||||
return '${diff.inDays}天前';
|
||||
} else if (diff.inHours > 0) {
|
||||
return '${diff.inHours}小时前';
|
||||
} else if (diff.inMinutes > 0) {
|
||||
return '${diff.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transform: Matrix4.identity()
|
||||
..scale(_isHovered ? 1.02 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? (isDark ? WebTheme.darkGrey500 : WebTheme.grey500)
|
||||
: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: _isHovered ? [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
] : [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cover Image Area - 更紧凑的比例
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 2,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDark ? WebTheme.darkGrey300 : WebTheme.grey300).withOpacity(0.2),
|
||||
(isDark ? WebTheme.darkGrey200 : WebTheme.grey200).withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ImageCacheService().getAdaptiveImage(
|
||||
imageUrl: _getCoverImageUrl(),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
backgroundColor: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
placeholder: 'menu_book',
|
||||
),
|
||||
),
|
||||
// Status Badge
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusBackgroundColor(_getNovelStatus(), context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_getNovelStatus(),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getStatusColor(_getNovelStatus(), context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// More Options Button
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Material(
|
||||
color: (isDark ? WebTheme.darkGrey100 : WebTheme.white).withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 14, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('编辑', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.share, size: 14, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('分享', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 14, color: WebTheme.error),
|
||||
const SizedBox(width: 6),
|
||||
Text('删除', style: TextStyle(color: WebTheme.error, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
widget.onEdit?.call();
|
||||
break;
|
||||
case 'share':
|
||||
widget.onShare?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
widget.onDelete?.call();
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content Area - 更紧凑的布局
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Description
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.novel.description.isNotEmpty
|
||||
? widget.novel.description
|
||||
: '暂无描述',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Category and Rating
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
widget.novel.seriesName.isNotEmpty
|
||||
? widget.novel.seriesName
|
||||
: '独立作品',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.novel.completionPercentage > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.percent,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${widget.novel.completionPercentage.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Stats - 单行显示
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 1),
|
||||
Text(
|
||||
'${(widget.novel.wordCount / 1000).toStringAsFixed(0)}k字',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 1),
|
||||
Text(
|
||||
'${widget.novel.readTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
_formatLastEditTime(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Continue Writing Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 24,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onContinueWriting,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _isHovered
|
||||
? WebTheme.white
|
||||
: WebTheme.getTextColor(context),
|
||||
backgroundColor: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: Colors.transparent,
|
||||
side: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text(
|
||||
'继续创作',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
WebTheme.getSecondaryColor(context).withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.menu_book,
|
||||
size: 32,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
AINoval/lib/widgets/common/compose/chapter_count_field.dart
Normal file
54
AINoval/lib/widgets/common/compose/chapter_count_field.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChapterCountField extends StatelessWidget {
|
||||
final int value;
|
||||
final int min;
|
||||
final int max;
|
||||
final ValueChanged<int> onChanged;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const ChapterCountField({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.min = 1,
|
||||
this.max = 12,
|
||||
required this.onChanged,
|
||||
this.title = '章节数量',
|
||||
this.description = '生成的章节数(黄金三章=3,可自定义)',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(description, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
min: min.toDouble(),
|
||||
max: max.toDouble(),
|
||||
divisions: (max - min),
|
||||
value: value.toDouble().clamp(min.toDouble(), max.toDouble()),
|
||||
label: '$value',
|
||||
onChanged: (v) => onChanged(v.round()),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text('$value', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
99
AINoval/lib/widgets/common/compose/chapter_length_field.dart
Normal file
99
AINoval/lib/widgets/common/compose/chapter_length_field.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChapterLengthField extends StatefulWidget {
|
||||
final String? preset; // 'short' | 'medium' | 'long' | null
|
||||
final String? customLength;
|
||||
final ValueChanged<String?> onPresetChanged;
|
||||
final ValueChanged<String> onCustomChanged;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const ChapterLengthField({
|
||||
super.key,
|
||||
this.preset,
|
||||
this.customLength,
|
||||
required this.onPresetChanged,
|
||||
required this.onCustomChanged,
|
||||
this.title = '每章长度',
|
||||
this.description = '每章期望长度(短/中/长)或自定义字数',
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChapterLengthField> createState() => _ChapterLengthFieldState();
|
||||
}
|
||||
|
||||
class _ChapterLengthFieldState extends State<ChapterLengthField> {
|
||||
late TextEditingController _controller;
|
||||
String? _preset;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_preset = widget.preset;
|
||||
_controller = TextEditingController(text: widget.customLength ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(widget.description, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('短'),
|
||||
selected: _preset == 'short',
|
||||
onSelected: (_) {
|
||||
setState(() { _preset = 'short'; _controller.clear(); });
|
||||
widget.onPresetChanged('short');
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('中'),
|
||||
selected: _preset == 'medium',
|
||||
onSelected: (_) {
|
||||
setState(() { _preset = 'medium'; _controller.clear(); });
|
||||
widget.onPresetChanged('medium');
|
||||
},
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('长'),
|
||||
selected: _preset == 'long',
|
||||
onSelected: (_) {
|
||||
setState(() { _preset = 'long'; _controller.clear(); });
|
||||
widget.onPresetChanged('long');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '自定义字数,如 2000 字',
|
||||
),
|
||||
onChanged: (v) {
|
||||
setState(() { _preset = null; });
|
||||
widget.onCustomChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
AINoval/lib/widgets/common/compose/include_depth_field.dart
Normal file
47
AINoval/lib/widgets/common/compose/include_depth_field.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class IncludeDepthField extends StatelessWidget {
|
||||
final String value; // 'summaryOnly' | 'full'
|
||||
final ValueChanged<String> onChanged;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const IncludeDepthField({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.title = '上下文深度',
|
||||
this.description = '选择将设定或既有内容以摘要或全文形式纳入上下文',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 6),
|
||||
Text(description, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('仅摘要'),
|
||||
selected: value == 'summaryOnly',
|
||||
onSelected: (_) => onChanged('summaryOnly'),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('全文'),
|
||||
selected: value == 'full',
|
||||
onSelected: (_) => onChanged('full'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
210
AINoval/lib/widgets/common/context_badge.dart
Normal file
210
AINoval/lib/widgets/common/context_badge.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 上下文数据
|
||||
class ContextData {
|
||||
/// 构造函数
|
||||
const ContextData({
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.id,
|
||||
});
|
||||
|
||||
/// 标题
|
||||
final String title;
|
||||
|
||||
/// 副标题(可选)
|
||||
final String? subtitle;
|
||||
|
||||
/// 图标(可选,如果不提供会根据内容自动判断)
|
||||
final IconData? icon;
|
||||
|
||||
/// 唯一标识(可选)
|
||||
final String? id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ContextData &&
|
||||
other.title == title &&
|
||||
other.subtitle == subtitle &&
|
||||
other.icon == icon &&
|
||||
other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return title.hashCode ^
|
||||
subtitle.hashCode ^
|
||||
icon.hashCode ^
|
||||
id.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文标签组件
|
||||
/// 显示上下文信息,支持删除操作,风格简洁现代
|
||||
class ContextBadge extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const ContextBadge({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.onDelete,
|
||||
this.maxWidth = 200,
|
||||
this.showDeleteButton = true,
|
||||
this.globalKey,
|
||||
});
|
||||
|
||||
/// 上下文数据
|
||||
final ContextData data;
|
||||
|
||||
/// 删除回调
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// 最大宽度
|
||||
final double maxWidth;
|
||||
|
||||
/// 是否显示删除按钮
|
||||
final bool showDeleteButton;
|
||||
|
||||
/// 全局Key,用于定位
|
||||
final GlobalKey? globalKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
key: globalKey,
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
height: 36, // h-9 equivalent
|
||||
decoration: BoxDecoration(
|
||||
color: _getBackgroundColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 图标
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8),
|
||||
child: Icon(
|
||||
_getIcon(),
|
||||
size: 16, // size-4 equivalent
|
||||
color: _getIconColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
// 内容
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
data.title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600, // font-semibold
|
||||
color: _getTextColor(context),
|
||||
height: 1.2, // leading-tight
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// 副标题
|
||||
if (data.subtitle != null && data.subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
data.subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 10, // text-xs
|
||||
color: _getSubtitleColor(context),
|
||||
height: 1.2, // leading-tight
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 删除按钮
|
||||
if (showDeleteButton)
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onDelete,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: 20, // h-5 w-5
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14, // h-3.5 w-3.5
|
||||
color: _getDeleteButtonColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取图标
|
||||
IconData _getIcon() {
|
||||
// 如果提供了自定义图标,直接使用
|
||||
if (data.icon != null) {
|
||||
return data.icon!;
|
||||
}
|
||||
|
||||
// 根据标题内容自动判断图标
|
||||
final title = data.title.toLowerCase();
|
||||
|
||||
if (title.contains('act') || title.contains('chapter') || title.contains('scene')) {
|
||||
return Icons.menu_book_outlined; // block-quote equivalent
|
||||
} else if (title.contains('novel') || title.contains('book') || title.contains('text')) {
|
||||
return Icons.menu_book; // book-open equivalent
|
||||
} else if (title.contains('folder') || title.contains('directory')) {
|
||||
return Icons.folder_outlined; // folder-closed equivalent
|
||||
} else {
|
||||
return Icons.description_outlined; // 默认文档图标
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取背景颜色
|
||||
Color _getBackgroundColor(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final title = data.title.toLowerCase();
|
||||
final bool isContent = title.contains('novel') || title.contains('book') || title.contains('text');
|
||||
return isContent ? scheme.surfaceContainerHigh : scheme.surfaceContainer;
|
||||
}
|
||||
|
||||
/// 获取图标颜色
|
||||
Color _getIconColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
/// 获取文字颜色
|
||||
Color _getTextColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.onSurface;
|
||||
}
|
||||
|
||||
/// 获取副标题颜色
|
||||
Color _getSubtitleColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
/// 获取删除按钮颜色
|
||||
Color _getDeleteButtonColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart'; // kDebugMode
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
|
||||
/// 基于MenuAnchor的上下文选择下拉框组件(官方级联菜单实现)
|
||||
class ContextSelectionDropdownMenuAnchor extends StatefulWidget {
|
||||
const ContextSelectionDropdownMenuAnchor({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.onSelectionChanged,
|
||||
this.placeholder = '选择上下文',
|
||||
this.maxHeight = 400,
|
||||
this.width,
|
||||
this.initialChapterId,
|
||||
this.initialSceneId,
|
||||
this.typeColorMap,
|
||||
this.typeColorResolver,
|
||||
});
|
||||
|
||||
/// 上下文选择数据
|
||||
final ContextSelectionData data;
|
||||
|
||||
/// 选择变化回调
|
||||
final ValueChanged<ContextSelectionData> onSelectionChanged;
|
||||
|
||||
/// 占位符文字
|
||||
final String placeholder;
|
||||
|
||||
/// 下拉框最大高度
|
||||
final double maxHeight;
|
||||
|
||||
/// 宽度
|
||||
final double? width;
|
||||
|
||||
/// 初始聚焦的章节ID(用于长列表初始滚动定位)
|
||||
final String? initialChapterId;
|
||||
|
||||
/// 初始聚焦的场景ID(用于长列表初始滚动定位)
|
||||
final String? initialSceneId;
|
||||
|
||||
/// 自定义类型-颜色映射(优先级低于 typeColorResolver)
|
||||
final Map<ContextSelectionType, Color>? typeColorMap;
|
||||
|
||||
/// 自定义颜色解析器(优先级最高)
|
||||
final Color Function(ContextSelectionType type, BuildContext context)? typeColorResolver;
|
||||
|
||||
@override
|
||||
State<ContextSelectionDropdownMenuAnchor> createState() =>
|
||||
_ContextSelectionDropdownMenuAnchorState();
|
||||
}
|
||||
|
||||
class _ContextSelectionDropdownMenuAnchorState
|
||||
extends State<ContextSelectionDropdownMenuAnchor> {
|
||||
final MenuController _menuController = MenuController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final double menuWidth = widget.width ?? 280;
|
||||
|
||||
return MenuAnchor(
|
||||
controller: _menuController,
|
||||
style: MenuStyle(
|
||||
backgroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
elevation: WidgetStateProperty.all(8),
|
||||
shadowColor: WidgetStateProperty.all(
|
||||
WebTheme.getShadowColor(context, opacity: 0.3),
|
||||
),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context, controller, child) {
|
||||
return _buildTriggerButton(context, controller, isDark);
|
||||
},
|
||||
menuChildren: [
|
||||
// 头部操作栏
|
||||
_buildHeaderMenuItem(context, isDark, menuWidth),
|
||||
|
||||
// 分割线
|
||||
const Divider(height: 1),
|
||||
|
||||
// 菜单项(对长列表进行虚拟化构建)
|
||||
...widget.data.availableItems.map((item) => _buildMenuItem(item, context, menuWidth)),
|
||||
|
||||
// 底部取消选择选项
|
||||
if (widget.data.selectedCount > 0) ...[
|
||||
const Divider(height: 1),
|
||||
_buildCancelSelectionMenuItem(context, isDark, menuWidth),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建触发按钮
|
||||
Widget _buildTriggerButton(BuildContext context, MenuController controller, bool isDark) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
splashColor: WebTheme.getPrimaryColor(context).withOpacity(0.10),
|
||||
highlightColor: WebTheme.getPrimaryColor(context).withOpacity(0.12),
|
||||
child: Container(
|
||||
height: 36, // 与标签高度保持一致
|
||||
padding: const EdgeInsets.only(left: 6, right: 10, top: 8, bottom: 8), // 调整垂直内边距以居中
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent, // 背景透明
|
||||
border: Border.all(
|
||||
color: Colors.transparent, // 边框透明
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6), // rounded-md
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min, // 让按钮自适应内容大小
|
||||
children: [
|
||||
// 加号图标
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 16, // w-4 h-4 对应16px
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6), // gap-1.5 对应约6px
|
||||
|
||||
// Context文本
|
||||
Text(
|
||||
'Context',
|
||||
style: TextStyle(
|
||||
fontSize: 12, // text-xs 对应12px
|
||||
fontWeight: FontWeight.w600, // font-semibold
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5, // tracking-wide
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头部菜单项
|
||||
Widget _buildHeaderMenuItem(BuildContext context, bool isDark, double menuWidth) {
|
||||
return MenuItemButton(
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||
backgroundColor: WidgetStateProperty.all(Colors.transparent),
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
minimumSize: WidgetStateProperty.all(Size(menuWidth, 44)),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
onPressed: null, // 禁用点击
|
||||
child: SizedBox(
|
||||
width: menuWidth,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'添加上下文',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// 清除选择按钮
|
||||
if (widget.data.selectedCount > 0)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
_clearSelection();
|
||||
_menuController.close();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
'清除选择',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建菜单项
|
||||
Widget _buildMenuItem(ContextSelectionItem item, BuildContext context, double menuWidth) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final bool isGroup = item.type == ContextSelectionType.contentFixedGroup || item.type == ContextSelectionType.summaryFixedGroup;
|
||||
|
||||
if (isGroup) {
|
||||
// 固定分组(内容/摘要):使用普通子项列表,避免可滚动视图在菜单中的布局问题
|
||||
return SubmenuButton(
|
||||
style: _getMenuItemButtonStyle(menuWidth),
|
||||
child: _buildMenuItemContent(context, item, true),
|
||||
menuChildren: [
|
||||
// 直接渲染子项列表(数量较少,无需虚拟化)
|
||||
...item.children.map((child) => _buildSubMenuItem(child, context, menuWidth)),
|
||||
const Divider(height: 1),
|
||||
_buildSubmenuCancelSelectionMenuItem(item, isDark, menuWidth),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (item.hasChildren && item.children.isNotEmpty) {
|
||||
// 有子项的容器项 - 使用SubmenuButton
|
||||
return SubmenuButton(
|
||||
style: _getMenuItemButtonStyle(menuWidth),
|
||||
child: _buildMenuItemContent(context, item, true),
|
||||
// 用 Builder 包裹,确保子菜单获得稳定的布局上下文
|
||||
menuChildren: [
|
||||
Builder(builder: (subCtx) {
|
||||
return _buildVirtualizedSubmenuList(
|
||||
parent: item,
|
||||
context: subCtx,
|
||||
// 行高大约44,对齐 _getMenuItemButtonStyle 的 minimumSize
|
||||
itemExtent: 44,
|
||||
maxHeight: widget.maxHeight,
|
||||
menuWidth: menuWidth,
|
||||
);
|
||||
}),
|
||||
const Divider(height: 1),
|
||||
_buildSubmenuCancelSelectionMenuItem(item, isDark, menuWidth),
|
||||
],
|
||||
);
|
||||
} else if (item.hasChildren && item.children.isEmpty) {
|
||||
// 空容器项 - 使用SubmenuButton显示空状态
|
||||
return SubmenuButton(
|
||||
style: _getMenuItemButtonStyle(menuWidth),
|
||||
child: _buildMenuItemContent(context, item, true),
|
||||
menuChildren: [
|
||||
_buildEmptySubmenuContent(item, isDark, menuWidth),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// 叶子节点项 - 使用MenuItemButton
|
||||
return MenuItemButton(
|
||||
style: _getMenuItemButtonStyle(menuWidth),
|
||||
onPressed: () => _onItemTap(item),
|
||||
child: SizedBox(width: menuWidth, child: _buildMenuItemContent(context, item, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用虚拟化方式渲染子菜单列表,支持初始滚动到目标章节/场景
|
||||
Widget _buildVirtualizedSubmenuList({
|
||||
required ContextSelectionItem parent,
|
||||
required BuildContext context,
|
||||
required double itemExtent,
|
||||
required double maxHeight,
|
||||
required double menuWidth,
|
||||
}) {
|
||||
// 计算初始滚动定位索引
|
||||
final int initialIndex = _computeInitialIndexForParent(parent);
|
||||
|
||||
// 计算高度:最多不超过 maxHeight,也不超过总高度
|
||||
final double computedHeight = (parent.children.length * itemExtent).clamp(
|
||||
itemExtent,
|
||||
maxHeight,
|
||||
);
|
||||
|
||||
// 使用固定高度盒子,确保子 ListView 获得有界约束,避免 RenderBox 未布局错误
|
||||
return SizedBox(
|
||||
height: computedHeight,
|
||||
width: menuWidth,
|
||||
child: _VirtualizedMenuList(
|
||||
items: parent.children,
|
||||
itemExtent: itemExtent,
|
||||
initialIndex: initialIndex >= 0 ? initialIndex : null,
|
||||
itemBuilder: (child) => _buildSubMenuItem(child, context, menuWidth),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 计算在父级子项中的初始索引,用于滚动到当前章节/场景
|
||||
int _computeInitialIndexForParent(ContextSelectionItem parent) {
|
||||
// 优先使用场景定位
|
||||
if (widget.initialSceneId != null && widget.initialSceneId!.isNotEmpty) {
|
||||
final sceneId = widget.initialSceneId!;
|
||||
// 支持平铺ID(flat_ 前缀)与层级ID
|
||||
final flatSceneId = 'flat_${sceneId}';
|
||||
for (int i = 0; i < parent.children.length; i++) {
|
||||
final child = parent.children[i];
|
||||
if (child.id == sceneId || child.id == flatSceneId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 其次使用章节定位
|
||||
if (widget.initialChapterId != null && widget.initialChapterId!.isNotEmpty) {
|
||||
final chapterId = widget.initialChapterId!;
|
||||
final flatChapterId = 'flat_${chapterId}';
|
||||
for (int i = 0; i < parent.children.length; i++) {
|
||||
final child = parent.children[i];
|
||||
if (child.id == chapterId || child.id == flatChapterId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// 构建子菜单项
|
||||
Widget _buildSubMenuItem(ContextSelectionItem item, BuildContext context, double menuWidth) {
|
||||
return MenuItemButton(
|
||||
style: _getMenuItemButtonStyle(menuWidth),
|
||||
onPressed: () => _onItemTap(item),
|
||||
child: SizedBox(width: menuWidth, child: _buildMenuItemContent(context, item, false)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建菜单项内容
|
||||
Widget _buildMenuItemContent(BuildContext context, ContextSelectionItem item, bool isContainer) {
|
||||
final bool isRadioGroupChild = item.parentId != null && (widget.data.flatItems[item.parentId!]!.type == ContextSelectionType.contentFixedGroup || widget.data.flatItems[item.parentId!]!.type == ContextSelectionType.summaryFixedGroup);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 选择状态图标(固定分组子项用单选样式)
|
||||
if (isRadioGroupChild)
|
||||
Icon(
|
||||
item.selectionState.isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 16,
|
||||
color: item.selectionState.isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outlineVariant,
|
||||
)
|
||||
else
|
||||
_buildSelectionIcon(context, item.selectionState, isContainer),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 类型图标
|
||||
Icon(
|
||||
item.type.icon,
|
||||
size: 16,
|
||||
color: _getTypeIconColor(item.type, context),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 标题和副标题
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: item.selectionState.isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (item.displaySubtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.displaySubtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空子菜单内容
|
||||
Widget _buildEmptySubmenuContent(ContextSelectionItem item, bool isDark, double menuWidth) {
|
||||
String emptyMessage;
|
||||
|
||||
switch (item.type) {
|
||||
case ContextSelectionType.acts:
|
||||
emptyMessage = '没有卷';
|
||||
break;
|
||||
case ContextSelectionType.chapters:
|
||||
emptyMessage = '没有章节';
|
||||
break;
|
||||
case ContextSelectionType.scenes:
|
||||
emptyMessage = '没有场景';
|
||||
break;
|
||||
default:
|
||||
emptyMessage = '暂无内容';
|
||||
break;
|
||||
}
|
||||
|
||||
// 使用固定高度的容器,避免未布局的 TapRegion/hitTest 问题
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
width: menuWidth,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
emptyMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
height: 1.2,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取菜单项按钮样式
|
||||
ButtonStyle _getMenuItemButtonStyle(double menuWidth) {
|
||||
return ButtonStyle(
|
||||
padding: WidgetStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
minimumSize: WidgetStateProperty.all(Size(menuWidth, 44)),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
alignment: Alignment.centerLeft,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建选择状态图标
|
||||
Widget _buildSelectionIcon(BuildContext context, SelectionState state, bool isContainer) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// 容器类型(Acts、Chapters、Scenes)的显示逻辑
|
||||
if (isContainer) {
|
||||
switch (state) {
|
||||
case SelectionState.fullySelected:
|
||||
case SelectionState.partiallySelected:
|
||||
// 容器有子项被选中时显示圆点
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case SelectionState.unselected:
|
||||
// 容器没有子项被选中时不显示图标
|
||||
return const SizedBox(width: 16, height: 16);
|
||||
}
|
||||
}
|
||||
|
||||
// 非容器类型(Full Novel Text、Full Outline等)的显示逻辑
|
||||
switch (state) {
|
||||
case SelectionState.fullySelected:
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: scheme.primary,
|
||||
);
|
||||
case SelectionState.partiallySelected:
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.outlineVariant,
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: scheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
case SelectionState.unselected:
|
||||
return Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: scheme.outlineVariant,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取类型图标颜色
|
||||
Color _getTypeIconColor(ContextSelectionType type, BuildContext context) {
|
||||
// 优先使用外部解析器
|
||||
if (widget.typeColorResolver != null) {
|
||||
try {
|
||||
return widget.typeColorResolver!(type, context);
|
||||
} catch (_) {}
|
||||
}
|
||||
// 其次使用外部映射
|
||||
if (widget.typeColorMap != null) {
|
||||
final mapped = widget.typeColorMap![type];
|
||||
if (mapped != null) return mapped;
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
switch (type) {
|
||||
case ContextSelectionType.fullNovelText:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.fullOutline:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.contentFixedGroup:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.summaryFixedGroup:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.currentSceneContent:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.currentSceneSummary:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.currentChapterContent:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.currentChapterSummaries:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.previousChaptersContent:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.previousChaptersSummary:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.novelBasicInfo:
|
||||
return scheme.tertiary;
|
||||
case ContextSelectionType.recentChaptersContent:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.recentChaptersSummary:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.acts:
|
||||
return scheme.tertiary;
|
||||
case ContextSelectionType.chapters:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.scenes:
|
||||
return scheme.primary;
|
||||
case ContextSelectionType.snippets:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.settings:
|
||||
return scheme.tertiary;
|
||||
case ContextSelectionType.settingGroups:
|
||||
return scheme.secondary;
|
||||
case ContextSelectionType.settingsByType:
|
||||
return scheme.secondary;
|
||||
default:
|
||||
return scheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取显示文本
|
||||
// String _getDisplayText() {
|
||||
// if (widget.data.selectedCount == 0) {
|
||||
// return widget.placeholder;
|
||||
// } else if (widget.data.selectedCount == 1) {
|
||||
// final selectedItem = widget.data.selectedItems.values.first;
|
||||
// return selectedItem.title;
|
||||
// } else {
|
||||
// return '已选择 ${widget.data.selectedCount} 项';
|
||||
// }
|
||||
// }
|
||||
|
||||
/// 项目点击处理
|
||||
void _onItemTap(ContextSelectionItem item) {
|
||||
ContextSelectionData newData;
|
||||
|
||||
if (item.selectionState.isSelected) {
|
||||
// 取消选择
|
||||
newData = widget.data.deselectItem(item.id);
|
||||
} else {
|
||||
// 选择
|
||||
newData = widget.data.selectItem(item.id);
|
||||
}
|
||||
|
||||
widget.onSelectionChanged(newData);
|
||||
|
||||
// 保持菜单开启,允许多选
|
||||
// 如果需要选择后自动关闭,可以调用 _menuController.close();
|
||||
}
|
||||
|
||||
/// 清除选择
|
||||
void _clearSelection() {
|
||||
final newData = ContextSelectionData(
|
||||
novelId: widget.data.novelId,
|
||||
availableItems: widget.data.availableItems,
|
||||
flatItems: widget.data.flatItems.map(
|
||||
(key, value) => MapEntry(key, value.copyWith(selectionState: SelectionState.unselected)),
|
||||
),
|
||||
);
|
||||
|
||||
widget.onSelectionChanged(newData);
|
||||
}
|
||||
|
||||
/// 构建取消选择菜单项
|
||||
Widget _buildCancelSelectionMenuItem(BuildContext context, bool isDark, double menuWidth) {
|
||||
return MenuItemButton(
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||
minimumSize: WidgetStateProperty.all(Size(menuWidth, 44)),
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
onPressed: () {
|
||||
_clearSelection();
|
||||
_menuController.close();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: menuWidth,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.clear_all,
|
||||
size: 16,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'取消当前所选的选择',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部留白
|
||||
// Widget _buildBottomSpacing() {
|
||||
// return MenuItemButton(
|
||||
// style: ButtonStyle(
|
||||
// padding: WidgetStateProperty.all(EdgeInsets.zero),
|
||||
// backgroundColor: WidgetStateProperty.all(Colors.transparent),
|
||||
// overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
// minimumSize: WidgetStateProperty.all(const Size.fromHeight(20)),
|
||||
// ),
|
||||
// onPressed: null,
|
||||
// child: const SizedBox.shrink(),
|
||||
// );
|
||||
// }
|
||||
|
||||
/// 构建子菜单取消选择菜单项
|
||||
Widget _buildSubmenuCancelSelectionMenuItem(ContextSelectionItem parentItem, bool isDark, double menuWidth) {
|
||||
// 检查父级项目下是否有选中的子项
|
||||
final hasSelectedChildren = parentItem.children.any((child) => child.selectionState.isSelected);
|
||||
|
||||
// 在调试模式下输出详细信息, 生产环境默认静默
|
||||
if (kDebugMode) {
|
||||
// debug logs removed in release
|
||||
}
|
||||
|
||||
// 🚀 即使没有选中项也显示,但禁用状态(用于调试)
|
||||
// if (!hasSelectedChildren) {
|
||||
// return const SizedBox.shrink();
|
||||
// }
|
||||
|
||||
return MenuItemButton(
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||
minimumSize: WidgetStateProperty.all(Size(menuWidth, 44)),
|
||||
alignment: Alignment.centerLeft,
|
||||
// 🚀 如果没有选中项,禁用按钮但仍显示
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (!hasSelectedChildren) {
|
||||
return Colors.transparent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
onPressed: hasSelectedChildren ? () {
|
||||
if (kDebugMode) //debugPrint('🚀 执行子菜单取消选择: ${parentItem.title}');
|
||||
_clearSubmenuSelection(parentItem);
|
||||
_menuController.close();
|
||||
} : null, // 🚀 没有选中项时禁用
|
||||
child: SizedBox(
|
||||
width: menuWidth,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.clear_outlined,
|
||||
size: 16,
|
||||
color: hasSelectedChildren
|
||||
? (isDark ? WebTheme.darkGrey500 : WebTheme.grey500)
|
||||
: (isDark ? WebTheme.darkGrey300 : WebTheme.grey300), // 🚀 禁用状态颜色
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
hasSelectedChildren
|
||||
? '取消当前子菜单选择'
|
||||
: '取消当前子菜单选择 (无选中项)', // 🚀 显示状态信息
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: hasSelectedChildren
|
||||
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400), // 🚀 禁用状态颜色
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 清除子菜单选择
|
||||
void _clearSubmenuSelection(ContextSelectionItem parentItem) {
|
||||
ContextSelectionData newData = widget.data;
|
||||
|
||||
|
||||
widget.onSelectionChanged(newData);
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文选择下拉框构建器(MenuAnchor版本)
|
||||
class ContextSelectionDropdownBuilder {
|
||||
/// 创建基于MenuAnchor的上下文选择下拉框
|
||||
static Widget buildMenuAnchor({
|
||||
required ContextSelectionData data,
|
||||
required ValueChanged<ContextSelectionData> onSelectionChanged,
|
||||
String placeholder = '选择上下文',
|
||||
double? width,
|
||||
double maxHeight = 400,
|
||||
String? initialChapterId,
|
||||
String? initialSceneId,
|
||||
Map<ContextSelectionType, Color>? typeColorMap,
|
||||
Color Function(ContextSelectionType type, BuildContext context)? typeColorResolver,
|
||||
}) {
|
||||
return ContextSelectionDropdownMenuAnchor(
|
||||
data: data,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
placeholder: placeholder,
|
||||
width: width,
|
||||
maxHeight: maxHeight,
|
||||
initialChapterId: initialChapterId,
|
||||
initialSceneId: initialSceneId,
|
||||
typeColorMap: typeColorMap,
|
||||
typeColorResolver: typeColorResolver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 子菜单虚拟化列表,支持初始定位到指定索引
|
||||
class _VirtualizedMenuList extends StatefulWidget {
|
||||
const _VirtualizedMenuList({
|
||||
required this.items,
|
||||
required this.itemExtent,
|
||||
required this.itemBuilder,
|
||||
this.initialIndex,
|
||||
});
|
||||
|
||||
final List<ContextSelectionItem> items;
|
||||
final double itemExtent;
|
||||
final int? initialIndex;
|
||||
final Widget Function(ContextSelectionItem item) itemBuilder;
|
||||
|
||||
@override
|
||||
State<_VirtualizedMenuList> createState() => _VirtualizedMenuListState();
|
||||
}
|
||||
|
||||
class _VirtualizedMenuListState extends State<_VirtualizedMenuList> {
|
||||
late final ScrollController _controller;
|
||||
bool _didJump = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = ScrollController();
|
||||
if (widget.initialIndex != null && widget.initialIndex! >= 0) {
|
||||
// 延迟到首帧后跳转,避免布局尚未完成导致的异常
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && !_didJump) {
|
||||
final double offset = widget.initialIndex! * widget.itemExtent;
|
||||
_controller.jumpTo(offset.clamp(0.0, (_controller.position.maxScrollExtent)));
|
||||
_didJump = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RepaintBoundary(
|
||||
child: Scrollbar(
|
||||
controller: _controller,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
child: ListView.builder(
|
||||
controller: _controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemExtent: widget.itemExtent,
|
||||
itemCount: widget.items.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
addRepaintBoundaries: true,
|
||||
addSemanticIndexes: false,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.items[index];
|
||||
// 子项本身已经包含视觉与交互,这里直接返回
|
||||
return widget.itemBuilder(item);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
361
AINoval/lib/widgets/common/credit_display.dart
Normal file
361
AINoval/lib/widgets/common/credit_display.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/auth/auth_bloc.dart';
|
||||
import 'package:ainoval/blocs/credit/credit_bloc.dart';
|
||||
// import 'package:ainoval/models/user_credit.dart';
|
||||
|
||||
/// 积分显示组件
|
||||
/// 用于在聊天输入框等位置显示用户当前积分
|
||||
class CreditDisplay extends StatefulWidget {
|
||||
const CreditDisplay({
|
||||
super.key,
|
||||
this.size = CreditDisplaySize.small,
|
||||
this.showRefreshButton = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// 显示尺寸
|
||||
final CreditDisplaySize size;
|
||||
|
||||
/// 是否显示刷新按钮
|
||||
final bool showRefreshButton;
|
||||
|
||||
/// 点击回调
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
State<CreditDisplay> createState() => _CreditDisplayState();
|
||||
}
|
||||
|
||||
class _CreditDisplayState extends State<CreditDisplay> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 组件初始化时加载积分信息
|
||||
try {
|
||||
final authed = context.read<AuthBloc>().state is AuthAuthenticated;
|
||||
if (!authed) return;
|
||||
// 若已在加载或已加载,避免重复触发
|
||||
final state = context.read<CreditBloc>().state;
|
||||
if (state is CreditLoading || state is CreditLoaded) return;
|
||||
context.read<CreditBloc>().add(const LoadUserCredits());
|
||||
} catch (_) {
|
||||
// 在无 AuthBloc 场景下静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<CreditBloc, CreditState>(
|
||||
builder: (context, state) {
|
||||
return _buildCreditWidget(context, state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreditWidget(BuildContext context, CreditState state) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (state is CreditLoading) {
|
||||
return _buildLoadingWidget(colorScheme);
|
||||
}
|
||||
|
||||
if (state is CreditError) {
|
||||
return _buildErrorWidget(context, colorScheme, state.message);
|
||||
}
|
||||
|
||||
if (state is CreditLoaded) {
|
||||
return _buildLoadedWidget(context, colorScheme, isDark, state);
|
||||
}
|
||||
|
||||
// 默认状态(游客视为0积分)
|
||||
return _buildGuestWidget(context, colorScheme);
|
||||
}
|
||||
|
||||
/// 构建加载中的小部件
|
||||
Widget _buildLoadingWidget(ColorScheme colorScheme) {
|
||||
final double size = _getIconSize();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: _getContainerDecoration(colorScheme, false),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.size != CreditDisplaySize.iconOnly) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'...',
|
||||
style: _getTextStyle(colorScheme),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误状态的小部件
|
||||
Widget _buildErrorWidget(BuildContext context, ColorScheme colorScheme, String message) {
|
||||
final double iconSize = _getIconSize();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap ?? () {
|
||||
try {
|
||||
final authed = context.read<AuthBloc>().state is AuthAuthenticated;
|
||||
if (authed) {
|
||||
context.read<CreditBloc>().add(const LoadUserCredits());
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: _getContainerDecoration(colorScheme, false),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: iconSize,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
if (widget.size != CreditDisplaySize.iconOnly) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'错误',
|
||||
style: _getTextStyle(colorScheme).copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建已加载状态的小部件
|
||||
Widget _buildLoadedWidget(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
bool isDark,
|
||||
CreditLoaded state,
|
||||
) {
|
||||
final double iconSize = _getIconSize();
|
||||
final bool isLowCredit = state.userCredit.credits < 100; // 小于100积分视为余额不足
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: _getContainerDecoration(colorScheme, isLowCredit),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildGradientIcon(colorScheme, isDark, iconSize),
|
||||
if (widget.size != CreditDisplaySize.iconOnly) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatCredits(state.userCredit.credits),
|
||||
style: _getTextStyle(colorScheme).copyWith(
|
||||
fontWeight: isLowCredit ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
// 刷新按钮
|
||||
if (widget.showRefreshButton) ...[
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () => context.read<CreditBloc>().add(const RefreshUserCredits()),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.refresh,
|
||||
size: iconSize * 0.8,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建游客状态的小部件(显示0)
|
||||
Widget _buildGuestWidget(BuildContext context, ColorScheme colorScheme) {
|
||||
final double iconSize = _getIconSize();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap, // 游客不触发加载
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: _getContainerDecoration(colorScheme, false),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildGradientIcon(colorScheme, Theme.of(context).brightness == Brightness.dark, iconSize),
|
||||
if (widget.size != CreditDisplaySize.iconOnly) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'0',
|
||||
style: _getTextStyle(colorScheme).copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取图标尺寸
|
||||
double _getIconSize() {
|
||||
switch (widget.size) {
|
||||
case CreditDisplaySize.small:
|
||||
return 14;
|
||||
case CreditDisplaySize.medium:
|
||||
return 16;
|
||||
case CreditDisplaySize.large:
|
||||
return 20;
|
||||
case CreditDisplaySize.iconOnly:
|
||||
return 16;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取内边距
|
||||
EdgeInsets _getPadding() {
|
||||
switch (widget.size) {
|
||||
case CreditDisplaySize.small:
|
||||
return const EdgeInsets.symmetric(horizontal: 8, vertical: 4);
|
||||
case CreditDisplaySize.medium:
|
||||
return const EdgeInsets.symmetric(horizontal: 10, vertical: 6);
|
||||
case CreditDisplaySize.large:
|
||||
return const EdgeInsets.symmetric(horizontal: 12, vertical: 8);
|
||||
case CreditDisplaySize.iconOnly:
|
||||
return const EdgeInsets.all(6);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文本样式
|
||||
TextStyle _getTextStyle(ColorScheme colorScheme) {
|
||||
switch (widget.size) {
|
||||
case CreditDisplaySize.small:
|
||||
return TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
case CreditDisplaySize.medium:
|
||||
return TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
case CreditDisplaySize.large:
|
||||
return TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
case CreditDisplaySize.iconOnly:
|
||||
return const TextStyle(); // 不显示文本
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取容器装饰
|
||||
BoxDecoration _getContainerDecoration(ColorScheme colorScheme, bool isLowCredit) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
// 背景与主题一致:使用表面容器色系,形成轻微对比;取消红色处理
|
||||
final Color backgroundColor = isDark
|
||||
? colorScheme.surfaceContainerHighest.withOpacity(0.6)
|
||||
: colorScheme.surfaceContainerHighest.withOpacity(0.8);
|
||||
final Color borderColor = colorScheme.outline.withOpacity(0.2);
|
||||
|
||||
return BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(widget.size == CreditDisplaySize.iconOnly ? 12 : 8),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: 0.8,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 渐变图标:星光图标 + 主题友好的多彩渐变
|
||||
Widget _buildGradientIcon(ColorScheme colorScheme, bool isDark, double size) {
|
||||
final List<Color> colors = _getIconGradientColors(isDark);
|
||||
return ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: colors,
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.srcIn,
|
||||
child: Icon(
|
||||
Icons.auto_awesome,
|
||||
size: size,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Color> _getIconGradientColors(bool isDark) {
|
||||
// 深浅色模式下均使用鲜明但优雅的配色
|
||||
return isDark
|
||||
? const [
|
||||
Color(0xFF60A5FA), // blue-400
|
||||
Color(0xFF8B5CF6), // violet-500
|
||||
Color(0xFFF472B6), // pink-400
|
||||
]
|
||||
: const [
|
||||
Color(0xFF6366F1), // indigo-500
|
||||
Color(0xFF8B5CF6), // violet-500
|
||||
Color(0xFFEC4899), // pink-500
|
||||
];
|
||||
}
|
||||
|
||||
/// 格式化积分显示
|
||||
String _formatCredits(num credits) {
|
||||
if (credits >= 10000) {
|
||||
return '${(credits / 1000).toStringAsFixed(1)}K';
|
||||
} else if (credits >= 1000) {
|
||||
return '${(credits / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return credits.toStringAsFixed(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分显示尺寸
|
||||
enum CreditDisplaySize {
|
||||
/// 小尺寸,适用于工具栏
|
||||
small,
|
||||
|
||||
/// 中等尺寸,适用于一般用途
|
||||
medium,
|
||||
|
||||
/// 大尺寸,适用于强调显示
|
||||
large,
|
||||
|
||||
/// 仅图标,不显示文本
|
||||
iconOnly,
|
||||
}
|
||||
248
AINoval/lib/widgets/common/custom_dropdown.dart
Normal file
248
AINoval/lib/widgets/common/custom_dropdown.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 下拉选项
|
||||
class DropdownOption<T> {
|
||||
/// 构造函数
|
||||
const DropdownOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 选项值
|
||||
final T value;
|
||||
|
||||
/// 显示标签
|
||||
final String label;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
/// 自定义下拉选择器组件
|
||||
/// 提供统一的下拉选择器样式和功能
|
||||
class CustomDropdown<T> extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const CustomDropdown({
|
||||
super.key,
|
||||
required this.options,
|
||||
this.value,
|
||||
required this.onChanged,
|
||||
this.placeholder = '请选择...',
|
||||
this.enabled = true,
|
||||
this.width,
|
||||
this.height = 36,
|
||||
});
|
||||
|
||||
/// 选项列表
|
||||
final List<DropdownOption<T>> options;
|
||||
|
||||
/// 当前选中值
|
||||
final T? value;
|
||||
|
||||
/// 值改变回调
|
||||
final ValueChanged<T?> onChanged;
|
||||
|
||||
/// 占位符文字
|
||||
final String placeholder;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
|
||||
/// 宽度
|
||||
final double? width;
|
||||
|
||||
/// 高度
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
final selectedOption = options.where((option) => option.value == value).firstOrNull;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: enabled ? () => _showDropdown(context) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选中值或占位符
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedOption?.label ?? placeholder,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: selectedOption != null ? FontWeight.w500 : FontWeight.normal,
|
||||
color: selectedOption != null
|
||||
? (enabled
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.7))
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 下拉箭头
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示下拉菜单
|
||||
void _showDropdown(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
final Offset offset = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
|
||||
showMenu<T>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
offset.dx,
|
||||
offset.dy + size.height + 4,
|
||||
offset.dx + size.width,
|
||||
offset.dy + size.height + 4,
|
||||
),
|
||||
items: options.map((option) => PopupMenuItem<T>(
|
||||
value: option.value,
|
||||
enabled: option.enabled,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minWidth: size.width - 2),
|
||||
child: Text(
|
||||
option.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: option.enabled
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shadowColor: WebTheme.getShadowColor(context, opacity: 0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
).then((selectedValue) {
|
||||
if (selectedValue != null) {
|
||||
onChanged(selectedValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 带添加按钮的下拉选择器
|
||||
class DropdownWithAddButton<T> extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const DropdownWithAddButton({
|
||||
super.key,
|
||||
required this.dropdown,
|
||||
required this.onAdd,
|
||||
this.addLabel = '添加',
|
||||
this.addIcon = Icons.add,
|
||||
});
|
||||
|
||||
/// 下拉选择器
|
||||
final CustomDropdown<T> dropdown;
|
||||
|
||||
/// 添加按钮回调
|
||||
final VoidCallback onAdd;
|
||||
|
||||
/// 添加按钮文字
|
||||
final String addLabel;
|
||||
|
||||
/// 添加按钮图标
|
||||
final IconData addIcon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 下拉选择器
|
||||
Flexible(child: dropdown),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 添加按钮
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: dropdown.enabled ? onAdd : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
height: dropdown.height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: dropdown.enabled
|
||||
? (isDark ? WebTheme.darkGrey100 : WebTheme.white)
|
||||
: (isDark ? WebTheme.darkGrey200 : WebTheme.grey100),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
addIcon,
|
||||
size: 16,
|
||||
color: dropdown.enabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
addLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dropdown.enabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
243
AINoval/lib/widgets/common/custom_tab_bar.dart
Normal file
243
AINoval/lib/widgets/common/custom_tab_bar.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/preset_dropdown_button.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
|
||||
/// 选项卡项目数据
|
||||
class TabItem {
|
||||
/// 构造函数
|
||||
const TabItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
/// 标识符
|
||||
final String id;
|
||||
|
||||
/// 显示文字
|
||||
final String label;
|
||||
|
||||
/// 图标
|
||||
final IconData? icon;
|
||||
}
|
||||
|
||||
/// 自定义选项卡栏组件
|
||||
/// 支持图标、文字和预设按钮
|
||||
class CustomTabBar extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const CustomTabBar({
|
||||
super.key,
|
||||
required this.tabs,
|
||||
required this.selectedTabId,
|
||||
required this.onTabChanged,
|
||||
this.showPresets = false,
|
||||
this.onPresetsPressed,
|
||||
this.presetsLabel = '预设',
|
||||
this.usePresetDropdown = false,
|
||||
this.presetFeatureType,
|
||||
this.currentPreset,
|
||||
this.onPresetSelected,
|
||||
this.onCreatePreset,
|
||||
this.onManagePresets,
|
||||
this.novelId,
|
||||
});
|
||||
|
||||
/// 选项卡列表
|
||||
final List<TabItem> tabs;
|
||||
|
||||
/// 当前选中的选项卡ID
|
||||
final String selectedTabId;
|
||||
|
||||
/// 选项卡改变回调
|
||||
final ValueChanged<String> onTabChanged;
|
||||
|
||||
/// 是否显示预设按钮
|
||||
final bool showPresets;
|
||||
|
||||
/// 预设按钮点击回调
|
||||
final VoidCallback? onPresetsPressed;
|
||||
|
||||
/// 预设按钮文字
|
||||
final String presetsLabel;
|
||||
|
||||
/// 是否使用新的预设下拉框
|
||||
final bool usePresetDropdown;
|
||||
|
||||
/// 预设功能类型(用于过滤预设)
|
||||
final String? presetFeatureType;
|
||||
|
||||
/// 当前选中的预设
|
||||
final AIPromptPreset? currentPreset;
|
||||
|
||||
/// 预设选择回调
|
||||
final ValueChanged<AIPromptPreset>? onPresetSelected;
|
||||
|
||||
/// 创建预设回调
|
||||
final VoidCallback? onCreatePreset;
|
||||
|
||||
/// 管理预设回调
|
||||
final VoidCallback? onManagePresets;
|
||||
|
||||
/// 小说ID(用于过滤预设)
|
||||
final String? novelId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选项卡列表
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: tabs.map((tab) => _buildTab(context, tab, isDark)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 预设按钮
|
||||
if (showPresets) ...[
|
||||
const SizedBox(width: 8),
|
||||
usePresetDropdown ? _buildPresetDropdown() : _buildPresetsButton(context, isDark),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个选项卡
|
||||
Widget _buildTab(BuildContext context, TabItem tab, bool isDark) {
|
||||
final isSelected = tab.id == selectedTabId;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () => onTabChanged(tab.id),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 选项卡内容
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey300.withValues(alpha: 0.2) : WebTheme.grey100)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (tab.icon != null) ...[
|
||||
Icon(
|
||||
tab.icon,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey700)
|
||||
: (isDark ? WebTheme.darkGrey500 : WebTheme.grey500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
tab.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey700)
|
||||
: (isDark ? WebTheme.darkGrey500 : WebTheme.grey500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 底部指示线
|
||||
Container(
|
||||
height: 2,
|
||||
width: 40,
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey700)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设下拉框
|
||||
Widget _buildPresetDropdown() {
|
||||
return PresetDropdownButton(
|
||||
featureType: presetFeatureType ?? '',
|
||||
currentPreset: currentPreset,
|
||||
onPresetSelected: onPresetSelected,
|
||||
onCreatePreset: onCreatePreset,
|
||||
onManagePresets: onManagePresets,
|
||||
novelId: novelId,
|
||||
label: presetsLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设按钮
|
||||
Widget _buildPresetsButton(BuildContext context, bool isDark) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPresetsPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tune,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
presetsLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
AINoval/lib/widgets/common/custom_text_editor.dart
Normal file
201
AINoval/lib/widgets/common/custom_text_editor.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 文本编辑器操作按钮类型
|
||||
enum EditorAction {
|
||||
expand, // 展开
|
||||
copy, // 复制
|
||||
}
|
||||
|
||||
/// 自定义文本编辑器组件
|
||||
/// 支持多行文本编辑、占位符和操作按钮
|
||||
class CustomTextEditor extends StatefulWidget {
|
||||
/// 构造函数
|
||||
const CustomTextEditor({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.placeholder = '请输入内容...',
|
||||
this.minLines = 3,
|
||||
this.maxLines = 10,
|
||||
this.showActions = true,
|
||||
this.actions = const [EditorAction.expand, EditorAction.copy],
|
||||
this.onExpand,
|
||||
this.onCopy,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
/// 文本控制器
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// 占位符文字
|
||||
final String placeholder;
|
||||
|
||||
/// 最小行数
|
||||
final int minLines;
|
||||
|
||||
/// 最大行数
|
||||
final int maxLines;
|
||||
|
||||
/// 是否显示操作按钮
|
||||
final bool showActions;
|
||||
|
||||
/// 操作按钮列表
|
||||
final List<EditorAction> actions;
|
||||
|
||||
/// 展开回调
|
||||
final VoidCallback? onExpand;
|
||||
|
||||
/// 复制回调
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
|
||||
/// 是否只读
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
State<CustomTextEditor> createState() => _CustomTextEditorState();
|
||||
}
|
||||
|
||||
class _CustomTextEditorState extends State<CustomTextEditor> {
|
||||
late TextEditingController _controller;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ?? TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) {
|
||||
_controller.dispose();
|
||||
}
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 文本输入区域
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: widget.minLines * 24.0,
|
||||
maxHeight: widget.maxLines * 24.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
maxLines: null,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: _controller.text.isEmpty ? widget.placeholder : null,
|
||||
hintStyle: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 操作按钮区域
|
||||
if (widget.showActions && widget.actions.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: widget.actions.map((action) => _buildActionButton(context, action, isDark)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButton(BuildContext context, EditorAction action, bool isDark) {
|
||||
IconData icon;
|
||||
String label;
|
||||
VoidCallback? onPressed;
|
||||
bool enabled = widget.enabled && !widget.readOnly;
|
||||
|
||||
switch (action) {
|
||||
case EditorAction.expand:
|
||||
icon = Icons.open_in_full;
|
||||
label = '展开';
|
||||
onPressed = enabled ? widget.onExpand : null;
|
||||
break;
|
||||
case EditorAction.copy:
|
||||
icon = Icons.content_copy;
|
||||
label = '复制';
|
||||
onPressed = enabled ? widget.onCopy : null;
|
||||
// 复制功能在有内容时才启用
|
||||
enabled = enabled && _controller.text.isNotEmpty;
|
||||
break;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
AINoval/lib/widgets/common/dialog_container.dart
Normal file
74
AINoval/lib/widgets/common/dialog_container.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 对话框容器组件
|
||||
/// 提供统一的对话框样式和布局
|
||||
class DialogContainer extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const DialogContainer({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.maxWidth = 768, // 3xl in Tailwind
|
||||
this.height,
|
||||
this.padding = const EdgeInsets.all(0),
|
||||
});
|
||||
|
||||
/// 子组件
|
||||
final Widget child;
|
||||
|
||||
/// 最大宽度
|
||||
final double maxWidth;
|
||||
|
||||
/// 高度(可选)
|
||||
final double? height;
|
||||
|
||||
/// 内边距
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final isSmallScreen = screenSize.width < 640; // sm breakpoint
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
horizontal: isSmallScreen ? 8 : 32,
|
||||
vertical: isSmallScreen ? 0 : 64,
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
maxHeight: height ?? (isSmallScreen ? screenSize.height * 0.95 : screenSize.height * 0.8),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(isSmallScreen ? 12 : 8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.25),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isSmallScreen ? 12 : 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
80
AINoval/lib/widgets/common/dialog_header.dart
Normal file
80
AINoval/lib/widgets/common/dialog_header.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 对话框标题栏组件
|
||||
/// 包含标题文字和关闭按钮
|
||||
class DialogHeader extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const DialogHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.onClose,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 24, 24, 6),
|
||||
});
|
||||
|
||||
/// 标题文字
|
||||
final String title;
|
||||
|
||||
/// 关闭回调
|
||||
final VoidCallback? onClose;
|
||||
|
||||
/// 内边距
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 关闭按钮
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onClose ?? () => Navigator.of(context).pop(),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'关闭',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
AINoval/lib/widgets/common/dropdown_menu_widget.dart
Normal file
184
AINoval/lib/widgets/common/dropdown_menu_widget.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
class MenuItemData {
|
||||
final String value;
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
|
||||
MenuItemData({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.color,
|
||||
});
|
||||
}
|
||||
|
||||
class DropdownMenuWidget extends StatefulWidget {
|
||||
final Widget trigger;
|
||||
final List<MenuItemData> items;
|
||||
final Function(String)? onItemSelected;
|
||||
final Offset offset;
|
||||
final double? width;
|
||||
|
||||
const DropdownMenuWidget({
|
||||
Key? key,
|
||||
required this.trigger,
|
||||
required this.items,
|
||||
this.onItemSelected,
|
||||
this.offset = const Offset(0, 8),
|
||||
this.width,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DropdownMenuWidget> createState() => _DropdownMenuWidgetState();
|
||||
}
|
||||
|
||||
class _DropdownMenuWidgetState extends State<DropdownMenuWidget> {
|
||||
final GlobalKey _triggerKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
bool _isOpen = false;
|
||||
|
||||
void _toggleDropdown() {
|
||||
if (_isOpen) {
|
||||
_closeDropdown();
|
||||
} else {
|
||||
_openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
void _openDropdown() {
|
||||
final RenderBox renderBox = _triggerKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final position = renderBox.localToGlobal(Offset.zero);
|
||||
final size = renderBox.size;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
// Invisible barrier to detect outside clicks
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _closeDropdown,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Dropdown menu
|
||||
Positioned(
|
||||
left: position.dx + widget.offset.dx,
|
||||
top: position.dy + size.height + widget.offset.dy,
|
||||
width: widget.width ?? size.width,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildDropdownContent(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
// 检查 Widget 是否还处于活跃状态
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isOpen = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _closeDropdown() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
// 检查 Widget 是否还处于活跃状态,避免在 dispose 后调用 setState
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isOpen = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDropdownContent(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.15),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: widget.items.map((item) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
widget.onItemSelected?.call(item.value);
|
||||
_closeDropdown();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (item.icon != null) ...[
|
||||
Icon(
|
||||
item.icon,
|
||||
size: 16,
|
||||
color: item.color ?? WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: item.color ?? WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: _triggerKey,
|
||||
onTap: _toggleDropdown,
|
||||
child: widget.trigger,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 直接清理 overlay,不调用 setState
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
464
AINoval/lib/widgets/common/dynamic_form_field_widget.dart
Normal file
464
AINoval/lib/widgets/common/dynamic_form_field_widget.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/ai_feature_form_config.dart';
|
||||
import 'package:ainoval/models/context_selection_models.dart';
|
||||
import 'package:ainoval/widgets/common/index.dart';
|
||||
import 'package:ainoval/widgets/common/multi_select_instructions_with_presets.dart' as multi_select;
|
||||
// import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 动态表单字段组件
|
||||
/// 根据FormFieldConfig配置动态渲染对应的表单字段
|
||||
class DynamicFormFieldWidget extends StatelessWidget {
|
||||
/// 字段配置
|
||||
final FormFieldConfig config;
|
||||
|
||||
/// 当前值映射表
|
||||
final Map<AIFormFieldType, dynamic> values;
|
||||
|
||||
/// 值变更回调
|
||||
final Function(AIFormFieldType type, dynamic value) onValueChanged;
|
||||
|
||||
/// 重置回调
|
||||
final Function(AIFormFieldType type) onReset;
|
||||
|
||||
/// 上下文选择数据(仅用于上下文选择字段)
|
||||
final ContextSelectionData? contextSelectionData;
|
||||
|
||||
/// 控制器映射表(用于文本输入字段)
|
||||
final Map<AIFormFieldType, TextEditingController>? controllers;
|
||||
|
||||
/// AI功能类型(用于提示词模板选择)
|
||||
final String? aiFeatureType;
|
||||
|
||||
/// 当前编辑的预设是否为系统预设(用于模板过滤)
|
||||
final bool? isSystemPreset;
|
||||
|
||||
/// 当前编辑的预设是否为公共预设(用于模板过滤)
|
||||
final bool? isPublicPreset;
|
||||
|
||||
const DynamicFormFieldWidget({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.values,
|
||||
required this.onValueChanged,
|
||||
required this.onReset,
|
||||
this.contextSelectionData,
|
||||
this.controllers,
|
||||
this.aiFeatureType,
|
||||
this.isSystemPreset,
|
||||
this.isPublicPreset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (config.type) {
|
||||
case AIFormFieldType.instructions:
|
||||
return _buildInstructionsField(context);
|
||||
case AIFormFieldType.length:
|
||||
return _buildLengthField(context);
|
||||
case AIFormFieldType.style:
|
||||
return _buildStyleField(context);
|
||||
case AIFormFieldType.contextSelection:
|
||||
return _buildContextSelectionField(context);
|
||||
case AIFormFieldType.smartContext:
|
||||
return _buildSmartContextField(context);
|
||||
case AIFormFieldType.promptTemplate:
|
||||
return _buildPromptTemplateField(context);
|
||||
case AIFormFieldType.temperature:
|
||||
return _buildTemperatureField(context);
|
||||
case AIFormFieldType.topP:
|
||||
return _buildTopPField(context);
|
||||
case AIFormFieldType.memoryCutoff:
|
||||
return _buildMemoryCutoffField(context);
|
||||
case AIFormFieldType.quickAccess:
|
||||
return _buildQuickAccessField(context);
|
||||
// 不需要 default:枚举已覆盖所有分支
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建指令字段
|
||||
Widget _buildInstructionsField(BuildContext context) {
|
||||
final controller = controllers?[config.type] ?? TextEditingController();
|
||||
final presets = _parseInstructionPresets(config.options?['presets']);
|
||||
|
||||
if (presets.isNotEmpty) {
|
||||
// 如果有预设,使用多选指令组件
|
||||
return FormFieldFactory.createMultiSelectInstructionsWithPresetsField(
|
||||
controller: controller,
|
||||
presets: presets,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
placeholder: config.options?['placeholder'] ?? 'e.g. 输入指令...',
|
||||
dropdownPlaceholder: '选择指令预设',
|
||||
onReset: () => onReset(config.type),
|
||||
onExpand: () => _handleExpandInstructions(),
|
||||
onCopy: () => _handleCopyInstructions(),
|
||||
onSelectionChanged: (selectedPresets) => _handlePresetSelectionChanged(selectedPresets),
|
||||
);
|
||||
} else {
|
||||
// 如果没有预设,使用简单的指令字段
|
||||
return FormFieldFactory.createInstructionsField(
|
||||
controller: controller,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
placeholder: config.options?['placeholder'] ?? 'e.g. 输入指令...',
|
||||
onReset: () => onReset(config.type),
|
||||
onExpand: () => _handleExpandInstructions(),
|
||||
onCopy: () => _handleCopyInstructions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建长度字段
|
||||
Widget _buildLengthField(BuildContext context) {
|
||||
final radioOptions = _parseRadioOptions(config.options?['radioOptions']);
|
||||
final placeholder = config.options?['placeholder'] ?? 'e.g. 输入长度...';
|
||||
final controller = controllers?[config.type] ?? TextEditingController();
|
||||
|
||||
return FormFieldFactory.createLengthField<String>(
|
||||
options: radioOptions,
|
||||
value: values[config.type] as String?,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
isRequired: config.isRequired,
|
||||
onReset: () => onReset(config.type),
|
||||
alternativeInput: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 40),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
filled: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
onValueChanged(config.type, null); // 清除单选按钮选择
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建重构方式字段
|
||||
Widget _buildStyleField(BuildContext context) {
|
||||
final radioOptions = _parseRadioOptions(config.options?['radioOptions']);
|
||||
final placeholder = config.options?['placeholder'] ?? 'e.g. 输入样式...';
|
||||
final controller = controllers?[config.type] ?? TextEditingController();
|
||||
|
||||
return FormFieldFactory.createLengthField<String>(
|
||||
options: radioOptions,
|
||||
value: values[config.type] as String?,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
onReset: () => onReset(config.type),
|
||||
alternativeInput: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 40),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
filled: true,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
onValueChanged(config.type, null); // 清除单选按钮选择
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建上下文选择字段
|
||||
Widget _buildContextSelectionField(BuildContext context) {
|
||||
if (contextSelectionData == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_outlined,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'上下文选择数据未提供',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FormFieldFactory.createContextSelectionField(
|
||||
contextData: contextSelectionData!,
|
||||
onSelectionChanged: (newData) => onValueChanged(config.type, newData),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
onReset: () => onReset(config.type),
|
||||
dropdownWidth: 400,
|
||||
initialChapterId: null,
|
||||
initialSceneId: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建智能上下文字段
|
||||
Widget _buildSmartContextField(BuildContext context) {
|
||||
return SmartContextToggle(
|
||||
value: values[config.type] as bool? ?? true,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建提示词模板字段
|
||||
Widget _buildPromptTemplateField(BuildContext context) {
|
||||
// 获取AI功能类型,如果没有提供则默认为TEXT_EXPANSION
|
||||
final featureType = aiFeatureType ?? 'TEXT_EXPANSION';
|
||||
|
||||
// 根据预设类型确定允许的模板类型
|
||||
// 系统预设:允许 系统默认 + 私有;禁止 公共
|
||||
// 公共预设:允许 系统默认 + 公共(仅已验证);禁止 私有
|
||||
// 用户预设:允许全部(系统默认 + 私有 + 公共)
|
||||
Set<PromptTemplateType>? allowedTypes;
|
||||
bool onlyVerifiedPublic = false;
|
||||
if (isSystemPreset == true) {
|
||||
allowedTypes = {PromptTemplateType.system, PromptTemplateType.private};
|
||||
onlyVerifiedPublic = false;
|
||||
} else if (isPublicPreset == true) {
|
||||
allowedTypes = {PromptTemplateType.system, PromptTemplateType.public};
|
||||
onlyVerifiedPublic = true;
|
||||
} else {
|
||||
allowedTypes = {PromptTemplateType.system, PromptTemplateType.private, PromptTemplateType.public};
|
||||
onlyVerifiedPublic = false;
|
||||
}
|
||||
|
||||
return FormFieldFactory.createPromptTemplateSelectionField(
|
||||
selectedTemplateId: values[config.type] as String?,
|
||||
onTemplateSelected: (templateId) => onValueChanged(config.type, templateId),
|
||||
aiFeatureType: featureType,
|
||||
allowedTypes: allowedTypes,
|
||||
onlyVerifiedPublic: onlyVerifiedPublic,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
onReset: () => onReset(config.type),
|
||||
onTemporaryPromptsSaved: (sys, user) {
|
||||
// 将临时提示词放入 values 的扩展槽位(若业务侧读取,需要自定义键)
|
||||
onValueChanged(config.type, values[config.type]);
|
||||
// 通过额外键把自定义提示词也放入values,供表单容器在提交时拼接到请求parameters
|
||||
onValueChanged(AIFormFieldType.promptTemplate, values[config.type]);
|
||||
values[AIFormFieldType.promptTemplate] = values[config.type];
|
||||
values[AIFormFieldType.instructions] = values[AIFormFieldType.instructions];
|
||||
// 不在此层直接发送请求,仅存储由上层容器读取
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建温度字段
|
||||
Widget _buildTemperatureField(BuildContext context) {
|
||||
return FormFieldFactory.createTemperatureSliderField(
|
||||
context: context,
|
||||
value: values[config.type] as double? ?? 0.7,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
onReset: () => onReset(config.type),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建Top-P字段
|
||||
Widget _buildTopPField(BuildContext context) {
|
||||
return FormFieldFactory.createTopPSliderField(
|
||||
context: context,
|
||||
value: values[config.type] as double? ?? 0.9,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
onReset: () => onReset(config.type),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建记忆截断字段
|
||||
Widget _buildMemoryCutoffField(BuildContext context) {
|
||||
final radioOptions = _parseRadioIntOptions(config.options?['radioOptions']);
|
||||
final placeholder = config.options?['placeholder'] ?? 'e.g. 24';
|
||||
final controller = controllers?[config.type] ?? TextEditingController();
|
||||
|
||||
return FormFieldFactory.createMemoryCutoffField(
|
||||
options: radioOptions,
|
||||
value: values[config.type] as int?,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
customInput: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
fillColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
filled: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
final intValue = int.tryParse(value);
|
||||
if (intValue != null) {
|
||||
onValueChanged(config.type, null); // 清除单选按钮选择
|
||||
}
|
||||
},
|
||||
),
|
||||
onReset: () => onReset(config.type),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建快捷访问字段
|
||||
Widget _buildQuickAccessField(BuildContext context) {
|
||||
return FormFieldFactory.createQuickAccessToggleField(
|
||||
value: values[config.type] as bool? ?? false,
|
||||
onChanged: (value) => onValueChanged(config.type, value),
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
onReset: () => onReset(config.type),
|
||||
);
|
||||
}
|
||||
|
||||
// 已移除未使用的不支持字段提示构建函数
|
||||
|
||||
// 工具方法
|
||||
|
||||
/// 解析指令预设
|
||||
List<multi_select.InstructionPreset> _parseInstructionPresets(dynamic presets) {
|
||||
if (presets is! List) return [];
|
||||
|
||||
return presets.map<multi_select.InstructionPreset>((preset) {
|
||||
if (preset is Map<String, dynamic>) {
|
||||
return multi_select.InstructionPreset(
|
||||
id: preset['id'] as String? ?? '',
|
||||
title: preset['title'] as String? ?? '',
|
||||
content: preset['content'] as String? ?? '',
|
||||
description: preset['description'] as String?,
|
||||
);
|
||||
}
|
||||
return const multi_select.InstructionPreset(
|
||||
id: '',
|
||||
title: '',
|
||||
content: '',
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 解析单选按钮选项(字符串值)
|
||||
List<RadioOption<String>> _parseRadioOptions(dynamic options) {
|
||||
if (options is! List) return [];
|
||||
|
||||
return options.map<RadioOption<String>>((option) {
|
||||
if (option is Map<String, dynamic>) {
|
||||
return RadioOption<String>(
|
||||
value: option['value'] as String? ?? '',
|
||||
label: option['label'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
return const RadioOption<String>(value: '', label: '');
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 解析单选按钮选项(整数值)
|
||||
List<RadioOption<int>> _parseRadioIntOptions(dynamic options) {
|
||||
if (options is! List) return [];
|
||||
|
||||
return options.map<RadioOption<int>>((option) {
|
||||
if (option is Map<String, dynamic>) {
|
||||
return RadioOption<int>(
|
||||
value: option['value'] as int? ?? 0,
|
||||
label: option['label'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
return const RadioOption<int>(value: 0, label: '');
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 事件处理器
|
||||
|
||||
void _handleExpandInstructions() {
|
||||
debugPrint('展开指令编辑器');
|
||||
}
|
||||
|
||||
void _handleCopyInstructions() {
|
||||
debugPrint('复制指令内容');
|
||||
}
|
||||
|
||||
void _handlePresetSelectionChanged(List<multi_select.InstructionPreset> selectedPresets) {
|
||||
debugPrint('选中的预设已改变: ${selectedPresets.map((p) => p.title).join(', ')}');
|
||||
}
|
||||
}
|
||||
74
AINoval/lib/widgets/common/empty_state_placeholder.dart
Normal file
74
AINoval/lib/widgets/common/empty_state_placeholder.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 空状态占位符
|
||||
class EmptyStatePlaceholder extends StatelessWidget {
|
||||
/// 图标
|
||||
final IconData icon;
|
||||
|
||||
/// 标题
|
||||
final String title;
|
||||
|
||||
/// 消息
|
||||
final String message;
|
||||
|
||||
/// 操作按钮
|
||||
final Widget? action;
|
||||
|
||||
const EmptyStatePlaceholder({
|
||||
Key? key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.action,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context), // 🚀 修复:使用动态表面色
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: WebTheme.isDarkMode(context) ? Colors.black.withAlpha(50) : Colors.grey.withAlpha(25),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 64,
|
||||
color: WebTheme.getSecondaryTextColor(context), // 🚀 修复:使用动态颜色
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context), // 🚀 修复:使用动态文本色
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context), // 🚀 修复:使用动态次要文本色
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
AINoval/lib/widgets/common/error_view.dart
Normal file
57
AINoval/lib/widgets/common/error_view.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用错误显示组件
|
||||
class ErrorView extends StatelessWidget {
|
||||
final String error;
|
||||
final VoidCallback? onRetry;
|
||||
final String? retryText;
|
||||
|
||||
const ErrorView({
|
||||
Key? key,
|
||||
required this.error,
|
||||
this.onRetry,
|
||||
this.retryText,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'出现错误',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(retryText ?? '重试'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
491
AINoval/lib/widgets/common/floating_card.dart
Normal file
491
AINoval/lib/widgets/common/floating_card.dart
Normal file
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 浮动卡片配置
|
||||
class FloatingCardConfig {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double? minWidth;
|
||||
final double? maxWidth;
|
||||
final double? minHeight;
|
||||
final double? maxHeight;
|
||||
final EdgeInsets? margin;
|
||||
final EdgeInsets? padding;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? backgroundColor;
|
||||
final List<BoxShadow>? shadows;
|
||||
final Border? border;
|
||||
final Duration animationDuration;
|
||||
final Curve animationCurve;
|
||||
final bool showCloseButton;
|
||||
final bool closeOnBackgroundTap;
|
||||
final bool enableBackgroundTap;
|
||||
final bool showFloatingCloseButton;
|
||||
|
||||
const FloatingCardConfig({
|
||||
this.width,
|
||||
this.height,
|
||||
this.minWidth = 300.0,
|
||||
this.maxWidth = 800.0,
|
||||
this.minHeight = 200.0,
|
||||
this.maxHeight = 600.0,
|
||||
this.margin,
|
||||
this.padding,
|
||||
this.borderRadius,
|
||||
this.backgroundColor,
|
||||
this.shadows,
|
||||
this.border,
|
||||
this.animationDuration = const Duration(milliseconds: 300),
|
||||
this.animationCurve = Curves.easeOutCubic,
|
||||
this.showCloseButton = true,
|
||||
this.closeOnBackgroundTap = false,
|
||||
this.enableBackgroundTap = true,
|
||||
this.showFloatingCloseButton = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// 浮动卡片位置配置
|
||||
class FloatingCardPosition {
|
||||
final double? left;
|
||||
final double? top;
|
||||
final double? right;
|
||||
final double? bottom;
|
||||
final Alignment? alignment;
|
||||
final double? offsetFromSidebar;
|
||||
|
||||
const FloatingCardPosition({
|
||||
this.left,
|
||||
this.top,
|
||||
this.right,
|
||||
this.bottom,
|
||||
this.alignment,
|
||||
this.offsetFromSidebar,
|
||||
});
|
||||
|
||||
/// 默认居中位置
|
||||
static const center = FloatingCardPosition(alignment: Alignment.center);
|
||||
|
||||
/// 从侧边栏偏移的位置
|
||||
static FloatingCardPosition fromSidebar({
|
||||
required double sidebarWidth,
|
||||
double offset = 16.0,
|
||||
double top = 80.0,
|
||||
}) {
|
||||
return FloatingCardPosition(
|
||||
left: sidebarWidth + offset,
|
||||
top: top,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用浮动卡片管理器
|
||||
class FloatingCard {
|
||||
static OverlayEntry? _overlayEntry;
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示浮动卡片
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
FloatingCardConfig config = const FloatingCardConfig(),
|
||||
FloatingCardPosition position = FloatingCardPosition.center,
|
||||
VoidCallback? onClose,
|
||||
String? title,
|
||||
List<Widget>? actions,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
_overlayEntry = _createOverlayEntry(
|
||||
context: context,
|
||||
child: child,
|
||||
config: config,
|
||||
position: position,
|
||||
onClose: onClose,
|
||||
title: title,
|
||||
actions: actions,
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
_isShowing = true;
|
||||
}
|
||||
|
||||
/// 隐藏浮动卡片
|
||||
static void hide() {
|
||||
if (_overlayEntry != null) {
|
||||
_overlayEntry!.remove();
|
||||
_overlayEntry = null;
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
|
||||
/// 创建 Overlay 条目
|
||||
static OverlayEntry _createOverlayEntry({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
required FloatingCardConfig config,
|
||||
required FloatingCardPosition position,
|
||||
VoidCallback? onClose,
|
||||
String? title,
|
||||
List<Widget>? actions,
|
||||
}) {
|
||||
return OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
// 背景遮罩
|
||||
if (config.enableBackgroundTap)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: config.closeOnBackgroundTap ? (onClose ?? hide) : null,
|
||||
child: Container(
|
||||
color: config.closeOnBackgroundTap
|
||||
? Colors.black.withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: true,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
|
||||
// 浮动卡片
|
||||
_FloatingCardWidget(
|
||||
child: child,
|
||||
config: config,
|
||||
position: position,
|
||||
onClose: onClose ?? hide,
|
||||
title: title,
|
||||
actions: actions,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 浮动卡片组件
|
||||
class _FloatingCardWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final FloatingCardConfig config;
|
||||
final FloatingCardPosition position;
|
||||
final VoidCallback onClose;
|
||||
final String? title;
|
||||
final List<Widget>? actions;
|
||||
|
||||
const _FloatingCardWidget({
|
||||
required this.child,
|
||||
required this.config,
|
||||
required this.position,
|
||||
required this.onClose,
|
||||
this.title,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FloatingCardWidget> createState() => _FloatingCardWidgetState();
|
||||
}
|
||||
|
||||
class _FloatingCardWidgetState extends State<_FloatingCardWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: widget.config.animationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<double>(
|
||||
begin: 400.0, // 改为和原来相同的滑入距离
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: widget.config.animationCurve,
|
||||
));
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack, // 保持和原来相同的动画曲线
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleClose() {
|
||||
_animationController.reverse().then((_) {
|
||||
widget.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) => _buildPositionedCard(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionedCard() {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
// 计算位置
|
||||
double? left = widget.position.left;
|
||||
double? top = widget.position.top;
|
||||
double? right = widget.position.right;
|
||||
double? bottom = widget.position.bottom;
|
||||
|
||||
if (widget.position.alignment != null) {
|
||||
final alignment = widget.position.alignment!;
|
||||
final cardWidth = _calculateCardWidth(screenSize);
|
||||
final cardHeight = _calculateCardHeight(screenSize);
|
||||
|
||||
switch (alignment) {
|
||||
case Alignment.center:
|
||||
left = (screenSize.width - cardWidth) / 2;
|
||||
top = (screenSize.height - cardHeight) / 2;
|
||||
break;
|
||||
case Alignment.topCenter:
|
||||
left = (screenSize.width - cardWidth) / 2;
|
||||
top = 50;
|
||||
break;
|
||||
case Alignment.bottomCenter:
|
||||
left = (screenSize.width - cardWidth) / 2;
|
||||
bottom = 50;
|
||||
break;
|
||||
// 可以添加更多对齐方式
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 主卡片
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
child: Transform.translate(
|
||||
offset: Offset(_slideAnimation.value, 0),
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: _buildCard(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 浮动关闭按钮
|
||||
if (widget.config.showFloatingCloseButton)
|
||||
Positioned(
|
||||
left: (left ?? 0) - 12,
|
||||
top: (top ?? 0) - 12,
|
||||
child: Transform.translate(
|
||||
offset: Offset(_slideAnimation.value, 0),
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: _buildFloatingCloseButton(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateCardWidth(Size screenSize) {
|
||||
if (widget.config.width != null) return widget.config.width!;
|
||||
|
||||
double width = screenSize.width * 0.4; // 默认40%屏幕宽度
|
||||
|
||||
if (widget.config.minWidth != null) {
|
||||
width = width.clamp(widget.config.minWidth!, double.infinity);
|
||||
}
|
||||
if (widget.config.maxWidth != null) {
|
||||
width = width.clamp(0, widget.config.maxWidth!);
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
double _calculateCardHeight(Size screenSize) {
|
||||
if (widget.config.height != null) return widget.config.height!;
|
||||
|
||||
double height = screenSize.height * 0.6; // 默认60%屏幕高度
|
||||
|
||||
if (widget.config.minHeight != null) {
|
||||
height = height.clamp(widget.config.minHeight!, double.infinity);
|
||||
}
|
||||
if (widget.config.maxHeight != null) {
|
||||
height = height.clamp(0, widget.config.maxHeight!);
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
Widget _buildCard(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
final cardWidth = _calculateCardWidth(screenSize);
|
||||
final cardHeight = _calculateCardHeight(screenSize);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: GestureDetector(
|
||||
onTap: () {}, // 阻止点击穿透
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
margin: widget.config.margin,
|
||||
padding: widget.config.padding,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.config.backgroundColor ??
|
||||
(isDark ? WebTheme.darkGrey100 : WebTheme.getBackgroundColor(context)),
|
||||
borderRadius: widget.config.borderRadius ??
|
||||
BorderRadius.circular(12),
|
||||
border: widget.config.border ??
|
||||
Border.all(
|
||||
color: isDark
|
||||
? WebTheme.darkGrey800
|
||||
: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: widget.config.shadows ?? [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.2),
|
||||
offset: const Offset(0, 8),
|
||||
blurRadius: 32,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 头部(如果有标题或动作)
|
||||
if (widget.title != null ||
|
||||
widget.actions != null ||
|
||||
(widget.config.showCloseButton && !widget.config.showFloatingCloseButton))
|
||||
_buildHeader(isDark),
|
||||
|
||||
// 内容区域
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 标题
|
||||
if (widget.title != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? WebTheme.grey100 : WebTheme.grey900,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 自定义操作按钮
|
||||
if (widget.actions != null) ...[
|
||||
...widget.actions!,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 关闭按钮(仅在不显示浮动关闭按钮时显示)
|
||||
if (widget.config.showCloseButton && !widget.config.showFloatingCloseButton)
|
||||
IconButton(
|
||||
onPressed: _handleClose,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: isDark ? WebTheme.grey400 : WebTheme.grey600,
|
||||
),
|
||||
style: IconButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(32, 32),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建浮动关闭按钮
|
||||
Widget _buildFloatingCloseButton() {
|
||||
return Material(
|
||||
elevation: 8,
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.black87,
|
||||
child: InkWell(
|
||||
onTap: _handleClose,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black87,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1324
AINoval/lib/widgets/common/form_dialog_template.dart
Normal file
1324
AINoval/lib/widgets/common/form_dialog_template.dart
Normal file
File diff suppressed because it is too large
Load Diff
153
AINoval/lib/widgets/common/form_fieldset.dart
Normal file
153
AINoval/lib/widgets/common/form_fieldset.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
import 'required_badge.dart';
|
||||
|
||||
/// 表单字段集组件
|
||||
/// 提供统一的表单字段布局,包含标题、描述和重置功能
|
||||
class FormFieldset extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const FormFieldset({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.description,
|
||||
this.showReset = false,
|
||||
this.onReset,
|
||||
this.resetEnabled = true,
|
||||
this.showRequired = false,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 16),
|
||||
});
|
||||
|
||||
/// 字段标题
|
||||
final String title;
|
||||
|
||||
/// 字段描述(可选)
|
||||
final String? description;
|
||||
|
||||
/// 子组件
|
||||
final Widget child;
|
||||
|
||||
/// 是否显示重置按钮
|
||||
final bool showReset;
|
||||
|
||||
/// 重置回调
|
||||
final VoidCallback? onReset;
|
||||
|
||||
/// 重置按钮是否可用
|
||||
final bool resetEnabled;
|
||||
|
||||
/// 是否显示必填标识
|
||||
final bool showRequired;
|
||||
|
||||
/// 内边距
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey800,
|
||||
),
|
||||
),
|
||||
|
||||
// 必填标识
|
||||
if (showRequired) ...[
|
||||
const SizedBox(width: 8),
|
||||
const RequiredBadge(),
|
||||
],
|
||||
|
||||
// 占据剩余空间
|
||||
const Spacer(),
|
||||
|
||||
// 重置按钮
|
||||
if (showReset)
|
||||
_buildResetButton(context, isDark),
|
||||
],
|
||||
),
|
||||
|
||||
// 描述文字
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// 内容区域
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建重置按钮
|
||||
Widget _buildResetButton(BuildContext context, bool isDark) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: resetEnabled ? onReset : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: resetEnabled
|
||||
? (isDark ? WebTheme.darkGrey700 : WebTheme.grey700)
|
||||
: (isDark ? WebTheme.darkGrey600 : WebTheme.grey600),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: resetEnabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey800)
|
||||
: (isDark ? WebTheme.darkGrey600 : WebTheme.grey600),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.refresh,
|
||||
size: 12,
|
||||
color: resetEnabled
|
||||
? (isDark ? WebTheme.darkGrey100 : WebTheme.grey50)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'重置',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: resetEnabled
|
||||
? (isDark ? WebTheme.darkGrey100 : WebTheme.grey50)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
AINoval/lib/widgets/common/icp_record_footer.dart
Normal file
138
AINoval/lib/widgets/common/icp_record_footer.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// ICP备案信息组件
|
||||
/// 包含备案号、工信部链接和图标
|
||||
class ICPRecordFooter extends StatelessWidget {
|
||||
final String icpNumber;
|
||||
final String recordUrl;
|
||||
final EdgeInsets? padding;
|
||||
final bool showIcon;
|
||||
|
||||
const ICPRecordFooter({
|
||||
Key? key,
|
||||
this.icpNumber = '沪ICP备2025140539号-1',
|
||||
this.recordUrl = 'https://beian.miit.gov.cn/#/',
|
||||
this.padding,
|
||||
this.showIcon = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding ?? const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.getBorderColor(context).withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: InkWell(
|
||||
onTap: () => _launchICPUrl(),
|
||||
hoverColor: WebTheme.getPrimaryColor(context).withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showIcon) ...[
|
||||
// 工信部图标 - 使用简化的政府图标
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.account_balance,
|
||||
size: 12,
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
icpNumber,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开工信部备案查询网站
|
||||
Future<void> _launchICPUrl() async {
|
||||
try {
|
||||
final uri = Uri.parse(recordUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默处理错误,避免在生产环境中显示错误信息
|
||||
print('无法打开ICP备案查询网站: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化版ICP备案信息组件,仅显示文本
|
||||
class ICPRecordText extends StatelessWidget {
|
||||
final String icpNumber;
|
||||
final String recordUrl;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
const ICPRecordText({
|
||||
Key? key,
|
||||
this.icpNumber = '沪ICP备2025140539号-1',
|
||||
this.recordUrl = 'https://beian.miit.gov.cn/#/',
|
||||
this.textStyle,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => _launchICPUrl(),
|
||||
child: Text(
|
||||
icpNumber,
|
||||
style: textStyle ??
|
||||
TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 打开工信部备案查询网站
|
||||
Future<void> _launchICPUrl() async {
|
||||
try {
|
||||
final uri = Uri.parse(recordUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('无法打开ICP备案查询网站: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
30
AINoval/lib/widgets/common/index.dart
Normal file
30
AINoval/lib/widgets/common/index.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
// 公共组件导出文件
|
||||
// 统一导出所有表单相关的公共组件
|
||||
|
||||
// 对话框组件
|
||||
export 'dialog_container.dart';
|
||||
export 'dialog_header.dart';
|
||||
|
||||
// 选项卡组件
|
||||
export 'custom_tab_bar.dart';
|
||||
|
||||
// 表单组件
|
||||
export 'form_fieldset.dart';
|
||||
export 'custom_text_editor.dart';
|
||||
export 'context_badge.dart';
|
||||
export 'radio_button_group.dart';
|
||||
export 'custom_dropdown.dart';
|
||||
export 'required_badge.dart';
|
||||
export 'instructions_with_presets.dart';
|
||||
export 'multi_select_instructions_with_presets.dart';
|
||||
|
||||
// 操作栏组件
|
||||
export 'bottom_action_bar.dart';
|
||||
|
||||
// 模板组件
|
||||
export 'form_dialog_template.dart';
|
||||
|
||||
// 新增的导出
|
||||
export 'prompt_preview_widget.dart';
|
||||
|
||||
export 'smart_context_toggle.dart';
|
||||
185
AINoval/lib/widgets/common/instructions_with_presets.dart
Normal file
185
AINoval/lib/widgets/common/instructions_with_presets.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'custom_text_editor.dart';
|
||||
|
||||
// 导入InstructionPreset定义
|
||||
import 'multi_select_instructions_with_presets.dart' show InstructionPreset;
|
||||
|
||||
/// 带预设选项的指令字段组件
|
||||
class InstructionsWithPresets extends StatefulWidget {
|
||||
/// 构造函数
|
||||
const InstructionsWithPresets({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.presets = const [],
|
||||
this.placeholder = 'e.g. You are a...',
|
||||
this.dropdownPlaceholder = 'Select \'Instructions\'...',
|
||||
this.onExpand,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
/// 文本控制器
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// 预设选项列表
|
||||
final List<InstructionPreset> presets;
|
||||
|
||||
/// 输入框占位符
|
||||
final String placeholder;
|
||||
|
||||
/// 下拉框占位符
|
||||
final String dropdownPlaceholder;
|
||||
|
||||
/// 展开回调
|
||||
final VoidCallback? onExpand;
|
||||
|
||||
/// 复制回调
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
@override
|
||||
State<InstructionsWithPresets> createState() => _InstructionsWithPresetsState();
|
||||
}
|
||||
|
||||
class _InstructionsWithPresetsState extends State<InstructionsWithPresets> {
|
||||
InstructionPreset? _selectedPreset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 第一行:预设选择器
|
||||
Row(
|
||||
children: [
|
||||
// 预设下拉选择器
|
||||
if (widget.presets.isNotEmpty) ...[
|
||||
Expanded(
|
||||
child: _buildPresetDropdown(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// AND 分隔符
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'AND',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 第二行:文本编辑器
|
||||
CustomTextEditor(
|
||||
controller: widget.controller,
|
||||
placeholder: widget.placeholder,
|
||||
onExpand: widget.onExpand,
|
||||
onCopy: widget.onCopy,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设下拉选择器
|
||||
Widget _buildPresetDropdown() {
|
||||
return Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<InstructionPreset>(
|
||||
value: _selectedPreset,
|
||||
isExpanded: true,
|
||||
hint: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
widget.dropdownPlaceholder,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey400
|
||||
: WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey400
|
||||
: WebTheme.grey400,
|
||||
),
|
||||
),
|
||||
items: widget.presets.map((preset) => DropdownMenuItem<InstructionPreset>(
|
||||
value: preset,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
preset.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey100
|
||||
: WebTheme.grey900,
|
||||
),
|
||||
),
|
||||
if (preset.description != null) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
preset.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey400
|
||||
: WebTheme.grey600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
onChanged: (preset) {
|
||||
setState(() {
|
||||
_selectedPreset = preset;
|
||||
});
|
||||
|
||||
// 将预设内容填入文本编辑器
|
||||
if (preset != null && widget.controller != null) {
|
||||
final currentText = widget.controller!.text;
|
||||
final newText = currentText.isEmpty
|
||||
? preset.content
|
||||
: '$currentText\n\n${preset.content}';
|
||||
widget.controller!.text = newText;
|
||||
}
|
||||
},
|
||||
dropdownColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
AINoval/lib/widgets/common/loading_indicator.dart
Normal file
54
AINoval/lib/widgets/common/loading_indicator.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 加载指示器
|
||||
class LoadingIndicator extends StatelessWidget {
|
||||
/// 消息
|
||||
final String? message;
|
||||
|
||||
/// 指示器大小
|
||||
final double size;
|
||||
|
||||
/// 指示器粗细
|
||||
final double strokeWidth;
|
||||
|
||||
/// 指示器颜色
|
||||
final Color? color;
|
||||
|
||||
const LoadingIndicator({
|
||||
Key? key,
|
||||
this.message,
|
||||
this.size = 24,
|
||||
this.strokeWidth = 2,
|
||||
this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: strokeWidth,
|
||||
valueColor: color != null
|
||||
? AlwaysStoppedAnimation<Color>(color!)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
306
AINoval/lib/widgets/common/management_list_widgets.dart
Normal file
306
AINoval/lib/widgets/common/management_list_widgets.dart
Normal file
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 顶部标题栏(用于提示词/预设管理列表)
|
||||
class ManagementListTopBar extends StatelessWidget {
|
||||
const ManagementListTopBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
return Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: WebTheme.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
height: 1.1,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: WebTheme.bodySmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.0,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 类型标签(System/Public/Custom)
|
||||
class ManagementTypeChip extends StatelessWidget {
|
||||
const ManagementTypeChip({
|
||||
super.key,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String type;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
Color backgroundColor;
|
||||
Color textColor;
|
||||
|
||||
switch (type) {
|
||||
case 'System':
|
||||
backgroundColor = isDark ? const Color(0xFF2C3E50) : const Color(0xFFE3F2FD);
|
||||
textColor = isDark ? const Color(0xFF74B9FF) : const Color(0xFF1565C0);
|
||||
break;
|
||||
case 'Public':
|
||||
backgroundColor = isDark ? const Color(0xFF2D5016) : const Color(0xFFE8F5E8);
|
||||
textColor = isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32);
|
||||
break;
|
||||
case 'Custom':
|
||||
backgroundColor = isDark ? const Color(0xFF4A2C2A) : const Color(0xFFF3E5F5);
|
||||
textColor = isDark ? const Color(0xFFBA68C8) : const Color(0xFF7B1FA2);
|
||||
break;
|
||||
default:
|
||||
backgroundColor = isDark ? WebTheme.darkGrey200 : WebTheme.grey100;
|
||||
textColor = WebTheme.getSecondaryTextColor(context);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
type,
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用管理列表项
|
||||
class ManagementListItem extends StatelessWidget {
|
||||
const ManagementListItem({
|
||||
super.key,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.leftIcon,
|
||||
required this.leftIconColor,
|
||||
required this.leftIconBgColor,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.tags = const [],
|
||||
this.trailing,
|
||||
this.statusBadges,
|
||||
this.showQuickStar = false,
|
||||
});
|
||||
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final IconData leftIcon;
|
||||
final Color leftIconColor;
|
||||
final Color leftIconBgColor;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final List<String> tags;
|
||||
final Widget? trailing;
|
||||
final List<Widget>? statusBadges;
|
||||
final bool showQuickStar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey200 : WebTheme.grey100)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey400,
|
||||
width: 1,
|
||||
)
|
||||
: Border.all(color: Colors.transparent, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧图标
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: leftIconBgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
leftIcon,
|
||||
size: 12,
|
||||
color: leftIconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 主要内容
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: WebTheme.bodyMedium.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context)
|
||||
: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (statusBadges != null && statusBadges!.isNotEmpty) ...[
|
||||
..._intersperse(statusBadges!, const SizedBox(width: 4)),
|
||||
],
|
||||
if (showQuickStar) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF4A4A4A)
|
||||
: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.star,
|
||||
size: 10,
|
||||
color: Color(0xFFFF8F00),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (subtitle != null && subtitle!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: WebTheme.bodySmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: tags.take(3).map((t) => _buildTag(context, t)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Widget> _intersperse(List<Widget> widgets, Widget spacer) {
|
||||
if (widgets.length <= 1) return widgets;
|
||||
final result = <Widget>[];
|
||||
for (int i = 0; i < widgets.length; i++) {
|
||||
result.add(widgets[i]);
|
||||
if (i != widgets.length - 1) result.add(spacer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _buildTag(BuildContext context, String tag) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
46
AINoval/lib/widgets/common/master_detail_split_view.dart
Normal file
46
AINoval/lib/widgets/common/master_detail_split_view.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 通用左右分栏组件:左侧主列表,右侧详情/编辑
|
||||
class MasterDetailSplitView extends StatelessWidget {
|
||||
final Widget master;
|
||||
final Widget detail;
|
||||
final int masterFlex;
|
||||
final int detailFlex;
|
||||
final double dividerWidth;
|
||||
final Color? dividerColor;
|
||||
|
||||
const MasterDetailSplitView({
|
||||
super.key,
|
||||
required this.master,
|
||||
required this.detail,
|
||||
this.masterFlex = 2,
|
||||
this.detailFlex = 3,
|
||||
this.dividerWidth = 1,
|
||||
this.dividerColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color effectiveDividerColor = dividerColor ??
|
||||
Theme.of(context).dividerColor.withOpacity(0.6);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: masterFlex,
|
||||
child: master,
|
||||
),
|
||||
Container(
|
||||
width: dividerWidth,
|
||||
color: effectiveDividerColor,
|
||||
),
|
||||
Flexible(
|
||||
flex: detailFlex,
|
||||
child: detail,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
481
AINoval/lib/widgets/common/model_display_selector.dart
Normal file
481
AINoval/lib/widgets/common/model_display_selector.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../blocs/ai_config/ai_config_bloc.dart';
|
||||
import '../../blocs/public_models/public_models_bloc.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../../config/provider_icons.dart';
|
||||
import '../../models/ai_request_models.dart';
|
||||
import '../../models/novel_setting_item.dart';
|
||||
import '../../models/novel_snippet.dart';
|
||||
import '../../models/novel_structure.dart';
|
||||
import '../../models/setting_group.dart';
|
||||
import '../../models/unified_ai_model.dart';
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
import '../../models/public_model_config.dart';
|
||||
import 'top_toast.dart';
|
||||
import 'unified_ai_model_dropdown.dart';
|
||||
|
||||
/// 尺寸变体:根据不同大小展示不同的信息密度
|
||||
enum ModelDisplaySize { small, medium, large }
|
||||
|
||||
/// 通用的“模型显示与选择”组件
|
||||
/// - 支持显示模型名称、标签,可选显示提供商图标
|
||||
/// - 点击后弹出统一的模型下拉菜单(自动根据空间选择上下方向)
|
||||
class ModelDisplaySelector extends StatefulWidget {
|
||||
const ModelDisplaySelector({
|
||||
Key? key,
|
||||
this.selectedModel,
|
||||
this.onModelSelected,
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.placeholder = '选择模型',
|
||||
this.size = ModelDisplaySize.medium,
|
||||
this.showIcon = true,
|
||||
this.showTags = true,
|
||||
this.showSettingsButton = true,
|
||||
this.width,
|
||||
this.height,
|
||||
}) : super(key: key);
|
||||
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final ValueChanged<UnifiedAIModel?>? onModelSelected;
|
||||
final UniversalAIRequest? chatConfig;
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final String placeholder;
|
||||
final ModelDisplaySize size;
|
||||
final bool showIcon;
|
||||
final bool showTags;
|
||||
final bool showSettingsButton;
|
||||
final double? width;
|
||||
final double? height; // 可覆盖默认高度
|
||||
|
||||
@override
|
||||
State<ModelDisplaySelector> createState() => _ModelDisplaySelectorState();
|
||||
}
|
||||
|
||||
class _ModelDisplaySelectorState extends State<ModelDisplaySelector> {
|
||||
OverlayEntry? _overlay;
|
||||
bool _autoPickDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 首帧尝试自动选择默认模型
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoPickDefault());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
if (_overlay != null && _overlay!.mounted) {
|
||||
_overlay!.remove();
|
||||
}
|
||||
_overlay = null;
|
||||
}
|
||||
|
||||
void _showDropdown() {
|
||||
if (_overlay != null) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:如果没有任何可用模型,提示并返回
|
||||
final aiState = context.read<AiConfigBloc>().state;
|
||||
final publicState = context.read<PublicModelsBloc>().state;
|
||||
final hasPrivate = aiState.validatedConfigs.isNotEmpty;
|
||||
final hasPublic = publicState is PublicModelsLoaded && publicState.models.isNotEmpty;
|
||||
if (!hasPrivate && !hasPublic) {
|
||||
TopToast.error(context, '暂无可用的AI模型配置');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算触发器组件的全局矩形作为锚点
|
||||
final RenderBox box = context.findRenderObject() as RenderBox;
|
||||
final Offset globalPosition = box.localToGlobal(Offset.zero);
|
||||
final Rect anchorRect = Rect.fromLTWH(
|
||||
globalPosition.dx,
|
||||
globalPosition.dy,
|
||||
box.size.width,
|
||||
box.size.height,
|
||||
);
|
||||
|
||||
_overlay = UnifiedAIModelDropdown.show(
|
||||
context: context,
|
||||
anchorRect: anchorRect,
|
||||
selectedModel: widget.selectedModel,
|
||||
onModelSelected: (unifiedModel) {
|
||||
// 直接回传统一模型
|
||||
widget.onModelSelected?.call(unifiedModel);
|
||||
|
||||
// 如果需要同步到聊天配置(保留与旧接口兼容)
|
||||
if (widget.onConfigChanged != null && widget.chatConfig != null && unifiedModel != null) {
|
||||
UserAIModelConfigModel? compatModel;
|
||||
if (unifiedModel.isPublic) {
|
||||
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
|
||||
compatModel = UserAIModelConfigModel.fromJson({
|
||||
'id': 'public_${publicModel.id}',
|
||||
'userId': AppConfig.userId ?? 'unknown',
|
||||
'alias': publicModel.displayName,
|
||||
'modelName': publicModel.modelId,
|
||||
'provider': publicModel.provider,
|
||||
'apiEndpoint': '',
|
||||
'isDefault': false,
|
||||
'isValidated': true,
|
||||
'createdAt': DateTime.now().toIso8601String(),
|
||||
'updatedAt': DateTime.now().toIso8601String(),
|
||||
});
|
||||
} else {
|
||||
compatModel = (unifiedModel as PrivateAIModel).userConfig;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> mergedMetadata = {
|
||||
...?widget.chatConfig?.metadata,
|
||||
'modelName': unifiedModel.modelId,
|
||||
'modelProvider': unifiedModel.provider,
|
||||
'modelConfigId': unifiedModel.id,
|
||||
'isPublicModel': unifiedModel.isPublic,
|
||||
};
|
||||
if (unifiedModel.isPublic) {
|
||||
final publicId = (unifiedModel as PublicAIModel).publicConfig.id;
|
||||
mergedMetadata['publicModelConfigId'] = publicId;
|
||||
mergedMetadata['publicModelId'] = publicId;
|
||||
} else {
|
||||
mergedMetadata.remove('publicModelConfigId');
|
||||
mergedMetadata.remove('publicModelId');
|
||||
}
|
||||
|
||||
final updated = widget.chatConfig!.copyWith(
|
||||
modelConfig: compatModel,
|
||||
metadata: mergedMetadata,
|
||||
);
|
||||
widget.onConfigChanged!(updated);
|
||||
}
|
||||
},
|
||||
showSettingsButton: widget.showSettingsButton,
|
||||
// 隐藏“调整并生成”入口:小说列表输入框不需要该动作
|
||||
// 该组件当前仅用于首页/列表输入区,因此固定为false
|
||||
// 如将来复用到其他地方,可将该参数暴露为构造函数可配置
|
||||
showAdjustAndGenerate: false,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
chatConfig: widget.chatConfig,
|
||||
onConfigChanged: widget.onConfigChanged,
|
||||
onClose: () {
|
||||
_overlay = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _maybeAutoPickDefault() {
|
||||
if (_autoPickDone) return;
|
||||
if (widget.selectedModel != null) return;
|
||||
|
||||
final UnifiedAIModel? defaultModel = _computeDefaultModel();
|
||||
if (defaultModel != null) {
|
||||
_autoPickDone = true;
|
||||
widget.onModelSelected?.call(defaultModel);
|
||||
}
|
||||
}
|
||||
|
||||
UnifiedAIModel? _computeDefaultModel() {
|
||||
// 优先:已登录用户的默认私有模型
|
||||
final String? userId = AppConfig.userId;
|
||||
final aiState = context.read<AiConfigBloc>().state;
|
||||
if (userId != null) {
|
||||
final defaults = aiState.validatedConfigs.where((c) => c.isDefault).toList();
|
||||
if (defaults.isNotEmpty) {
|
||||
return PrivateAIModel(defaults.first);
|
||||
}
|
||||
// 可选:如无默认,继续尝试公共模型
|
||||
}
|
||||
|
||||
// 未登录或无默认 → 使用公共服务 gemini-2.0(或最优的gemini可用项)
|
||||
final publicState = context.read<PublicModelsBloc>().state;
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
final List<PublicModel> models = publicState.models;
|
||||
PublicModel? target;
|
||||
for (final m in models) {
|
||||
if (m.modelId.toLowerCase() == 'gemini-2.0') {
|
||||
target = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target == null) {
|
||||
// 选择 provider/modelId 含 gemini 的优先项(按 priority 降序)
|
||||
final geminiCandidates = models.where((m) {
|
||||
final p = m.provider.toLowerCase();
|
||||
final id = m.modelId.toLowerCase();
|
||||
return p.contains('gemini') || p.contains('google') || id.contains('gemini');
|
||||
}).toList();
|
||||
if (geminiCandidates.isNotEmpty) {
|
||||
geminiCandidates.sort((a, b) => (b.priority ?? 0).compareTo(a.priority ?? 0));
|
||||
target = geminiCandidates.first;
|
||||
}
|
||||
}
|
||||
if (target != null) {
|
||||
return PublicAIModel(target);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String _displayName() {
|
||||
if (widget.selectedModel != null) return widget.selectedModel!.displayName;
|
||||
final configModel = widget.chatConfig?.modelConfig;
|
||||
if (configModel != null) {
|
||||
return configModel.alias.isNotEmpty ? configModel.alias : configModel.modelName;
|
||||
}
|
||||
return widget.placeholder;
|
||||
}
|
||||
|
||||
double _heightForSize() {
|
||||
if (widget.height != null) return widget.height!;
|
||||
switch (widget.size) {
|
||||
case ModelDisplaySize.small:
|
||||
return 32;
|
||||
case ModelDisplaySize.medium:
|
||||
return 36;
|
||||
case ModelDisplaySize.large:
|
||||
return 44;
|
||||
}
|
||||
}
|
||||
|
||||
double _fontSizeForSize() {
|
||||
switch (widget.size) {
|
||||
case ModelDisplaySize.small:
|
||||
return 12;
|
||||
case ModelDisplaySize.medium:
|
||||
return 13;
|
||||
case ModelDisplaySize.large:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
|
||||
int _maxTagsToShow() {
|
||||
if (!widget.showTags) return 0;
|
||||
switch (widget.size) {
|
||||
case ModelDisplaySize.small:
|
||||
return 1;
|
||||
case ModelDisplaySize.medium:
|
||||
return 2;
|
||||
case ModelDisplaySize.large:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 主题与展示数据
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final textColor = isDark ? const Color(0xFFD1D5DB) : const Color(0xFF374151);
|
||||
final borderColor = isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB);
|
||||
List<String> tags;
|
||||
final sel = widget.selectedModel;
|
||||
if (sel != null) {
|
||||
final bool isPublicById = sel.id.startsWith('public_') || sel.isPublic;
|
||||
if (isPublicById) {
|
||||
tags = ['系统'];
|
||||
} else {
|
||||
tags = sel.modelTags;
|
||||
}
|
||||
} else {
|
||||
final cfgId = widget.chatConfig?.modelConfig?.id;
|
||||
if (cfgId != null && cfgId.startsWith('public_')) {
|
||||
tags = ['系统'];
|
||||
} else {
|
||||
tags = const [];
|
||||
}
|
||||
}
|
||||
final int showTagCount = _maxTagsToShow().clamp(0, tags.length);
|
||||
|
||||
// 监听相关Bloc以在数据加载后执行一次自动选择
|
||||
// 注意:仅在尚未自动选择且外部未传入selectedModel时才会触发
|
||||
// 使用Listener而非Builder,避免无谓重建
|
||||
final child = GestureDetector(
|
||||
onTap: _showDropdown,
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: _heightForSize(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF374151) : Colors.white,
|
||||
border: Border.all(color: borderColor, width: 1.0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.showIcon)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildProviderIcon(),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_displayName(),
|
||||
style: TextStyle(
|
||||
fontSize: _fontSizeForSize(),
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (showTagCount > 0) const SizedBox(width: 8),
|
||||
if (showTagCount > 0)
|
||||
Flexible(
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 2,
|
||||
children: tags
|
||||
.take(showTagCount)
|
||||
.map((t) => _TagChip(text: t))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.expand_more,
|
||||
size: 18,
|
||||
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<AiConfigBloc, AiConfigState>(
|
||||
listenWhen: (p, c) => !_autoPickDone && widget.selectedModel == null,
|
||||
listener: (context, state) => _maybeAutoPickDefault(),
|
||||
),
|
||||
BlocListener<PublicModelsBloc, PublicModelsState>(
|
||||
listenWhen: (p, c) => !_autoPickDone && widget.selectedModel == null,
|
||||
listener: (context, state) => _maybeAutoPickDefault(),
|
||||
),
|
||||
],
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProviderIcon() {
|
||||
final model = widget.selectedModel;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
if (model == null) {
|
||||
return Icon(
|
||||
Icons.model_training_outlined,
|
||||
size: 16,
|
||||
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
|
||||
);
|
||||
}
|
||||
|
||||
final color = ProviderIcons.getProviderColor(model.provider);
|
||||
return Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.white.withOpacity(0.9) : color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark ? color.withOpacity(0.3) : color.withOpacity(0.25),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ProviderIcons.getProviderIcon(model.provider, size: 12, useHighQuality: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
Color tagColor;
|
||||
Color backgroundColor;
|
||||
Color borderColor;
|
||||
|
||||
if (text == '私有') {
|
||||
tagColor = Colors.blue;
|
||||
backgroundColor = isDark ? Colors.blue.withOpacity(0.15) : Colors.blue.withOpacity(0.1);
|
||||
borderColor = Colors.blue.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (text == '系统') {
|
||||
tagColor = Colors.green;
|
||||
backgroundColor = isDark ? Colors.green.withOpacity(0.15) : Colors.green.withOpacity(0.1);
|
||||
borderColor = Colors.green.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (text == '推荐') {
|
||||
tagColor = Colors.orange;
|
||||
backgroundColor = isDark ? Colors.orange.withOpacity(0.15) : Colors.orange.withOpacity(0.1);
|
||||
borderColor = Colors.orange.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (text == '免费') {
|
||||
tagColor = Colors.purple;
|
||||
backgroundColor = isDark ? Colors.purple.withOpacity(0.15) : Colors.purple.withOpacity(0.1);
|
||||
borderColor = Colors.purple.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (text.contains('积分')) {
|
||||
tagColor = Colors.red;
|
||||
backgroundColor = isDark ? Colors.red.withOpacity(0.15) : Colors.red.withOpacity(0.1);
|
||||
borderColor = Colors.red.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else {
|
||||
tagColor = cs.outline;
|
||||
backgroundColor = isDark ? cs.surfaceVariant.withOpacity(0.3) : cs.surfaceVariant.withOpacity(0.5);
|
||||
borderColor = cs.outline.withOpacity(isDark ? 0.3 : 0.2);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: borderColor, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tagColor.withOpacity(isDark ? 0.9 : 0.8),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
477
AINoval/lib/widgets/common/model_dropdown_menu.dart
Normal file
477
AINoval/lib/widgets/common/model_dropdown_menu.dart
Normal file
@@ -0,0 +1,477 @@
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
import '../../models/novel_structure.dart';
|
||||
import '../../models/novel_setting_item.dart';
|
||||
import '../../models/setting_group.dart';
|
||||
import '../../models/novel_snippet.dart';
|
||||
import '../../screens/chat/widgets/chat_settings_dialog.dart';
|
||||
import '../../config/provider_icons.dart';
|
||||
import '../../models/ai_request_models.dart';
|
||||
|
||||
/// 纯粹的模型下拉菜单组件,供多个场景复用
|
||||
/// 通过 [show] 静态方法弹出 Overlay 菜单
|
||||
class ModelDropdownMenu {
|
||||
static OverlayEntry show({
|
||||
required BuildContext context,
|
||||
LayerLink? layerLink,
|
||||
Rect? anchorRect,
|
||||
required List<UserAIModelConfigModel> configs,
|
||||
UserAIModelConfigModel? selectedModel,
|
||||
required Function(UserAIModelConfigModel?) onModelSelected,
|
||||
bool showSettingsButton = true,
|
||||
double maxHeight = 2400,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
assert(layerLink != null || anchorRect != null, '必须提供 layerLink 或 anchorRect');
|
||||
|
||||
late OverlayEntry entry;
|
||||
bool _closed = false;
|
||||
|
||||
void safeClose() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
if (entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
onClose?.call();
|
||||
}
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) {
|
||||
// 计算菜单高度(依据当前 UI 调整过的真实尺寸)
|
||||
const double groupHeaderHeight = 48.0; // 分组标题约 28px
|
||||
const double modelItemHeight = 36.0; // 单条模型项约 36px
|
||||
const double bottomButtonHeight = 56.0; // 底部操作区固定 56px
|
||||
const double verticalPadding = 12.0; // 上下留白
|
||||
|
||||
final grouped = _groupModelsByProvider(configs);
|
||||
int totalItems = 0;
|
||||
for (var g in grouped.values) {
|
||||
totalItems += g.length;
|
||||
}
|
||||
final double contentHeight =
|
||||
(grouped.length * groupHeaderHeight) +
|
||||
(totalItems * modelItemHeight) +
|
||||
(showSettingsButton ? bottomButtonHeight : 0) +
|
||||
(verticalPadding * 2);
|
||||
final double minHeight = showSettingsButton ? 180 : 100;
|
||||
final double menuHeight = contentHeight.clamp(minHeight, maxHeight);
|
||||
|
||||
// 主题检测
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 点击空白处关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: safeClose,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
if (layerLink != null) ...[
|
||||
Positioned(
|
||||
width: 300,
|
||||
child: CompositedTransformFollower(
|
||||
link: layerLink!,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.topCenter,
|
||||
followerAnchor: Alignment.bottomCenter,
|
||||
offset: const Offset(0, -6), // 向上偏移6像素
|
||||
child: _buildMenuContainer(context, menuHeight, configs, selectedModel, onModelSelected, showSettingsButton, novel, settings, settingGroups, snippets, chatConfig, onConfigChanged, safeClose),
|
||||
),
|
||||
),
|
||||
] else if (anchorRect != null) ...[
|
||||
_buildPositionedMenu(context, anchorRect!, menuHeight, configs, selectedModel, onModelSelected, showSettingsButton, novel, settings, settingGroups, snippets, chatConfig, onConfigChanged, safeClose),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void _remove(OverlayEntry entry) {
|
||||
if (entry.mounted) entry.remove();
|
||||
}
|
||||
|
||||
// 分组逻辑提取
|
||||
static Map<String, List<UserAIModelConfigModel>> _groupModelsByProvider(
|
||||
List<UserAIModelConfigModel> configs) {
|
||||
final Map<String, List<UserAIModelConfigModel>> grouped = {};
|
||||
for (var c in configs) {
|
||||
grouped.putIfAbsent(c.provider, () => []);
|
||||
grouped[c.provider]!.add(c);
|
||||
}
|
||||
for (var list in grouped.values) {
|
||||
list.sort((a, b) {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// internal build helpers
|
||||
static Widget _buildMenuContainer(BuildContext context,double menuHeight,
|
||||
List<UserAIModelConfigModel> configs,
|
||||
UserAIModelConfigModel? selectedModel,
|
||||
Function(UserAIModelConfigModel?) onModelSelected,
|
||||
bool showSettingsButton,Novel? novel,List<NovelSettingItem> settings,List<SettingGroup> settingGroups,List<NovelSnippet> snippets,UniversalAIRequest? chatConfig,ValueChanged<UniversalAIRequest>? onConfigChanged,VoidCallback onClose){
|
||||
final isDark = Theme.of(context).brightness==Brightness.dark;
|
||||
return Material(
|
||||
elevation: isDark?12:8,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isDark?Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.95):Theme.of(context).colorScheme.surfaceContainer,
|
||||
shadowColor: Colors.black.withOpacity(isDark?0.3:0.15),
|
||||
child: Container(
|
||||
height: menuHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color:Theme.of(context).colorScheme.outlineVariant.withOpacity(isDark?0.2:0.3),width:0.8),
|
||||
),
|
||||
child: _MenuContent(
|
||||
configs:configs,
|
||||
selectedModel:selectedModel,
|
||||
onModelSelected:onModelSelected,
|
||||
onClose:onClose,
|
||||
showSettingsButton:showSettingsButton,
|
||||
novel:novel,
|
||||
settings:settings,
|
||||
settingGroups:settingGroups,
|
||||
snippets:snippets,
|
||||
chatConfig:chatConfig,
|
||||
onConfigChanged:onConfigChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildPositionedMenu(BuildContext context,Rect anchorRect,double menuHeight,
|
||||
List<UserAIModelConfigModel> configs,
|
||||
UserAIModelConfigModel? selectedModel,
|
||||
Function(UserAIModelConfigModel?) onModelSelected,
|
||||
bool showSettingsButton,Novel? novel,List<NovelSettingItem> settings,List<SettingGroup> settingGroups,List<NovelSnippet> snippets,UniversalAIRequest? chatConfig,ValueChanged<UniversalAIRequest>? onConfigChanged,VoidCallback onClose){
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizMargin=16;
|
||||
double left=anchorRect.left;
|
||||
if(left+300>screenSize.width-horizMargin){
|
||||
left=screenSize.width-300-horizMargin;
|
||||
}
|
||||
|
||||
// Determine vertical placement
|
||||
double top=anchorRect.top-menuHeight-6; // above
|
||||
if(top<MediaQuery.of(context).padding.top+10){
|
||||
top=anchorRect.bottom+6; // below
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
left:left,
|
||||
top:top,
|
||||
width:300,
|
||||
child:_buildMenuContainer(context, menuHeight, configs, selectedModel, onModelSelected, showSettingsButton, novel, settings, settingGroups, snippets, chatConfig, onConfigChanged, onClose),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ 内部菜单内容 ------------------
|
||||
class _MenuContent extends StatelessWidget {
|
||||
const _MenuContent({
|
||||
Key? key,
|
||||
required this.configs,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
required this.onClose,
|
||||
required this.showSettingsButton,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<UserAIModelConfigModel> configs;
|
||||
final UserAIModelConfigModel? selectedModel;
|
||||
final Function(UserAIModelConfigModel?) onModelSelected;
|
||||
final VoidCallback onClose;
|
||||
final bool showSettingsButton;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final UniversalAIRequest? chatConfig;
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (configs.isEmpty) {
|
||||
return _buildEmpty(context);
|
||||
}
|
||||
final grouped = ModelDropdownMenu._groupModelsByProvider(configs);
|
||||
final providers = grouped.keys.toList()
|
||||
..sort((a, b) {
|
||||
final aDef = grouped[a]!.any((c) => c.isDefault);
|
||||
final bDef = grouped[b]!.any((c) => c.isDefault);
|
||||
if (aDef && !bDef) return -1;
|
||||
if (!aDef && bDef) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
itemCount: providers.length,
|
||||
separatorBuilder: (c, i) => Divider(
|
||||
height: 8,
|
||||
thickness: 0.6,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.12),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
itemBuilder: (c, index) {
|
||||
final provider = providers[index];
|
||||
final models = grouped[provider]!;
|
||||
return _ProviderGroup(
|
||||
provider: provider,
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
onModelSelected: (m){
|
||||
onModelSelected(m);
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showSettingsButton) _buildBottomActions(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.model_training_outlined,
|
||||
size: 48, color: cs.onSurfaceVariant.withOpacity(0.5)),
|
||||
const SizedBox(height: 12),
|
||||
Text('无可用模型',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
Text('请先配置AI模型',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withOpacity(0.7))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? cs.surface.withOpacity(0.8) : cs.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: cs.outlineVariant.withOpacity(isDark ? 0.15 : 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onClose(); // 先关闭 Overlay
|
||||
showChatSettingsDialog(
|
||||
context,
|
||||
selectedModel: selectedModel,
|
||||
onModelChanged: (m) => onModelSelected(m),
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
initialChatConfig: chatConfig,
|
||||
onConfigChanged: onConfigChanged,
|
||||
initialContextSelections: null, // 🚀 让ChatSettingsDialog自己构建上下文数据
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: const Text('调整并生成'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor:
|
||||
isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
backgroundColor: isDark
|
||||
? cs.primaryContainer.withOpacity(0.08)
|
||||
: cs.primaryContainer.withOpacity(0.1),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
elevation: 0,
|
||||
side: BorderSide(color: cs.primary.withOpacity(isDark ? 0.2 : 0.3), width: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 分组
|
||||
class _ProviderGroup extends StatelessWidget {
|
||||
const _ProviderGroup({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
final String provider;
|
||||
final List<UserAIModelConfigModel> models;
|
||||
final UserAIModelConfigModel? selectedModel;
|
||||
final Function(UserAIModelConfigModel?) onModelSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: Text(provider.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
fontSize: 14,
|
||||
)),
|
||||
),
|
||||
...models.map((m) => _ModelItem(
|
||||
model: m,
|
||||
isSelected: selectedModel?.id == m.id,
|
||||
onTap: () => onModelSelected(m),
|
||||
)),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModelItem extends StatelessWidget {
|
||||
const _ModelItem({
|
||||
Key? key,
|
||||
required this.model,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final UserAIModelConfigModel model;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final displayName = model.alias.isNotEmpty ? model.alias : model.modelName;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
splashColor: cs.primary.withOpacity(0.08),
|
||||
highlightColor: cs.primary.withOpacity(0.04),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? cs.primaryContainer.withOpacity(0.2)
|
||||
: cs.primaryContainer.withOpacity(0.15))
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(color: cs.primary.withOpacity(0.2), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: _getModelIcon(model.provider, context),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? cs.primary
|
||||
: (isDark
|
||||
? cs.onSurface.withOpacity(0.9)
|
||||
: cs.onSurface),
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(Icons.check_circle_rounded, size: 16, color: cs.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getModelIcon(String provider, BuildContext context) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.white.withOpacity(0.9) : color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark ? color.withOpacity(0.3) : color.withOpacity(0.25),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ProviderIcons.getProviderIcon(provider, size: 10, useHighQuality: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
768
AINoval/lib/widgets/common/model_selector.dart
Normal file
768
AINoval/lib/widgets/common/model_selector.dart
Normal file
@@ -0,0 +1,768 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/ai_config/ai_config_bloc.dart';
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
import '../../models/novel_structure.dart';
|
||||
import '../../models/novel_setting_item.dart';
|
||||
import '../../models/setting_group.dart';
|
||||
import '../../models/novel_snippet.dart';
|
||||
import '../../models/ai_request_models.dart';
|
||||
import '../../screens/chat/widgets/chat_settings_dialog.dart';
|
||||
import '../../config/provider_icons.dart';
|
||||
import 'model_dropdown_menu.dart';
|
||||
|
||||
/// 模型选择器公共组件
|
||||
///
|
||||
/// 功能特性:
|
||||
/// - 按供应商分组显示模型
|
||||
/// - 模型图标显示
|
||||
/// - 默认模型标识
|
||||
/// - 模型标签支持(如免费标签)
|
||||
/// - 分为模型列表区和底部操作区
|
||||
class ModelSelector extends StatefulWidget {
|
||||
const ModelSelector({
|
||||
Key? key,
|
||||
this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
this.onSettingsPressed,
|
||||
this.compact = false,
|
||||
this.showSettingsButton = true,
|
||||
this.maxHeight = 2400,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
/// 当前选中的模型
|
||||
final UserAIModelConfigModel? selectedModel;
|
||||
|
||||
/// 模型选择回调
|
||||
final Function(UserAIModelConfigModel?) onModelSelected;
|
||||
|
||||
/// 设置按钮点击回调
|
||||
final VoidCallback? onSettingsPressed;
|
||||
|
||||
/// 是否紧凑模式
|
||||
final bool compact;
|
||||
|
||||
/// 是否显示设置按钮
|
||||
final bool showSettingsButton;
|
||||
|
||||
/// 最大高度
|
||||
final double maxHeight;
|
||||
|
||||
/// 小说数据,用于上下文选择
|
||||
final Novel? novel;
|
||||
|
||||
/// 设定数据
|
||||
final List<NovelSettingItem> settings;
|
||||
|
||||
/// 设定组数据
|
||||
final List<SettingGroup> settingGroups;
|
||||
|
||||
/// 片段数据
|
||||
final List<NovelSnippet> snippets;
|
||||
|
||||
/// 🚀 聊天配置
|
||||
final UniversalAIRequest? chatConfig;
|
||||
|
||||
/// 🚀 配置变更回调
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
|
||||
@override
|
||||
State<ModelSelector> createState() => _ModelSelectorState();
|
||||
}
|
||||
|
||||
class _ModelSelectorState extends State<ModelSelector> {
|
||||
OverlayEntry? _overlayEntry;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
bool _isMenuOpen = false;
|
||||
|
||||
/// 公开方法:触发菜单显示/隐藏
|
||||
void showDropdown() {
|
||||
final aiConfigBloc = context.read<AiConfigBloc>();
|
||||
final validatedConfigs = aiConfigBloc.state.validatedConfigs;
|
||||
if (validatedConfigs.isNotEmpty) {
|
||||
_toggleMenu(context, validatedConfigs);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
_isMenuOpen = false;
|
||||
}
|
||||
|
||||
void _toggleMenu(BuildContext context, List<UserAIModelConfigModel> configs) {
|
||||
if (_isMenuOpen) {
|
||||
_removeOverlay();
|
||||
} else {
|
||||
_createOverlay(context, configs);
|
||||
_isMenuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
void _createOverlay(BuildContext context, List<UserAIModelConfigModel> configs) {
|
||||
_overlayEntry = ModelDropdownMenu.show(
|
||||
context: context,
|
||||
layerLink: _layerLink,
|
||||
configs: configs,
|
||||
selectedModel: widget.selectedModel,
|
||||
onModelSelected: (model) {
|
||||
widget.onModelSelected(model);
|
||||
setState(() {});
|
||||
},
|
||||
showSettingsButton: widget.showSettingsButton,
|
||||
maxHeight: widget.maxHeight,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
chatConfig: widget.chatConfig,
|
||||
onConfigChanged: widget.onConfigChanged,
|
||||
onClose: () {
|
||||
_overlayEntry = null;
|
||||
setState(() {
|
||||
_isMenuOpen = false;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuContent(List<UserAIModelConfigModel> configs) {
|
||||
if (configs.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.model_training_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'无可用模型',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请先配置AI模型',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildModelList(configs),
|
||||
),
|
||||
if (widget.showSettingsButton)
|
||||
_buildBottomActions(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelList(List<UserAIModelConfigModel> configs) {
|
||||
final groupedModels = _groupModelsByProvider(configs);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
|
||||
// Sort providers: default provider first, then alphabetically
|
||||
final sortedProviders = groupedModels.keys.toList()..sort((a, b) {
|
||||
final aIsDefault = groupedModels[a]!.any((c) => c.isDefault);
|
||||
final bIsDefault = groupedModels[b]!.any((c) => c.isDefault);
|
||||
if (aIsDefault && !bIsDefault) return -1;
|
||||
if (!aIsDefault && bIsDefault) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0),
|
||||
itemCount: sortedProviders.length,
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 16,
|
||||
thickness: 0.8,
|
||||
color: colorScheme.outlineVariant.withOpacity(0.12),
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final provider = sortedProviders[index];
|
||||
final models = groupedModels[provider]!;
|
||||
return _buildProviderGroup(provider, models);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProviderGroup(String provider, List<UserAIModelConfigModel> models) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 供应商分组标题 - 完全移除图标,增大字体
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: Text(
|
||||
provider.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: isDark
|
||||
? colorScheme.primary.withOpacity(0.9)
|
||||
: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.0,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 该供应商下的模型列表
|
||||
...models.map((model) => _buildModelItem(model)).toList(),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelItem(UserAIModelConfigModel model) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSelected = widget.selectedModel?.id == model.id;
|
||||
final displayName = model.alias.isNotEmpty ? model.alias : model.modelName;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
widget.onModelSelected(model);
|
||||
_removeOverlay();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
splashColor: colorScheme.primary.withOpacity(0.08),
|
||||
highlightColor: colorScheme.primary.withOpacity(0.04),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? colorScheme.primaryContainer.withOpacity(0.2)
|
||||
: colorScheme.primaryContainer.withOpacity(0.15))
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: colorScheme.primary.withOpacity(0.2),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 模型图标 - 外层包装防止突兀
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: _getModelIcon(model.provider),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
// 模型信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 模型名称行
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: (isDark
|
||||
? colorScheme.onSurface.withOpacity(0.9)
|
||||
: colorScheme.onSurface),
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 默认模型标识
|
||||
if (model.isDefault) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.amber.withOpacity(0.15)
|
||||
: Colors.amber.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.amber.withOpacity(isDark ? 0.4 : 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'默认',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: isDark
|
||||
? Colors.amber.shade300
|
||||
: Colors.amber.shade700,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// 模型标签行(预留区域)
|
||||
if (_getModelTags(model).isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Wrap(
|
||||
spacing: 3,
|
||||
runSpacing: 2,
|
||||
children: _getModelTags(model).map((tag) => _buildModelTag(tag)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 选中标识
|
||||
if (isSelected)
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelTag(ModelTag tag) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
MaterialColor tagColor;
|
||||
switch (tag.type) {
|
||||
case ModelTagType.free:
|
||||
tagColor = Colors.green;
|
||||
break;
|
||||
case ModelTagType.premium:
|
||||
tagColor = Colors.purple;
|
||||
break;
|
||||
case ModelTagType.beta:
|
||||
tagColor = Colors.orange;
|
||||
break;
|
||||
default:
|
||||
tagColor = Colors.grey;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? tagColor.withOpacity(0.08)
|
||||
: tagColor.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: tagColor.withOpacity(isDark ? 0.2 : 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag.label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: isDark
|
||||
? tagColor.shade300
|
||||
: tagColor.shade700,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? colorScheme.surface.withOpacity(0.8)
|
||||
: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: colorScheme.outlineVariant.withOpacity(isDark ? 0.15 : 0.2),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_removeOverlay();
|
||||
// 显示聊天设置对话框
|
||||
showChatSettingsDialog(
|
||||
context,
|
||||
selectedModel: widget.selectedModel,
|
||||
onModelChanged: (model) {
|
||||
widget.onModelSelected(model);
|
||||
},
|
||||
onSettingsSaved: () {
|
||||
widget.onSettingsPressed?.call();
|
||||
},
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
// 🚀 传递聊天配置,确保设置对话框能够同步
|
||||
initialChatConfig: widget.chatConfig,
|
||||
onConfigChanged: widget.onConfigChanged,
|
||||
initialContextSelections: null, // 🚀 让ChatSettingsDialog自己构建上下文数据
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune_rounded, size: 18),
|
||||
label: const Text('调整并生成'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: isDark
|
||||
? colorScheme.primary.withOpacity(0.9)
|
||||
: colorScheme.primary,
|
||||
backgroundColor: isDark
|
||||
? colorScheme.primaryContainer.withOpacity(0.08)
|
||||
: colorScheme.primaryContainer.withOpacity(0.1),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 0,
|
||||
side: BorderSide(
|
||||
color: colorScheme.primary.withOpacity(isDark ? 0.2 : 0.3),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, List<UserAIModelConfigModel>> _groupModelsByProvider(
|
||||
List<UserAIModelConfigModel> configs) {
|
||||
final Map<String, List<UserAIModelConfigModel>> grouped = {};
|
||||
|
||||
for (final config in configs) {
|
||||
final provider = config.provider;
|
||||
grouped.putIfAbsent(provider, () => []);
|
||||
grouped[provider]!.add(config);
|
||||
}
|
||||
|
||||
// 对每个供应商的模型按名称排序,默认模型排在前面
|
||||
for (final models in grouped.values) {
|
||||
models.sort((a, b) {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
Widget _getProviderIcon(String provider) {
|
||||
return ProviderIcons.getProviderIconForContext(
|
||||
provider,
|
||||
iconSize: IconSize.small,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getModelIcon(String provider) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withOpacity(0.9) // 暗黑模式下背景为白色
|
||||
: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? color.withOpacity(0.3)
|
||||
: color.withOpacity(0.25),
|
||||
width: 0.5,
|
||||
),
|
||||
boxShadow: isDark ? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ProviderIcons.getProviderIcon(
|
||||
provider,
|
||||
size: 10,
|
||||
useHighQuality: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ModelTag> _getModelTags(UserAIModelConfigModel model) {
|
||||
// 根据模型信息返回标签列表
|
||||
List<ModelTag> tags = [];
|
||||
|
||||
// 示例:根据模型名称或其他属性添加标签
|
||||
if (model.modelName.toLowerCase().contains('free') ||
|
||||
model.modelName.toLowerCase().contains('gpt-3.5')) {
|
||||
tags.add(const ModelTag(label: '免费', type: ModelTagType.free));
|
||||
}
|
||||
|
||||
if (model.modelName.toLowerCase().contains('beta')) {
|
||||
tags.add(const ModelTag(label: 'Beta', type: ModelTagType.beta));
|
||||
}
|
||||
|
||||
if (model.modelName.toLowerCase().contains('pro') ||
|
||||
model.modelName.toLowerCase().contains('gpt-4')) {
|
||||
tags.add(const ModelTag(label: '专业版', type: ModelTagType.premium));
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, state) {
|
||||
final validatedConfigs = state.validatedConfigs;
|
||||
|
||||
// 确定当前选中的模型
|
||||
UserAIModelConfigModel? currentSelection;
|
||||
if (widget.selectedModel != null &&
|
||||
validatedConfigs.any((c) => c.id == widget.selectedModel!.id)) {
|
||||
currentSelection = widget.selectedModel;
|
||||
} else if (state.defaultConfig != null &&
|
||||
validatedConfigs.any((c) => c.id == state.defaultConfig!.id)) {
|
||||
currentSelection = state.defaultConfig;
|
||||
} else if (validatedConfigs.isNotEmpty) {
|
||||
currentSelection = validatedConfigs.first;
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if (state.status == AiConfigStatus.loading && validatedConfigs.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(widget.compact ? 12 : 16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outline.withOpacity(0.2),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('加载中...', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 无模型状态
|
||||
if (state.status != AiConfigStatus.loading && validatedConfigs.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(widget.compact ? 12 : 16),
|
||||
border: Border.all(
|
||||
color: colorScheme.error.withOpacity(0.3),
|
||||
width: 0.8,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_outlined,
|
||||
size: 16,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'无可用模型',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 正常状态 - 模型选择器
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: validatedConfigs.isNotEmpty
|
||||
? () => _toggleMenu(context, validatedConfigs)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: colorScheme.onSurface.withOpacity(0.08),
|
||||
splashColor: colorScheme.onSurface.withOpacity(0.12),
|
||||
child: Container(
|
||||
height: 44,
|
||||
constraints: const BoxConstraints(maxWidth: 128),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: Colors.transparent,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 主要内容区域
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 文字内容
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 第一行:General Chat
|
||||
Text(
|
||||
'General Chat',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 第二行:模型名称
|
||||
Text(
|
||||
_getModelDisplayName(currentSelection),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurface.withOpacity(0.5),
|
||||
height: 1.2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 下拉箭头
|
||||
if (validatedConfigs.length > 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
child: Icon(
|
||||
_isMenuOpen
|
||||
? Icons.keyboard_arrow_up_rounded
|
||||
: Icons.keyboard_arrow_down_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getDisplayText(UserAIModelConfigModel? model) {
|
||||
if (model == null) {
|
||||
return '选择模型';
|
||||
}
|
||||
final namePart = model.alias.isNotEmpty ? model.alias : model.modelName;
|
||||
return widget.compact ? namePart : '${model.provider}/$namePart';
|
||||
}
|
||||
|
||||
String _getModelDisplayName(UserAIModelConfigModel? model) {
|
||||
if (model == null) {
|
||||
return '请选择模型';
|
||||
}
|
||||
final namePart = model.alias.isNotEmpty ? model.alias : model.modelName;
|
||||
return namePart;
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型标签数据类
|
||||
class ModelTag {
|
||||
const ModelTag({
|
||||
required this.label,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final ModelTagType type;
|
||||
}
|
||||
|
||||
/// 模型标签类型枚举
|
||||
enum ModelTagType {
|
||||
free, // 免费
|
||||
premium, // 专业版
|
||||
beta, // 测试版
|
||||
custom, // 自定义
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'custom_text_editor.dart';
|
||||
|
||||
/// 指令预设选项
|
||||
class InstructionPreset {
|
||||
/// 构造函数
|
||||
const InstructionPreset({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.description,
|
||||
});
|
||||
|
||||
/// 唯一标识
|
||||
final String id;
|
||||
|
||||
/// 显示标题
|
||||
final String title;
|
||||
|
||||
/// 指令内容
|
||||
final String content;
|
||||
|
||||
/// 描述
|
||||
final String? description;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is InstructionPreset &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
/// 多选指令预设组件
|
||||
/// 类似于HTML中的多选下拉框,支持选择多个预设
|
||||
/// 选中的预设以badges/chips形式显示
|
||||
class MultiSelectInstructionsWithPresets extends StatefulWidget {
|
||||
/// 构造函数
|
||||
const MultiSelectInstructionsWithPresets({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.presets = const [],
|
||||
this.placeholder = 'e.g. You are a...',
|
||||
this.dropdownPlaceholder = 'Select Instructions...',
|
||||
this.onExpand,
|
||||
this.onCopy,
|
||||
this.onSelectionChanged,
|
||||
});
|
||||
|
||||
/// 文本控制器
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// 预设选项列表
|
||||
final List<InstructionPreset> presets;
|
||||
|
||||
/// 输入框占位符
|
||||
final String placeholder;
|
||||
|
||||
/// 下拉框占位符
|
||||
final String dropdownPlaceholder;
|
||||
|
||||
/// 展开回调
|
||||
final VoidCallback? onExpand;
|
||||
|
||||
/// 复制回调
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
/// 选择改变回调
|
||||
final ValueChanged<List<InstructionPreset>>? onSelectionChanged;
|
||||
|
||||
@override
|
||||
State<MultiSelectInstructionsWithPresets> createState() => _MultiSelectInstructionsWithPresetsState();
|
||||
}
|
||||
|
||||
class _MultiSelectInstructionsWithPresetsState extends State<MultiSelectInstructionsWithPresets> {
|
||||
final Set<InstructionPreset> _selectedPresets = <InstructionPreset>{};
|
||||
OverlayEntry? _overlayEntry;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
final GlobalKey _dropdownKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 第一行:多选下拉框
|
||||
if (widget.presets.isNotEmpty) ...[
|
||||
_buildMultiSelectDropdown(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// 第二行:文本编辑器
|
||||
CustomTextEditor(
|
||||
controller: widget.controller,
|
||||
placeholder: widget.placeholder,
|
||||
onExpand: widget.onExpand,
|
||||
onCopy: widget.onCopy,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建多选下拉框
|
||||
Widget _buildMultiSelectDropdown() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: GestureDetector(
|
||||
key: _dropdownKey,
|
||||
onTap: _toggleDropdown,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选中的badges区域
|
||||
Expanded(
|
||||
child: _selectedPresets.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
widget.dropdownPlaceholder,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 4),
|
||||
..._selectedPresets.map((preset) => _buildPresetBadge(preset)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 下拉箭头
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设badge
|
||||
Widget _buildPresetBadge(InstructionPreset preset) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? const Color(0xFF3A3A3A).withOpacity(0.8)
|
||||
: const Color(0xFFF4F4F5),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
preset.title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? const Color(0xFFA1A1AA)
|
||||
: const Color(0xFF52525B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _removePreset(preset),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 12,
|
||||
color: isDark
|
||||
? const Color(0xFFA1A1AA)
|
||||
: const Color(0xFF52525B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换下拉菜单显示状态
|
||||
void _toggleDropdown() {
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
} else {
|
||||
_showOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示下拉菜单覆盖层
|
||||
void _showOverlay() {
|
||||
final RenderBox? renderBox = _dropdownKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final size = renderBox.size;
|
||||
final overlay = Overlay.of(context);
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
// 透明背景,点击关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _removeOverlay,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// 下拉菜单内容
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, size.height + 4),
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Container(
|
||||
width: size.width,
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: _buildDropdownContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
/// 移除覆盖层
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
/// 构建下拉菜单内容
|
||||
Widget _buildDropdownContent() {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.presets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = widget.presets[index];
|
||||
final isSelected = _selectedPresets.contains(preset);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _togglePreset(preset),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 复选框
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: (isDark ? WebTheme.darkGrey500 : WebTheme.grey400),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 预设信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey900,
|
||||
),
|
||||
),
|
||||
if (preset.description != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
preset.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换预设选择状态
|
||||
void _togglePreset(InstructionPreset preset) {
|
||||
setState(() {
|
||||
if (_selectedPresets.contains(preset)) {
|
||||
_selectedPresets.remove(preset);
|
||||
} else {
|
||||
_selectedPresets.add(preset);
|
||||
}
|
||||
});
|
||||
|
||||
_updateInstructions();
|
||||
widget.onSelectionChanged?.call(_selectedPresets.toList());
|
||||
}
|
||||
|
||||
/// 移除预设
|
||||
void _removePreset(InstructionPreset preset) {
|
||||
setState(() {
|
||||
_selectedPresets.remove(preset);
|
||||
});
|
||||
|
||||
_updateInstructions();
|
||||
widget.onSelectionChanged?.call(_selectedPresets.toList());
|
||||
}
|
||||
|
||||
/// 更新指令文本
|
||||
void _updateInstructions() {
|
||||
if (widget.controller != null && _selectedPresets.isNotEmpty) {
|
||||
final contents = _selectedPresets.map((preset) => preset.content).toList();
|
||||
final newText = contents.join('\n\n');
|
||||
|
||||
// 只有当前文本为空或者只包含预设内容时才更新
|
||||
final currentText = widget.controller!.text.trim();
|
||||
if (currentText.isEmpty || _isOnlyPresetContent(currentText)) {
|
||||
widget.controller!.text = newText;
|
||||
} else {
|
||||
// 如果有自定义内容,追加到末尾
|
||||
widget.controller!.text = '$currentText\n\n$newText';
|
||||
}
|
||||
} else if (_selectedPresets.isEmpty && widget.controller != null) {
|
||||
// 如果没有选中任何预设,检查是否只有预设内容,如果是则清空
|
||||
final currentText = widget.controller!.text.trim();
|
||||
if (_isOnlyPresetContent(currentText)) {
|
||||
widget.controller!.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查当前文本是否只包含预设内容
|
||||
bool _isOnlyPresetContent(String text) {
|
||||
if (text.isEmpty) return true;
|
||||
|
||||
// 这里可以实现更复杂的逻辑来检测是否只包含预设内容
|
||||
// 暂时简化处理
|
||||
for (final preset in widget.presets) {
|
||||
if (text.contains(preset.content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
115
AINoval/lib/widgets/common/notice_ticker.dart
Normal file
115
AINoval/lib/widgets/common/notice_ticker.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 公告轮播组件:
|
||||
/// - 支持自动循环播放
|
||||
/// - 鼠标悬停暂停
|
||||
/// - 文本可选择复制
|
||||
/// - 可手动添加消息
|
||||
class NoticeTicker extends StatefulWidget {
|
||||
final List<String>? initialMessages;
|
||||
final Duration interval;
|
||||
final TextStyle? textStyle;
|
||||
final bool allowAdd;
|
||||
|
||||
const NoticeTicker({
|
||||
super.key,
|
||||
this.initialMessages,
|
||||
this.interval = const Duration(seconds: 4),
|
||||
this.textStyle,
|
||||
this.allowAdd = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NoticeTicker> createState() => _NoticeTickerState();
|
||||
}
|
||||
|
||||
class _NoticeTickerState extends State<NoticeTicker> {
|
||||
late List<String> _messages;
|
||||
int _currentIndex = 0;
|
||||
Timer? _timer;
|
||||
bool _isHovering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messages = (widget.initialMessages == null || widget.initialMessages!.isEmpty)
|
||||
? <String>[
|
||||
'当前小说网站属于测试状态,欢迎大家加入qq群1062403092',
|
||||
'如果有报错和bug或者改进建议,欢迎大家在群里反馈'
|
||||
]
|
||||
: List<String>.from(widget.initialMessages!);
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
if (_messages.length <= 1) return;
|
||||
_timer = Timer.periodic(widget.interval, (_) {
|
||||
if (!_isHovering && mounted) {
|
||||
setState(() {
|
||||
_currentIndex = (_currentIndex + 1) % _messages.length;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = widget.textStyle ?? TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
);
|
||||
|
||||
final current = _messages.isNotEmpty ? _messages[_currentIndex] : '';
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
// 文本区域:悬停暂停 + 可复制
|
||||
Expanded(
|
||||
child: MouseRegion(
|
||||
onEnter: (_) {
|
||||
setState(() => _isHovering = true);
|
||||
},
|
||||
onExit: (_) {
|
||||
setState(() => _isHovering = false);
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
transitionBuilder: (child, animation) {
|
||||
// 轻微滑动+淡入
|
||||
final offset = Tween<Offset>(begin: const Offset(0.1, 0), end: Offset.zero).animate(animation);
|
||||
return ClipRect(
|
||||
child: SlideTransition(position: offset, child: FadeTransition(opacity: animation, child: child)),
|
||||
);
|
||||
},
|
||||
child: SelectableText(
|
||||
current,
|
||||
key: ValueKey<int>(_currentIndex),
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.left,
|
||||
toolbarOptions: const ToolbarOptions(copy: true, selectAll: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
480
AINoval/lib/widgets/common/novel_card.dart
Normal file
480
AINoval/lib/widgets/common/novel_card.dart
Normal file
@@ -0,0 +1,480 @@
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Novel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String category;
|
||||
final int wordCount;
|
||||
final String lastUpdated;
|
||||
final String status; // 草稿 | 连载中 | 已完结
|
||||
final int views;
|
||||
final String? coverImage;
|
||||
final double? rating;
|
||||
|
||||
Novel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.category,
|
||||
required this.wordCount,
|
||||
required this.lastUpdated,
|
||||
required this.status,
|
||||
required this.views,
|
||||
this.coverImage,
|
||||
this.rating,
|
||||
});
|
||||
}
|
||||
|
||||
class NovelCard extends StatefulWidget {
|
||||
final Novel novel;
|
||||
final VoidCallback? onContinueWriting;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onShare;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const NovelCard({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.onContinueWriting,
|
||||
this.onEdit,
|
||||
this.onShare,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelCard> createState() => _NovelCardState();
|
||||
}
|
||||
|
||||
class _NovelCardState extends State<NovelCard> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
case '连载中':
|
||||
return Colors.blue.shade600;
|
||||
case '已完结':
|
||||
return Colors.green.shade600;
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusBackgroundColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
case '连载中':
|
||||
return Colors.blue.shade100.withOpacity(isDark ? 0.2 : 1.0);
|
||||
case '已完结':
|
||||
return Colors.green.shade100.withOpacity(isDark ? 0.2 : 1.0);
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transform: Matrix4.identity()
|
||||
..scale(_isHovered ? 1.02 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? (isDark ? WebTheme.darkGrey500 : WebTheme.grey500)
|
||||
: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: _isHovered ? [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
] : [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cover Image Area
|
||||
AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDark ? WebTheme.darkGrey300 : WebTheme.grey300).withOpacity(0.2),
|
||||
(isDark ? WebTheme.darkGrey200 : WebTheme.grey200).withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: widget.novel.coverImage != null
|
||||
? Image.network(
|
||||
widget.novel.coverImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(context),
|
||||
)
|
||||
: _buildPlaceholder(context),
|
||||
),
|
||||
// Status Badge
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusBackgroundColor(widget.novel.status, context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.novel.status,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getStatusColor(widget.novel.status, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// More Options Button
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Material(
|
||||
color: (isDark ? WebTheme.darkGrey100 : WebTheme.white).withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 16,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 16, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('编辑'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.share, size: 16, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('分享'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 16, color: WebTheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Text('删除', style: TextStyle(color: WebTheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
widget.onEdit?.call();
|
||||
break;
|
||||
case 'share':
|
||||
widget.onShare?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
widget.onDelete?.call();
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content Area
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Description
|
||||
Text(
|
||||
widget.novel.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Category and Rating
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.novel.category,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.novel.rating != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 14,
|
||||
color: Colors.amber.shade600,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
widget.novel.rating!.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Footer
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
// Stats
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${widget.novel.wordCount.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
)}字',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.novel.views.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.novel.lastUpdated,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Continue Writing Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 32,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onContinueWriting,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _isHovered
|
||||
? WebTheme.white
|
||||
: WebTheme.getTextColor(context),
|
||||
backgroundColor: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: Colors.transparent,
|
||||
side: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
child: const Text(
|
||||
'继续创作',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
WebTheme.getSecondaryColor(context).withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.menu_book,
|
||||
size: 48,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
480
AINoval/lib/widgets/common/novel_card_widget.dart
Normal file
480
AINoval/lib/widgets/common/novel_card_widget.dart
Normal file
@@ -0,0 +1,480 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
class NovelInfo {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final String category;
|
||||
final int wordCount;
|
||||
final String lastUpdated;
|
||||
final String status; // 草稿 | 连载中 | 已完结
|
||||
final int views;
|
||||
final String? coverImage;
|
||||
final double? rating;
|
||||
|
||||
NovelInfo({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.category,
|
||||
required this.wordCount,
|
||||
required this.lastUpdated,
|
||||
required this.status,
|
||||
required this.views,
|
||||
this.coverImage,
|
||||
this.rating,
|
||||
});
|
||||
}
|
||||
|
||||
class NovelCardWidget extends StatefulWidget {
|
||||
final NovelInfo novel;
|
||||
final VoidCallback? onContinueWriting;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onShare;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
const NovelCardWidget({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
this.onContinueWriting,
|
||||
this.onEdit,
|
||||
this.onShare,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NovelCardWidget> createState() => _NovelCardWidgetState();
|
||||
}
|
||||
|
||||
class _NovelCardWidgetState extends State<NovelCardWidget> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color _getStatusColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
case '连载中':
|
||||
return Colors.blue.shade600;
|
||||
case '已完结':
|
||||
return Colors.green.shade600;
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey400 : WebTheme.grey400;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getStatusBackgroundColor(String status, BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
switch (status) {
|
||||
case '草稿':
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
case '连载中':
|
||||
return Colors.blue.shade100.withOpacity(isDark ? 0.2 : 1.0);
|
||||
case '已完结':
|
||||
return Colors.green.shade100.withOpacity(isDark ? 0.2 : 1.0);
|
||||
default:
|
||||
return isDark ? WebTheme.darkGrey200 : WebTheme.grey200;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transform: Matrix4.identity()
|
||||
..scale(_isHovered ? 1.02 : 1.0),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getCardColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? (isDark ? WebTheme.darkGrey500 : WebTheme.grey500)
|
||||
: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: _isHovered ? [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
] : [
|
||||
BoxShadow(
|
||||
color: WebTheme.getShadowColor(context, opacity: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Cover Image Area
|
||||
AspectRatio(
|
||||
aspectRatio: 4 / 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDark ? WebTheme.darkGrey300 : WebTheme.grey300).withOpacity(0.2),
|
||||
(isDark ? WebTheme.darkGrey200 : WebTheme.grey200).withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: widget.novel.coverImage != null
|
||||
? Image.network(
|
||||
widget.novel.coverImage!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _buildPlaceholder(context),
|
||||
)
|
||||
: _buildPlaceholder(context),
|
||||
),
|
||||
// Status Badge
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusBackgroundColor(widget.novel.status, context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.novel.status,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getStatusColor(widget.novel.status, context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// More Options Button
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Material(
|
||||
color: (isDark ? WebTheme.darkGrey100 : WebTheme.white).withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_horiz,
|
||||
size: 16,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 16, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('编辑'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.share, size: 16, color: WebTheme.getTextColor(context, isPrimary: false)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('分享'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 16, color: WebTheme.error),
|
||||
const SizedBox(width: 8),
|
||||
Text('删除', style: TextStyle(color: WebTheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
widget.onEdit?.call();
|
||||
break;
|
||||
case 'share':
|
||||
widget.onShare?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
widget.onDelete?.call();
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content Area
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
widget.novel.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Description
|
||||
Text(
|
||||
widget.novel.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Category and Rating
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.novel.category,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.novel.rating != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 14,
|
||||
color: Colors.amber.shade600,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
widget.novel.rating!.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Footer
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
// Stats
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.menu_book,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${widget.novel.wordCount.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
)}字',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(
|
||||
Icons.visibility,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.novel.views.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.novel.lastUpdated,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Continue Writing Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 32,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onContinueWriting,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _isHovered
|
||||
? WebTheme.white
|
||||
: WebTheme.getTextColor(context),
|
||||
backgroundColor: _isHovered
|
||||
? WebTheme.getPrimaryColor(context)
|
||||
: Colors.transparent,
|
||||
side: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
child: const Text(
|
||||
'继续创作',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
WebTheme.getSecondaryColor(context).withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.menu_book,
|
||||
size: 48,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
393
AINoval/lib/widgets/common/permission_guard.dart
Normal file
393
AINoval/lib/widgets/common/permission_guard.dart
Normal file
@@ -0,0 +1,393 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../services/permission_service.dart';
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
/// 权限守卫小部件
|
||||
/// 根据用户权限显示或隐藏内容
|
||||
class PermissionGuard extends StatefulWidget {
|
||||
/// 需要的权限
|
||||
final String? permission;
|
||||
|
||||
/// 需要的多个权限
|
||||
final List<String>? permissions;
|
||||
|
||||
/// 多权限检查模式:true为需要全部权限,false为需要任一权限
|
||||
final bool requireAll;
|
||||
|
||||
/// 功能名称(用于功能级权限检查)
|
||||
final String? feature;
|
||||
|
||||
/// 有权限时显示的内容
|
||||
final Widget child;
|
||||
|
||||
/// 无权限时显示的内容
|
||||
final Widget? fallback;
|
||||
|
||||
/// 是否显示加载状态
|
||||
final bool showLoading;
|
||||
|
||||
/// 加载状态的小部件
|
||||
final Widget? loadingWidget;
|
||||
|
||||
/// 权限检查失败时的回调
|
||||
final VoidCallback? onPermissionDenied;
|
||||
|
||||
const PermissionGuard({
|
||||
Key? key,
|
||||
this.permission,
|
||||
this.permissions,
|
||||
this.requireAll = false,
|
||||
this.feature,
|
||||
required this.child,
|
||||
this.fallback,
|
||||
this.showLoading = true,
|
||||
this.loadingWidget,
|
||||
this.onPermissionDenied,
|
||||
}) : assert(
|
||||
permission != null || permissions != null || feature != null,
|
||||
'Must provide either permission, permissions, or feature',
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
/// 创建单权限守卫
|
||||
const PermissionGuard.permission(
|
||||
String permission, {
|
||||
Key? key,
|
||||
required Widget child,
|
||||
Widget? fallback,
|
||||
bool showLoading = true,
|
||||
Widget? loadingWidget,
|
||||
VoidCallback? onPermissionDenied,
|
||||
}) : this(
|
||||
key: key,
|
||||
permission: permission,
|
||||
child: child,
|
||||
fallback: fallback,
|
||||
showLoading: showLoading,
|
||||
loadingWidget: loadingWidget,
|
||||
onPermissionDenied: onPermissionDenied,
|
||||
);
|
||||
|
||||
/// 创建多权限守卫
|
||||
const PermissionGuard.permissions(
|
||||
List<String> permissions, {
|
||||
Key? key,
|
||||
bool requireAll = false,
|
||||
required Widget child,
|
||||
Widget? fallback,
|
||||
bool showLoading = true,
|
||||
Widget? loadingWidget,
|
||||
VoidCallback? onPermissionDenied,
|
||||
}) : this(
|
||||
key: key,
|
||||
permissions: permissions,
|
||||
requireAll: requireAll,
|
||||
child: child,
|
||||
fallback: fallback,
|
||||
showLoading: showLoading,
|
||||
loadingWidget: loadingWidget,
|
||||
onPermissionDenied: onPermissionDenied,
|
||||
);
|
||||
|
||||
/// 创建功能权限守卫
|
||||
const PermissionGuard.feature(
|
||||
String feature, {
|
||||
Key? key,
|
||||
required Widget child,
|
||||
Widget? fallback,
|
||||
bool showLoading = true,
|
||||
Widget? loadingWidget,
|
||||
VoidCallback? onPermissionDenied,
|
||||
}) : this(
|
||||
key: key,
|
||||
feature: feature,
|
||||
child: child,
|
||||
fallback: fallback,
|
||||
showLoading: showLoading,
|
||||
loadingWidget: loadingWidget,
|
||||
onPermissionDenied: onPermissionDenied,
|
||||
);
|
||||
|
||||
/// 创建管理员权限守卫
|
||||
const PermissionGuard.admin({
|
||||
Key? key,
|
||||
required Widget child,
|
||||
Widget? fallback,
|
||||
bool showLoading = true,
|
||||
Widget? loadingWidget,
|
||||
VoidCallback? onPermissionDenied,
|
||||
}) : this(
|
||||
key: key,
|
||||
permission: 'ADMIN', // 特殊标识符,在检查时会调用isAdmin()
|
||||
child: child,
|
||||
fallback: fallback,
|
||||
showLoading: showLoading,
|
||||
loadingWidget: loadingWidget,
|
||||
onPermissionDenied: onPermissionDenied,
|
||||
);
|
||||
|
||||
/// 创建超级管理员权限守卫
|
||||
const PermissionGuard.superAdmin({
|
||||
Key? key,
|
||||
required Widget child,
|
||||
Widget? fallback,
|
||||
bool showLoading = true,
|
||||
Widget? loadingWidget,
|
||||
VoidCallback? onPermissionDenied,
|
||||
}) : this(
|
||||
key: key,
|
||||
permission: 'SUPER_ADMIN', // 特殊标识符,在检查时会调用isSuperAdmin()
|
||||
child: child,
|
||||
fallback: fallback,
|
||||
showLoading: showLoading,
|
||||
loadingWidget: loadingWidget,
|
||||
onPermissionDenied: onPermissionDenied,
|
||||
);
|
||||
|
||||
@override
|
||||
State<PermissionGuard> createState() => _PermissionGuardState();
|
||||
}
|
||||
|
||||
class _PermissionGuardState extends State<PermissionGuard> {
|
||||
final PermissionService _permissionService = PermissionService();
|
||||
bool _isLoading = true;
|
||||
bool _hasPermission = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PermissionGuard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 如果权限参数发生变化,重新检查权限
|
||||
if (oldWidget.permission != widget.permission ||
|
||||
oldWidget.permissions != widget.permissions ||
|
||||
oldWidget.feature != widget.feature ||
|
||||
oldWidget.requireAll != widget.requireAll) {
|
||||
_checkPermission();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPermission() async {
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
bool hasPermission = false;
|
||||
|
||||
if (widget.feature != null) {
|
||||
// 功能级权限检查
|
||||
hasPermission = await _permissionService.canAccessFeature(widget.feature!);
|
||||
} else if (widget.permission != null) {
|
||||
// 单权限检查
|
||||
if (widget.permission == 'ADMIN') {
|
||||
hasPermission = await _permissionService.isAdmin();
|
||||
} else if (widget.permission == 'SUPER_ADMIN') {
|
||||
hasPermission = await _permissionService.isSuperAdmin();
|
||||
} else {
|
||||
hasPermission = await _permissionService.hasPermission(widget.permission!);
|
||||
}
|
||||
} else if (widget.permissions != null) {
|
||||
// 多权限检查
|
||||
if (widget.requireAll) {
|
||||
hasPermission = await _permissionService.hasAllPermissions(widget.permissions!);
|
||||
} else {
|
||||
hasPermission = await _permissionService.hasAnyPermission(widget.permissions!);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasPermission = hasPermission;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 如果没有权限,调用回调
|
||||
if (!hasPermission && widget.onPermissionDenied != null) {
|
||||
widget.onPermissionDenied!();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('PermissionGuard', '权限检查失败', e);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
_hasPermission = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 显示加载状态
|
||||
if (_isLoading && widget.showLoading) {
|
||||
return widget.loadingWidget ??
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示错误状态
|
||||
if (_error != null) {
|
||||
return widget.fallback ??
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'权限检查失败',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 有权限时显示内容
|
||||
if (_hasPermission) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
// 无权限时显示备用内容
|
||||
return widget.fallback ?? const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限装饰器小部件
|
||||
/// 为按钮等交互元素提供权限控制
|
||||
class PermissionWrapper extends StatelessWidget {
|
||||
/// 需要的权限
|
||||
final String? permission;
|
||||
|
||||
/// 需要的多个权限
|
||||
final List<String>? permissions;
|
||||
|
||||
/// 多权限检查模式
|
||||
final bool requireAll;
|
||||
|
||||
/// 功能名称
|
||||
final String? feature;
|
||||
|
||||
/// 子组件
|
||||
final Widget child;
|
||||
|
||||
/// 无权限时是否禁用
|
||||
final bool disableWhenNoPermission;
|
||||
|
||||
/// 无权限时的提示信息
|
||||
final String? deniedMessage;
|
||||
|
||||
const PermissionWrapper({
|
||||
Key? key,
|
||||
this.permission,
|
||||
this.permissions,
|
||||
this.requireAll = false,
|
||||
this.feature,
|
||||
required this.child,
|
||||
this.disableWhenNoPermission = true,
|
||||
this.deniedMessage,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PermissionGuard(
|
||||
permission: permission,
|
||||
permissions: permissions,
|
||||
requireAll: requireAll,
|
||||
feature: feature,
|
||||
child: child,
|
||||
fallback: disableWhenNoPermission
|
||||
? _buildDisabledChild(context)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDisabledChild(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: deniedMessage ?? '权限不足',
|
||||
child: IgnorePointer(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限检查的Future Builder
|
||||
class PermissionFutureBuilder<T> extends StatelessWidget {
|
||||
/// 权限检查函数
|
||||
final Future<bool> Function() permissionChecker;
|
||||
|
||||
/// 有权限时的构建器
|
||||
final Widget Function(BuildContext context) builder;
|
||||
|
||||
/// 无权限时的构建器
|
||||
final Widget Function(BuildContext context)? fallbackBuilder;
|
||||
|
||||
/// 加载状态构建器
|
||||
final Widget Function(BuildContext context)? loadingBuilder;
|
||||
|
||||
const PermissionFutureBuilder({
|
||||
Key? key,
|
||||
required this.permissionChecker,
|
||||
required this.builder,
|
||||
this.fallbackBuilder,
|
||||
this.loadingBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<bool>(
|
||||
future: permissionChecker(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return loadingBuilder?.call(context) ??
|
||||
const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return fallbackBuilder?.call(context) ??
|
||||
Center(
|
||||
child: Text(
|
||||
'权限检查失败',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.data == true) {
|
||||
return builder(context);
|
||||
}
|
||||
|
||||
return fallbackBuilder?.call(context) ?? const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
630
AINoval/lib/widgets/common/preset_dropdown.dart
Normal file
630
AINoval/lib/widgets/common/preset_dropdown.dart
Normal file
@@ -0,0 +1,630 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/prompt_models.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 预设下拉框组件
|
||||
class PresetDropdown extends StatefulWidget {
|
||||
/// 当前AI功能类型
|
||||
final AIRequestType requestType;
|
||||
|
||||
/// 当前表单数据
|
||||
final UniversalAIRequest? currentRequest;
|
||||
|
||||
/// 预设选择回调
|
||||
final Function(AIPromptPreset preset)? onPresetSelected;
|
||||
|
||||
/// 预设创建回调
|
||||
final Function(AIPromptPreset preset)? onPresetCreated;
|
||||
|
||||
/// 预设更新回调
|
||||
final Function(AIPromptPreset preset)? onPresetUpdated;
|
||||
|
||||
const PresetDropdown({
|
||||
super.key,
|
||||
required this.requestType,
|
||||
this.currentRequest,
|
||||
this.onPresetSelected,
|
||||
this.onPresetCreated,
|
||||
this.onPresetUpdated,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PresetDropdown> createState() => _PresetDropdownState();
|
||||
}
|
||||
|
||||
class _PresetDropdownState extends State<PresetDropdown> {
|
||||
final AIPresetService _presetService = AIPresetService();
|
||||
final String _tag = 'PresetDropdown';
|
||||
|
||||
OverlayEntry? _overlayEntry;
|
||||
final GlobalKey _buttonKey = GlobalKey();
|
||||
|
||||
List<AIPromptPreset> _recentPresets = [];
|
||||
List<AIPromptPreset> _favoritePresets = [];
|
||||
List<AIPromptPreset> _recommendedPresets = [];
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPresets();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载预设数据
|
||||
Future<void> _loadPresets() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final featureType = _getFeatureTypeString();
|
||||
|
||||
// 使用新的统一接口获取功能预设列表
|
||||
final presetListResponse = await _presetService.getFeaturePresetList(featureType);
|
||||
|
||||
setState(() {
|
||||
_recentPresets = presetListResponse.recentUsed.map((item) => item.preset).toList();
|
||||
_favoritePresets = presetListResponse.favorites.map((item) => item.preset).toList();
|
||||
_recommendedPresets = presetListResponse.recommended.map((item) => item.preset).toList();
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
AppLogger.d(_tag, '预设数据加载完成: 最近${_recentPresets.length}个, 收藏${_favoritePresets.length}个, 推荐${_recommendedPresets.length}个');
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载预设数据失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取功能类型字符串
|
||||
String _getFeatureTypeString() {
|
||||
// 🚀 映射AIRequestType到AIFeatureType,然后使用标准方法
|
||||
final aiFeatureType = _mapRequestTypeToFeatureType(widget.requestType);
|
||||
return aiFeatureType.toApiString();
|
||||
}
|
||||
|
||||
/// 映射AIRequestType到AIFeatureType
|
||||
AIFeatureType _mapRequestTypeToFeatureType(AIRequestType requestType) {
|
||||
switch (requestType) {
|
||||
case AIRequestType.expansion:
|
||||
return AIFeatureType.textExpansion;
|
||||
case AIRequestType.generation:
|
||||
return AIFeatureType.novelGeneration;
|
||||
case AIRequestType.refactor:
|
||||
return AIFeatureType.textRefactor;
|
||||
case AIRequestType.summary:
|
||||
return AIFeatureType.textSummary;
|
||||
case AIRequestType.sceneSummary:
|
||||
return AIFeatureType.sceneToSummary;
|
||||
case AIRequestType.chat:
|
||||
return AIFeatureType.aiChat;
|
||||
case AIRequestType.sceneBeat:
|
||||
return AIFeatureType.sceneBeatGeneration;
|
||||
case AIRequestType.novelCompose:
|
||||
return AIFeatureType.novelCompose;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: _buttonKey,
|
||||
onTap: _toggleDropdown,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey100
|
||||
: WebTheme.white,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).brightness == Brightness.dark
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Presets',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换下拉框显示/隐藏
|
||||
void _toggleDropdown() {
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
} else {
|
||||
_showDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示下拉框
|
||||
void _showDropdown() {
|
||||
final RenderBox? renderBox = _buttonKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final Offset position = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
// 透明背景,点击关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _removeOverlay,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
// 下拉框内容
|
||||
Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy + size.height + 4,
|
||||
width: 280,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shadowColor: Colors.black.withOpacity(0.15),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: _buildDropdownContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
/// 移除下拉框
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
/// 构建下拉框内容
|
||||
Widget _buildDropdownContent() {
|
||||
if (_isLoading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 头部操作
|
||||
_buildHeaderActions(),
|
||||
|
||||
if (_favoritePresets.isNotEmpty || _recentPresets.isNotEmpty || _recommendedPresets.isNotEmpty)
|
||||
const Divider(height: 1),
|
||||
|
||||
// 预设列表
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 收藏预设
|
||||
if (_favoritePresets.isNotEmpty) ...[
|
||||
_buildPresetSection('收藏预设', _favoritePresets),
|
||||
if (_recentPresets.isNotEmpty || _recommendedPresets.isNotEmpty) const Divider(height: 1),
|
||||
],
|
||||
|
||||
// 最近使用
|
||||
if (_recentPresets.isNotEmpty) ...[
|
||||
_buildPresetSection('最近使用', _recentPresets),
|
||||
if (_recommendedPresets.isNotEmpty) const Divider(height: 1),
|
||||
],
|
||||
|
||||
// 推荐预设
|
||||
if (_recommendedPresets.isNotEmpty)
|
||||
_buildPresetSection('推荐预设', _recommendedPresets),
|
||||
|
||||
// 空状态
|
||||
if (_favoritePresets.isEmpty && _recentPresets.isEmpty && _recommendedPresets.isEmpty)
|
||||
_buildEmptyState(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// 底部操作
|
||||
_buildFooterActions(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头部操作
|
||||
Widget _buildHeaderActions() {
|
||||
return Column(
|
||||
children: [
|
||||
// New Preset
|
||||
_buildActionItem(
|
||||
icon: Icons.add,
|
||||
title: 'New Preset',
|
||||
subtitle: null,
|
||||
onTap: _handleNewPreset,
|
||||
),
|
||||
|
||||
// Update Preset (仅当有当前请求时显示)
|
||||
if (widget.currentRequest != null)
|
||||
_buildActionItem(
|
||||
icon: Icons.edit_outlined,
|
||||
title: 'Update Preset',
|
||||
subtitle: null,
|
||||
onTap: _handleUpdatePreset,
|
||||
enabled: false, // 暂时禁用,需要选择现有预设
|
||||
),
|
||||
|
||||
// Create Preset
|
||||
if (widget.currentRequest != null)
|
||||
_buildActionItem(
|
||||
icon: Icons.bookmark_add,
|
||||
title: 'Create Preset',
|
||||
subtitle: null,
|
||||
onTap: _handleCreatePreset,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作
|
||||
Widget _buildFooterActions() {
|
||||
return _buildActionItem(
|
||||
icon: Icons.settings,
|
||||
title: 'Manage Presets',
|
||||
subtitle: null,
|
||||
onTap: _handleManagePresets,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建操作项
|
||||
Widget _buildActionItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
required VoidCallback onTap,
|
||||
bool enabled = true,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设分组
|
||||
Widget _buildPresetSection(String title, List<AIPromptPreset> presets) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
...presets.map((preset) => _buildPresetItem(preset)).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设项
|
||||
Widget _buildPresetItem(AIPromptPreset preset) {
|
||||
return InkWell(
|
||||
onTap: () => _handlePresetSelected(preset),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
// 收藏图标
|
||||
if (preset.isFavorite)
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
size: 14,
|
||||
color: Colors.red.shade400,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 14),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 预设信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
preset.presetName ?? '未命名预设',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (preset.presetDescription != null)
|
||||
Text(
|
||||
preset.presetDescription!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 使用次数
|
||||
if (preset.useCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${preset.useCount}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态
|
||||
Widget _buildEmptyState() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_outline,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无预设',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'创建第一个预设来快速重用配置',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 事件处理器
|
||||
void _handleNewPreset() {
|
||||
_removeOverlay();
|
||||
_showPresetNameDialog(isUpdate: false);
|
||||
}
|
||||
|
||||
void _handleUpdatePreset() {
|
||||
_removeOverlay();
|
||||
// TODO: 实现更新现有预设功能
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('更新预设功能即将推出')),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleCreatePreset() {
|
||||
_removeOverlay();
|
||||
_showPresetNameDialog(isUpdate: false);
|
||||
}
|
||||
|
||||
void _handleManagePresets() {
|
||||
_removeOverlay();
|
||||
// TODO: 导航到预设管理页面
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('预设管理页面即将推出')),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePresetSelected(AIPromptPreset preset) {
|
||||
_removeOverlay();
|
||||
widget.onPresetSelected?.call(preset);
|
||||
|
||||
// 记录预设使用(通过应用预设方法,它会自动记录使用)
|
||||
_presetService.applyPreset(preset.presetId).catchError((e) {
|
||||
AppLogger.w(_tag, '记录预设使用失败', e);
|
||||
return preset; // 返回原始预设对象
|
||||
});
|
||||
|
||||
AppLogger.i(_tag, '预设已选择: ${preset.presetName}');
|
||||
}
|
||||
|
||||
/// 显示预设名称输入对话框
|
||||
void _showPresetNameDialog({required bool isUpdate}) {
|
||||
final TextEditingController nameController = TextEditingController();
|
||||
final TextEditingController descController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isUpdate ? '更新预设' : '创建预设'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '预设名称',
|
||||
hintText: '输入预设名称',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: descController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '描述(可选)',
|
||||
hintText: '输入预设描述',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final name = nameController.text.trim();
|
||||
if (name.isNotEmpty) {
|
||||
Navigator.of(context).pop();
|
||||
_createPreset(name, descController.text.trim());
|
||||
}
|
||||
},
|
||||
child: Text(isUpdate ? '更新' : '创建'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建预设
|
||||
Future<void> _createPreset(String name, String description) async {
|
||||
if (widget.currentRequest == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法创建预设:缺少表单数据')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final request = CreatePresetRequest(
|
||||
presetName: name,
|
||||
presetDescription: description.isNotEmpty ? description : null,
|
||||
request: widget.currentRequest!,
|
||||
);
|
||||
|
||||
final preset = await _presetService.createPreset(request);
|
||||
|
||||
widget.onPresetCreated?.call(preset);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('预设 "$name" 创建成功')),
|
||||
);
|
||||
|
||||
// 重新加载预设列表
|
||||
_loadPresets();
|
||||
|
||||
AppLogger.i(_tag, '预设创建成功: $name');
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '创建预设失败', e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建预设失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
487
AINoval/lib/widgets/common/preset_dropdown_button.dart
Normal file
487
AINoval/lib/widgets/common/preset_dropdown_button.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
import 'package:ainoval/services/ai_preset_service.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 预设下拉框按钮组件
|
||||
/// 替换原有的预设按钮,提供下拉框选择预设的功能
|
||||
class PresetDropdownButton extends StatefulWidget {
|
||||
/// 构造函数
|
||||
const PresetDropdownButton({
|
||||
super.key,
|
||||
required this.featureType,
|
||||
this.currentPreset,
|
||||
this.onPresetSelected,
|
||||
this.onCreatePreset,
|
||||
this.onManagePresets,
|
||||
this.novelId,
|
||||
this.label = '预设',
|
||||
});
|
||||
|
||||
/// AI功能类型(用于过滤预设)
|
||||
final String featureType;
|
||||
|
||||
/// 当前选中的预设
|
||||
final AIPromptPreset? currentPreset;
|
||||
|
||||
/// 预设选择回调
|
||||
final ValueChanged<AIPromptPreset>? onPresetSelected;
|
||||
|
||||
/// 创建预设回调
|
||||
final VoidCallback? onCreatePreset;
|
||||
|
||||
/// 管理预设回调
|
||||
final VoidCallback? onManagePresets;
|
||||
|
||||
/// 小说ID(用于过滤预设)
|
||||
final String? novelId;
|
||||
|
||||
/// 按钮标签
|
||||
final String label;
|
||||
|
||||
@override
|
||||
State<PresetDropdownButton> createState() => _PresetDropdownButtonState();
|
||||
}
|
||||
|
||||
class _PresetDropdownButtonState extends State<PresetDropdownButton> {
|
||||
final String _tag = 'PresetDropdownButton';
|
||||
|
||||
List<AIPromptPreset> _recentPresets = [];
|
||||
List<AIPromptPreset> _favoritePresets = [];
|
||||
List<AIPromptPreset> _recommendedPresets = [];
|
||||
bool _isLoading = false;
|
||||
OverlayEntry? _overlayEntry;
|
||||
final LayerLink _layerLink = LayerLink();
|
||||
final GlobalKey _buttonKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPresets();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_removeOverlay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载预设数据
|
||||
Future<void> _loadPresets() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final presetService = AIPresetService();
|
||||
|
||||
// 使用新的统一接口获取功能预设列表
|
||||
final presetListResponse = await presetService.getFeaturePresetList(
|
||||
widget.featureType,
|
||||
novelId: widget.novelId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_recentPresets = presetListResponse.recentUsed.map((item) => item.preset).toList();
|
||||
_favoritePresets = presetListResponse.favorites.map((item) => item.preset).toList();
|
||||
_recommendedPresets = presetListResponse.recommended.map((item) => item.preset).toList();
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
AppLogger.d(_tag, '预设数据加载完成: 最近${_recentPresets.length}个, 收藏${_favoritePresets.length}个, 推荐${_recommendedPresets.length}个');
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
AppLogger.e(_tag, '加载预设数据失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示下拉菜单
|
||||
void _showDropdown() {
|
||||
if (_overlayEntry != null) {
|
||||
_removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayEntry = _createOverlayEntry();
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
/// 移除下拉菜单
|
||||
void _removeOverlay() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
/// 创建下拉菜单覆盖层
|
||||
OverlayEntry _createOverlayEntry() {
|
||||
final RenderBox renderBox = _buttonKey.currentContext?.findRenderObject() as RenderBox;
|
||||
final size = renderBox.size;
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => GestureDetector(
|
||||
onTap: _removeOverlay,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 透明背景,点击关闭
|
||||
Positioned.fill(
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
// 下拉菜单
|
||||
CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: Offset(0, size.height + 2),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 240, // 减小宽度使其更紧凑
|
||||
constraints: const BoxConstraints(maxHeight: 320), // 减小最大高度
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildDropdownContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建下拉菜单内容
|
||||
Widget _buildDropdownContent() {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 当前预设(如果有)
|
||||
if (widget.currentPreset != null) ...[
|
||||
_buildSectionHeader('当前预设'),
|
||||
_buildPresetItem(
|
||||
widget.currentPreset!,
|
||||
isSelected: true,
|
||||
showCheckmark: true,
|
||||
),
|
||||
_buildDivider(),
|
||||
],
|
||||
|
||||
// 最近使用预设
|
||||
if (_recentPresets.isNotEmpty) ...[
|
||||
_buildSectionHeader('最近使用'),
|
||||
..._recentPresets.take(3).map((preset) => _buildPresetItem(preset)), // 减少显示数量
|
||||
if (_favoritePresets.isNotEmpty || _recommendedPresets.isNotEmpty) _buildDivider(),
|
||||
],
|
||||
|
||||
// 收藏预设
|
||||
if (_favoritePresets.isNotEmpty) ...[
|
||||
_buildSectionHeader('收藏预设'),
|
||||
..._favoritePresets.take(3).map((preset) => _buildPresetItem(preset)), // 减少显示数量
|
||||
if (_recommendedPresets.isNotEmpty) _buildDivider(),
|
||||
],
|
||||
|
||||
// 推荐预设
|
||||
if (_recommendedPresets.isNotEmpty) ...[
|
||||
_buildSectionHeader('推荐预设'),
|
||||
..._recommendedPresets.take(3).map((preset) => _buildPresetItem(preset)), // 减少显示数量
|
||||
_buildDivider(),
|
||||
],
|
||||
|
||||
// 空状态
|
||||
if (_recentPresets.isEmpty && _favoritePresets.isEmpty && _recommendedPresets.isEmpty && widget.currentPreset == null) ...[
|
||||
_buildEmptyState(),
|
||||
_buildDivider(),
|
||||
],
|
||||
|
||||
// 操作按钮
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分组标题
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), // 减少内边距
|
||||
child: Text(
|
||||
title,
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建预设项
|
||||
Widget _buildPresetItem(
|
||||
AIPromptPreset preset, {
|
||||
bool isSelected = false,
|
||||
bool showCheckmark = false,
|
||||
}) {
|
||||
return WebTheme.getMaterialWrapper(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
_removeOverlay();
|
||||
widget.onPresetSelected?.call(preset);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), // 减少内边距
|
||||
child: Row(
|
||||
children: [
|
||||
// 预设信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible( // 使用 Flexible 而不是 Expanded 避免溢出
|
||||
child: Text(
|
||||
preset.displayName,
|
||||
style: WebTheme.bodySmall.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context, isPrimary: true)
|
||||
: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (preset.isFavorite) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 10,
|
||||
color: Colors.amber.shade600,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (preset.presetDescription != null && preset.presetDescription!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
preset.presetDescription!,
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 选中标记
|
||||
if (showCheckmark) ...[
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分割线
|
||||
Widget _buildDivider() {
|
||||
return Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey200
|
||||
: WebTheme.grey200,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建空状态
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16), // 减少内边距
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 24, // 减小图标尺寸
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'暂无预设',
|
||||
style: WebTheme.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'创建第一个预设来快速重用配置',
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButtons() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8), // 减少内边距
|
||||
child: Row(
|
||||
children: [
|
||||
// 创建预设按钮
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
_removeOverlay();
|
||||
widget.onCreatePreset?.call();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
size: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
label: Text(
|
||||
'创建',
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), // 减少内边距
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 4),
|
||||
|
||||
// 管理预设按钮
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
_removeOverlay();
|
||||
widget.onManagePresets?.call();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.settings,
|
||||
size: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
label: Text(
|
||||
'管理',
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), // 减少内边距
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 获取当前预设的显示名称,如果太长则截断
|
||||
String displayText = widget.currentPreset?.presetName ?? widget.label;
|
||||
if (displayText.length > 8) { // 限制显示长度避免溢出
|
||||
displayText = '${displayText.substring(0, 6)}...';
|
||||
}
|
||||
|
||||
return CompositedTransformTarget(
|
||||
link: _layerLink,
|
||||
child: WebTheme.getMaterialWrapper(
|
||||
child: InkWell(
|
||||
key: _buttonKey,
|
||||
onTap: _showDropdown,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), // 大幅减少内边距
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 60,
|
||||
maxWidth: 120, // 限制最大宽度避免溢出
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.tune,
|
||||
size: 14, // 减小图标尺寸
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible( // 使用 Flexible 而不是固定宽度
|
||||
child: Text(
|
||||
displayText,
|
||||
style: WebTheme.labelSmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 12, // 减小图标尺寸
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
AINoval/lib/widgets/common/preset_item_with_tags.dart
Normal file
194
AINoval/lib/widgets/common/preset_item_with_tags.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/preset_models.dart';
|
||||
|
||||
/// 带标签的预设项组件
|
||||
class PresetItemWithTags extends StatelessWidget {
|
||||
final PresetItemWithTag presetItem;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onFavoriteToggle;
|
||||
final bool showDescription;
|
||||
|
||||
const PresetItemWithTags({
|
||||
Key? key,
|
||||
required this.presetItem,
|
||||
this.onTap,
|
||||
this.onFavoriteToggle,
|
||||
this.showDescription = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final preset = presetItem.preset;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
preset.presetName ?? '未命名预设',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 收藏按钮
|
||||
if (onFavoriteToggle != null)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
preset.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: preset.isFavorite ? Colors.red : Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onFavoriteToggle,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// 标签行
|
||||
if (presetItem.getTags().isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: presetItem.getTags().map((tag) => _buildTag(context, tag)).toList(),
|
||||
),
|
||||
|
||||
// 描述
|
||||
if (showDescription && preset.presetDescription != null && preset.presetDescription!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
preset.presetDescription!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// 底部信息
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDateTime(preset.lastUsedAt ?? preset.updatedAt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (preset.useCount > 0) ...[
|
||||
Icon(
|
||||
Icons.trending_up,
|
||||
size: 14,
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${preset.useCount}次',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.textTheme.bodySmall?.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标签
|
||||
Widget _buildTag(BuildContext context, String tag) {
|
||||
Color? tagColor;
|
||||
IconData? tagIcon;
|
||||
|
||||
switch (tag) {
|
||||
case '收藏':
|
||||
tagColor = Colors.red;
|
||||
tagIcon = Icons.favorite;
|
||||
break;
|
||||
case '最近使用':
|
||||
tagColor = Colors.blue;
|
||||
tagIcon = Icons.access_time;
|
||||
break;
|
||||
case '推荐':
|
||||
tagColor = Colors.green;
|
||||
tagIcon = Icons.recommend;
|
||||
break;
|
||||
default:
|
||||
tagColor = Colors.grey;
|
||||
tagIcon = Icons.label;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: tagColor.withOpacity(0.1),
|
||||
border: Border.all(color: tagColor.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
tagIcon,
|
||||
size: 12,
|
||||
color: tagColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
tag,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: tagColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化时间
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
}
|
||||
1342
AINoval/lib/widgets/common/preset_quick_menu_refactored.dart
Normal file
1342
AINoval/lib/widgets/common/preset_quick_menu_refactored.dart
Normal file
File diff suppressed because it is too large
Load Diff
501
AINoval/lib/widgets/common/prompt_preview_widget.dart
Normal file
501
AINoval/lib/widgets/common/prompt_preview_widget.dart
Normal file
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_syntax_view/flutter_syntax_view.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/content_formatter.dart';
|
||||
|
||||
/// 提示词预览组件
|
||||
/// 用于显示AI请求的预览内容,使用固定宽度布局,根据内容决定长度
|
||||
class PromptPreviewWidget extends StatefulWidget {
|
||||
const PromptPreviewWidget({
|
||||
super.key,
|
||||
required this.previewResponse,
|
||||
this.onCopyToClipboard,
|
||||
this.showActions = true,
|
||||
this.fixedWidth = 680.0, // 固定宽度,可以根据需要调整
|
||||
});
|
||||
|
||||
/// 预览响应数据
|
||||
final UniversalAIPreviewResponse previewResponse;
|
||||
|
||||
/// 复制到剪贴板回调
|
||||
final VoidCallback? onCopyToClipboard;
|
||||
|
||||
/// 是否显示操作按钮
|
||||
final bool showActions;
|
||||
|
||||
/// 固定宽度
|
||||
final double fixedWidth;
|
||||
|
||||
@override
|
||||
State<PromptPreviewWidget> createState() => _PromptPreviewWidgetState();
|
||||
}
|
||||
|
||||
class _PromptPreviewWidgetState extends State<PromptPreviewWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
width: widget.fixedWidth,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(4), // 最小内边距,紧贴表单边缘
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 顶部统计和操作栏
|
||||
_buildHeaderActions(context, isDark),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 系统提示词部分
|
||||
if (widget.previewResponse.systemPrompt.isNotEmpty) ...[
|
||||
_buildPromptSection(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
title: '系统提示词',
|
||||
content: widget.previewResponse.systemPrompt,
|
||||
wordCount: widget.previewResponse.systemPromptWordCount,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// 用户提示词部分
|
||||
if (widget.previewResponse.userPrompt.isNotEmpty) ...[
|
||||
_buildPromptSection(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
title: '用户提示词',
|
||||
content: widget.previewResponse.userPrompt,
|
||||
wordCount: widget.previewResponse.userPromptWordCount,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// 上下文信息部分(如果有)
|
||||
if (widget.previewResponse.context != null && widget.previewResponse.context!.isNotEmpty) ...[
|
||||
_buildPromptSection(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
title: '上下文信息',
|
||||
content: widget.previewResponse.context!,
|
||||
wordCount: widget.previewResponse.contextWordCount,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部统计和操作栏
|
||||
Widget _buildHeaderActions(BuildContext context, bool isDark) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), // 减少内边距
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey50 : WebTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(4), // 减少圆角
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 预览图标
|
||||
Icon(
|
||||
Icons.preview_outlined,
|
||||
size: 14, // 减少图标大小
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
|
||||
Text(
|
||||
'提示词预览',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context, isPrimary: true),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13, // 减少字体大小
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 复制到剪贴板按钮
|
||||
if (widget.showActions) ...[
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
icon: Icons.content_copy_outlined,
|
||||
label: '复制',
|
||||
onPressed: () => _copyToClipboard(context, widget.previewResponse.preview),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 总字数统计
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), // 减少内边距
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(3), // 减少圆角
|
||||
),
|
||||
child: Text(
|
||||
'${widget.previewResponse.totalWordCount} 字',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 10, // 减少字体大小
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建提示词区块
|
||||
Widget _buildPromptSection({
|
||||
required BuildContext context,
|
||||
required bool isDark,
|
||||
required String title,
|
||||
required String content,
|
||||
required int wordCount,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 区块标题和操作
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), // 减少内边距
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey100,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4), // 减少圆角
|
||||
topRight: Radius.circular(4),
|
||||
),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: WebTheme.getTextColor(context, isPrimary: true),
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12, // 减少字体大小
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 字数统计
|
||||
Text(
|
||||
'$wordCount 字',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 10, // 减少字体大小
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 复制按钮
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
icon: Icons.content_copy_outlined,
|
||||
label: '复制',
|
||||
isSmall: true,
|
||||
onPressed: () => _copyToClipboard(context, content),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区域 - 固定宽度,根据内容决定高度
|
||||
_buildContentArea(context, isDark, content),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容区域
|
||||
Widget _buildContentArea(BuildContext context, bool isDark, String content) {
|
||||
// 计算内容行数来决定高度
|
||||
final lines = content.split('\n');
|
||||
final contentHeight = (lines.length * 18.0) + 16.0; // 每行18px高度 + 减少上下padding
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 50, // 减少最小高度
|
||||
maxHeight: contentHeight > 250 ? 250 : contentHeight, // 减少最大高度
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
right: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(4), // 减少圆角
|
||||
bottomRight: Radius.circular(4),
|
||||
),
|
||||
color: isDark ? WebTheme.darkGrey50 : WebTheme.white,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 行号区域
|
||||
Container(
|
||||
width: 35, // 减少宽度
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 50,
|
||||
maxHeight: contentHeight > 250 ? 250 : contentHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey50,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _buildLineNumbers(lines),
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 50,
|
||||
maxHeight: contentHeight > 250 ? 250 : contentHeight,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8), // 减少内边距
|
||||
child: SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 12, // 减少字体大小
|
||||
height: 1.4, // 调整行高
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey800,
|
||||
letterSpacing: 0.1, // 减少字符间距
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建行号
|
||||
Widget _buildLineNumbers(List<String> lines) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8), // 减少内边距
|
||||
child: Column(
|
||||
children: List.generate(lines.length, (index) {
|
||||
return Container(
|
||||
height: 16.8, // 匹配调整后的文本行高 (12 * 1.4)
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 9, // 减少字体大小
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建操作按钮
|
||||
Widget _buildActionButton({
|
||||
required BuildContext context,
|
||||
required bool isDark,
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onPressed,
|
||||
bool isSmall = false,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(3), // 减少圆角
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isSmall ? 4 : 6, // 减少内边距
|
||||
vertical: isSmall ? 2 : 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey400,
|
||||
width: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3), // 减少圆角
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.white,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isSmall ? 10 : 12, // 减少图标大小
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
if (!isSmall) ...[
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 10, // 减少字体大小
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 复制到剪贴板
|
||||
void _copyToClipboard(BuildContext context, String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('已复制到剪贴板'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: Colors.green.shade600,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示词预览加载组件
|
||||
/// 用于显示加载状态,加载图标位于中央
|
||||
class PromptPreviewLoadingWidget extends StatelessWidget {
|
||||
const PromptPreviewLoadingWidget({
|
||||
super.key,
|
||||
this.message = '正在生成预览...',
|
||||
});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示词预览对话框
|
||||
/// 独立的对话框版本,可以单独使用
|
||||
class PromptPreviewDialog extends StatelessWidget {
|
||||
const PromptPreviewDialog({
|
||||
super.key,
|
||||
required this.previewResponse,
|
||||
this.onGenerate,
|
||||
});
|
||||
|
||||
final UniversalAIPreviewResponse previewResponse;
|
||||
final VoidCallback? onGenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
const Icon(Icons.preview_outlined),
|
||||
const SizedBox(width: 8),
|
||||
const Text('提示词预览'),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 720,
|
||||
height: 600,
|
||||
child: PromptPreviewWidget(
|
||||
previewResponse: previewResponse,
|
||||
showActions: true,
|
||||
fixedWidth: 680,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
if (onGenerate != null)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onGenerate!();
|
||||
},
|
||||
child: const Text('生成'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示提示词预览对话框的便捷函数
|
||||
Future<void> showPromptPreviewDialog(
|
||||
BuildContext context, {
|
||||
required UniversalAIPreviewResponse previewResponse,
|
||||
VoidCallback? onGenerate,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => PromptPreviewDialog(
|
||||
previewResponse: previewResponse,
|
||||
onGenerate: onGenerate,
|
||||
),
|
||||
);
|
||||
}
|
||||
307
AINoval/lib/widgets/common/prompt_quick_edit_dialog.dart
Normal file
307
AINoval/lib/widgets/common/prompt_quick_edit_dialog.dart
Normal file
@@ -0,0 +1,307 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/models/prompt_models.dart';
|
||||
import 'package:ainoval/blocs/prompt_new/prompt_new_bloc.dart';
|
||||
import 'package:ainoval/blocs/prompt_new/prompt_new_event.dart';
|
||||
|
||||
class PromptQuickEditDialog extends StatefulWidget {
|
||||
const PromptQuickEditDialog({
|
||||
super.key,
|
||||
required this.templateId,
|
||||
required this.aiFeatureType,
|
||||
this.onTemporaryPromptsSaved,
|
||||
});
|
||||
|
||||
final String templateId;
|
||||
final String aiFeatureType;
|
||||
final void Function(String systemPrompt, String userPrompt)? onTemporaryPromptsSaved;
|
||||
|
||||
@override
|
||||
State<PromptQuickEditDialog> createState() => _PromptQuickEditDialogState();
|
||||
}
|
||||
|
||||
class _PromptQuickEditDialogState extends State<PromptQuickEditDialog> with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late TextEditingController _systemController;
|
||||
late TextEditingController _userController;
|
||||
bool _isEdited = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_systemController = TextEditingController();
|
||||
_userController = TextEditingController();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final state = context.read<PromptNewBloc>().state;
|
||||
final feature = AIFeatureTypeHelper.fromApiString(widget.aiFeatureType.toUpperCase());
|
||||
final pkg = state.promptPackages[feature];
|
||||
if (pkg != null) {
|
||||
UserPromptInfo? selected;
|
||||
if (widget.templateId.startsWith('system_default_')) {
|
||||
if (pkg.systemPrompt.defaultSystemPrompt.isNotEmpty) {
|
||||
selected = UserPromptInfo(
|
||||
id: widget.templateId,
|
||||
name: '系统默认模板',
|
||||
featureType: feature,
|
||||
systemPrompt: pkg.systemPrompt.effectivePrompt,
|
||||
userPrompt: pkg.systemPrompt.defaultUserPrompt,
|
||||
createdAt: pkg.lastUpdated,
|
||||
updatedAt: pkg.lastUpdated,
|
||||
);
|
||||
}
|
||||
} else if (widget.templateId.startsWith('public_')) {
|
||||
final pid = widget.templateId.substring('public_'.length);
|
||||
final pub = pkg.publicPrompts.firstWhere(
|
||||
(e) => e.id == pid,
|
||||
orElse: () => PublicPromptInfo(
|
||||
id: '', name: '', featureType: feature, systemPrompt: '', userPrompt: '', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (pub.id.isNotEmpty) {
|
||||
selected = UserPromptInfo(
|
||||
id: widget.templateId,
|
||||
name: pub.name,
|
||||
featureType: feature,
|
||||
systemPrompt: pub.systemPrompt,
|
||||
userPrompt: pub.userPrompt,
|
||||
createdAt: pub.createdAt,
|
||||
updatedAt: pub.updatedAt,
|
||||
isPublic: true,
|
||||
isVerified: pub.isVerified,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
selected = pkg.userPrompts.firstWhere(
|
||||
(e) => e.id == widget.templateId,
|
||||
orElse: () => UserPromptInfo(
|
||||
id: '', name: '', featureType: AIFeatureType.textExpansion, userPrompt: '', createdAt: DateTime.now(), updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (selected != null && selected.id.isNotEmpty) {
|
||||
_systemController.text = selected.systemPrompt ?? '';
|
||||
_userController.text = selected.userPrompt;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_systemController.dispose();
|
||||
_userController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(24),
|
||||
backgroundColor: WebTheme.getSurfaceColor(context),
|
||||
child: SizedBox(
|
||||
width: 900,
|
||||
height: 640,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildContentEditor(),
|
||||
_buildPropertiesPlaceholder(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildBottomActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: WebTheme.getBorderColor(context), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'编辑提示词',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
color: WebTheme.getTextColor(context),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: WebTheme.getBorderColor(context), width: 1),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: WebTheme.getTextColor(context),
|
||||
unselectedLabelColor: WebTheme.getSecondaryTextColor(context),
|
||||
indicatorColor: WebTheme.getTextColor(context),
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: const [
|
||||
Tab(text: '内容编辑', icon: Icon(Icons.edit_outlined, size: 16)),
|
||||
Tab(text: '属性设置', icon: Icon(Icons.settings_outlined, size: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentEditor() {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('系统提示词 (System Prompt)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: WebTheme.getTextColor(context))),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _systemController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(border: InputBorder.none, contentPadding: EdgeInsets.all(12), hintText: '输入系统提示词...'),
|
||||
onChanged: (_) => setState(() => _isEdited = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(width: 1, margin: const EdgeInsets.symmetric(horizontal: 12), color: WebTheme.getBorderColor(context)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('用户提示词 (User Prompt)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: WebTheme.getTextColor(context))),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _userController,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(border: InputBorder.none, contentPadding: EdgeInsets.all(12), hintText: '输入用户提示词...'),
|
||||
onChanged: (_) => setState(() => _isEdited = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPropertiesPlaceholder() {
|
||||
return Center(
|
||||
child: Text(
|
||||
'属性设置可在完整提示词页面中编辑',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: WebTheme.getBorderColor(context))),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.onTemporaryPromptsSaved?.call(
|
||||
_systemController.text.trim(),
|
||||
_userController.text.trim(),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已临时保存当前编辑的提示词')));
|
||||
},
|
||||
child: const Text('临时保存'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isEdited ? _saveToServer : null,
|
||||
icon: const Icon(Icons.save, size: 16),
|
||||
label: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveToServer() {
|
||||
if (widget.templateId.startsWith('system_default_') || widget.templateId.startsWith('public_')) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('系统/公共模板不可直接修改,请先复制为私有模板')));
|
||||
return;
|
||||
}
|
||||
|
||||
context.read<PromptNewBloc>().add(UpdatePromptDetails(
|
||||
promptId: widget.templateId,
|
||||
request: UpdatePromptTemplateRequest(
|
||||
systemPrompt: _systemController.text.trim(),
|
||||
userPrompt: _userController.text.trim(),
|
||||
),
|
||||
));
|
||||
|
||||
setState(() => _isEdited = false);
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('模板已保存')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
AINoval/lib/widgets/common/radio_button_group.dart
Normal file
221
AINoval/lib/widgets/common/radio_button_group.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 单选按钮选项
|
||||
class RadioOption<T> {
|
||||
/// 构造函数
|
||||
const RadioOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 选项值
|
||||
final T value;
|
||||
|
||||
/// 显示标签
|
||||
final String label;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
/// 单选按钮组组件
|
||||
/// 提供水平布局的单选按钮组,支持清除功能
|
||||
class RadioButtonGroup<T> extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const RadioButtonGroup({
|
||||
super.key,
|
||||
required this.options,
|
||||
this.value,
|
||||
required this.onChanged,
|
||||
this.showClear = false,
|
||||
this.onClear,
|
||||
this.clearLabel = '清除',
|
||||
this.spacing = 4,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 选项列表
|
||||
final List<RadioOption<T>> options;
|
||||
|
||||
/// 当前选中值
|
||||
final T? value;
|
||||
|
||||
/// 值改变回调
|
||||
final ValueChanged<T?> onChanged;
|
||||
|
||||
/// 是否显示清除按钮
|
||||
final bool showClear;
|
||||
|
||||
/// 清除回调
|
||||
final VoidCallback? onClear;
|
||||
|
||||
/// 清除按钮文字
|
||||
final String clearLabel;
|
||||
|
||||
/// 选项间距
|
||||
final double spacing;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 选项按钮
|
||||
...options.map((option) => Padding(
|
||||
padding: EdgeInsets.only(right: spacing),
|
||||
child: _buildRadioButton(context, option, isDark),
|
||||
)).toList(),
|
||||
|
||||
// 清除按钮
|
||||
if (showClear) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildClearButton(context, isDark),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单个单选按钮
|
||||
Widget _buildRadioButton(BuildContext context, RadioOption<T> option, bool isDark) {
|
||||
final isSelected = value == option.value;
|
||||
final isEnabled = enabled && option.enabled;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: isEnabled ? () => onChanged(option.value) : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected && isEnabled
|
||||
? (isDark ? WebTheme.darkGrey400 : WebTheme.grey400)
|
||||
: (isDark ? WebTheme.darkGrey200.withValues(alpha: 0.1) : WebTheme.grey200.withValues(alpha: 0.1)),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: isSelected && isEnabled
|
||||
? [
|
||||
BoxShadow(
|
||||
color: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400).withValues(alpha: 0.2),
|
||||
blurRadius: 0,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
option.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isEnabled
|
||||
? (isDark ? WebTheme.darkGrey800 : WebTheme.grey800)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建清除按钮
|
||||
Widget _buildClearButton(BuildContext context, bool isDark) {
|
||||
final isEnabled = enabled && onClear != null;
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: isEnabled ? onClear : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.block,
|
||||
size: 12,
|
||||
color: isEnabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
clearLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isEnabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey400 : WebTheme.grey400),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单选按钮组包装器,包含"或"分隔符
|
||||
class RadioButtonGroupWithSeparator<T> extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const RadioButtonGroupWithSeparator({
|
||||
super.key,
|
||||
required this.radioGroup,
|
||||
required this.alternativeWidget,
|
||||
this.separatorLabel = '或',
|
||||
});
|
||||
|
||||
/// 单选按钮组
|
||||
final RadioButtonGroup<T> radioGroup;
|
||||
|
||||
/// 替代组件(如输入框)
|
||||
final Widget alternativeWidget;
|
||||
|
||||
/// 分隔符文字
|
||||
final String separatorLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 单选按钮组
|
||||
radioGroup,
|
||||
|
||||
// 分隔符
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
separatorLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? WebTheme.darkGrey600 : WebTheme.grey600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 替代组件
|
||||
Expanded(child: alternativeWidget),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
40
AINoval/lib/widgets/common/required_badge.dart
Normal file
40
AINoval/lib/widgets/common/required_badge.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 必填字段标识组件
|
||||
/// 用于标识表单中的必填字段
|
||||
class RequiredBadge extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const RequiredBadge({
|
||||
super.key,
|
||||
this.text = 'Required',
|
||||
});
|
||||
|
||||
/// 显示文本
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.red.shade900.withOpacity(0.3) : Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark ? Colors.red.shade700 : Colors.red.shade200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? Colors.red.shade300 : Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
541
AINoval/lib/widgets/common/scene_selector.dart
Normal file
541
AINoval/lib/widgets/common/scene_selector.dart
Normal file
@@ -0,0 +1,541 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
|
||||
/// 场景选择器组件
|
||||
/// 提供场景信息显示和下拉选择功能,支持大量场景的性能优化
|
||||
class SceneSelector extends StatefulWidget {
|
||||
const SceneSelector({
|
||||
Key? key,
|
||||
required this.novel,
|
||||
required this.activeSceneId,
|
||||
required this.onSceneSelected,
|
||||
this.onSummaryLoaded,
|
||||
this.compact = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final Novel novel;
|
||||
final String? activeSceneId;
|
||||
final Function(String sceneId, String actId, String chapterId) onSceneSelected;
|
||||
final Function(String summary)? onSummaryLoaded;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<SceneSelector> createState() => _SceneSelectorState();
|
||||
}
|
||||
|
||||
class _SceneSelectorState extends State<SceneSelector> {
|
||||
final GlobalKey _buttonKey = GlobalKey();
|
||||
List<_SceneItem> _cachedSceneItems = [];
|
||||
bool _isDropdownOpen = false;
|
||||
OverlayEntry? _overlayEntry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_buildSceneItemsCache();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SceneSelector oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.novel != widget.novel) {
|
||||
_buildSceneItemsCache();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_closeDropdown();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 构建场景项缓存,提高性能
|
||||
void _buildSceneItemsCache() {
|
||||
_cachedSceneItems = [];
|
||||
|
||||
for (final act in widget.novel.acts) {
|
||||
// 添加Act分组标题
|
||||
_cachedSceneItems.add(_SceneItem(
|
||||
type: _SceneItemType.actHeader,
|
||||
title: act.title,
|
||||
actId: act.id,
|
||||
));
|
||||
|
||||
// 添加Act下的Chapter和Scene
|
||||
for (final chapter in act.chapters) {
|
||||
// 添加Chapter分组标题
|
||||
_cachedSceneItems.add(_SceneItem(
|
||||
type: _SceneItemType.chapterHeader,
|
||||
title: chapter.title,
|
||||
actId: act.id,
|
||||
chapterId: chapter.id,
|
||||
));
|
||||
|
||||
// 添加Scene
|
||||
for (int i = 0; i < chapter.scenes.length; i++) {
|
||||
final scene = chapter.scenes[i];
|
||||
_cachedSceneItems.add(_SceneItem(
|
||||
type: _SceneItemType.scene,
|
||||
title: scene.title,
|
||||
actId: act.id,
|
||||
chapterId: chapter.id,
|
||||
sceneId: scene.id,
|
||||
sceneIndex: i,
|
||||
sceneSummary: scene.summary?.content,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开下拉菜单
|
||||
void _openDropdown() {
|
||||
if (_isDropdownOpen) return;
|
||||
|
||||
final RenderBox buttonRenderBox = _buttonKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final buttonPosition = buttonRenderBox.localToGlobal(Offset.zero);
|
||||
final buttonSize = buttonRenderBox.size;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => _DropdownOverlay(
|
||||
buttonPosition: buttonPosition,
|
||||
buttonSize: buttonSize,
|
||||
items: _cachedSceneItems,
|
||||
activeSceneId: widget.activeSceneId,
|
||||
onItemSelected: (sceneId, actId, chapterId) {
|
||||
_closeDropdown();
|
||||
widget.onSceneSelected(sceneId, actId, chapterId);
|
||||
},
|
||||
onClose: _closeDropdown,
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() {
|
||||
_isDropdownOpen = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// 关闭下拉菜单
|
||||
void _closeDropdown() {
|
||||
if (!_isDropdownOpen) return;
|
||||
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
setState(() {
|
||||
_isDropdownOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取当前场景信息
|
||||
String _getCurrentSceneInfo() {
|
||||
final activeScene = _getActiveScene();
|
||||
if (activeScene == null) return '未选择场景';
|
||||
|
||||
final scenePosition = _getScenePosition(activeScene);
|
||||
if (widget.compact) {
|
||||
return scenePosition;
|
||||
}
|
||||
|
||||
return '$scenePosition · ${activeScene.title}';
|
||||
}
|
||||
|
||||
/// 获取当前激活的场景
|
||||
Scene? _getActiveScene() {
|
||||
if (widget.activeSceneId == null) return null;
|
||||
|
||||
for (final act in widget.novel.acts) {
|
||||
for (final chapter in act.chapters) {
|
||||
for (final scene in chapter.scenes) {
|
||||
if (scene.id == widget.activeSceneId) {
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 获取场景位置信息
|
||||
String _getScenePosition(Scene scene) {
|
||||
int actIndex = 0;
|
||||
int chapterIndex = 0;
|
||||
int sceneIndex = 0;
|
||||
|
||||
for (int i = 0; i < widget.novel.acts.length; i++) {
|
||||
final act = widget.novel.acts[i];
|
||||
for (int j = 0; j < act.chapters.length; j++) {
|
||||
final chapter = act.chapters[j];
|
||||
for (int k = 0; k < chapter.scenes.length; k++) {
|
||||
final sceneItem = chapter.scenes[k];
|
||||
if (sceneItem.id == scene.id) {
|
||||
actIndex = i + 1;
|
||||
chapterIndex = j + 1;
|
||||
sceneIndex = k + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '第${actIndex}卷 · 第${chapterIndex}章 · 第${sceneIndex}场';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: _buttonKey,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _isDropdownOpen ? Colors.blue : Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: _isDropdownOpen ? [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _isDropdownOpen ? _closeDropdown : _openDropdown,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
hoverColor: Colors.grey[50],
|
||||
child: Container(
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getCurrentSceneInfo(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_isDropdownOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
size: 18,
|
||||
color: _isDropdownOpen ? Colors.blue : Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义下拉菜单覆盖层
|
||||
class _DropdownOverlay extends StatefulWidget {
|
||||
const _DropdownOverlay({
|
||||
required this.buttonPosition,
|
||||
required this.buttonSize,
|
||||
required this.items,
|
||||
required this.activeSceneId,
|
||||
required this.onItemSelected,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final Offset buttonPosition;
|
||||
final Size buttonSize;
|
||||
final List<_SceneItem> items;
|
||||
final String? activeSceneId;
|
||||
final Function(String sceneId, String actId, String chapterId) onItemSelected;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
State<_DropdownOverlay> createState() => _DropdownOverlayState();
|
||||
}
|
||||
|
||||
class _DropdownOverlayState extends State<_DropdownOverlay> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 计算下拉菜单的位置和大小
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final maxHeight = 300.0; // 增加最大高度
|
||||
|
||||
// 计算菜单位置,确保上边缘紧贴按钮下边缘
|
||||
final menuTop = widget.buttonPosition.dy + widget.buttonSize.height;
|
||||
final menuLeft = widget.buttonPosition.dx;
|
||||
final menuWidth = widget.buttonSize.width;
|
||||
|
||||
// 确保菜单不会超出屏幕
|
||||
final availableHeight = screenSize.height - menuTop - 20;
|
||||
final menuHeight = maxHeight < availableHeight ? maxHeight : availableHeight;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 背景遮罩,点击关闭下拉菜单
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onClose,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 下拉菜单
|
||||
Positioned(
|
||||
left: menuLeft,
|
||||
top: menuTop,
|
||||
width: menuWidth,
|
||||
height: menuHeight,
|
||||
child: Material(
|
||||
elevation: 12, // 增加阴影
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
shadowColor: Colors.black.withOpacity(0.15),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 显示场景数量限制提示
|
||||
if (widget.items.where((item) => item.type == _SceneItemType.scene).length > 200)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[50],
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: Colors.orange[600],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'场景数量过多,仅显示前200个场景',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 场景列表
|
||||
Expanded(
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
scrollbarTheme: ScrollbarThemeData(
|
||||
thickness: MaterialStateProperty.all(6),
|
||||
radius: const Radius.circular(3),
|
||||
thumbColor: MaterialStateProperty.all(Colors.grey[400]),
|
||||
trackColor: MaterialStateProperty.all(Colors.grey[200]),
|
||||
),
|
||||
),
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: widget.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.items[index];
|
||||
return _buildDropdownItem(item);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownItem(_SceneItem item) {
|
||||
switch (item.type) {
|
||||
case _SceneItemType.actHeader:
|
||||
return Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
case _SceneItemType.chapterHeader:
|
||||
return Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 10,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
case _SceneItemType.scene:
|
||||
final isSelected = item.sceneId == widget.activeSceneId;
|
||||
final hasSummary = item.sceneSummary != null && item.sceneSummary!.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Material(
|
||||
color: isSelected ? Colors.blue[50] : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.onItemSelected(item.sceneId!, item.actId, item.chapterId!);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
hoverColor: isSelected ? Colors.blue[100] : Colors.grey[100],
|
||||
splashColor: isSelected ? Colors.blue[200] : Colors.grey[200],
|
||||
child: Container(
|
||||
// 动态高度:有摘要时使用更大的高度
|
||||
height: hasSummary ? 44 : 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
// 场景序号容器,固定在顶部对齐
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.blue : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: isSelected ? [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${item.sceneIndex! + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 7,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected ? Colors.blue[700] : Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasSummary) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.sceneSummary!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isSelected ? Colors.blue[600] : Colors.grey[600],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 场景项类型枚举
|
||||
enum _SceneItemType {
|
||||
actHeader,
|
||||
chapterHeader,
|
||||
scene,
|
||||
}
|
||||
|
||||
/// 场景项数据类
|
||||
class _SceneItem {
|
||||
final _SceneItemType type;
|
||||
final String title;
|
||||
final String actId;
|
||||
final String? chapterId;
|
||||
final String? sceneId;
|
||||
final int? sceneIndex;
|
||||
final String? sceneSummary;
|
||||
|
||||
_SceneItem({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.actId,
|
||||
this.chapterId,
|
||||
this.sceneId,
|
||||
this.sceneIndex,
|
||||
this.sceneSummary,
|
||||
});
|
||||
}
|
||||
265
AINoval/lib/widgets/common/search_action_bar.dart
Normal file
265
AINoval/lib/widgets/common/search_action_bar.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 搜索和操作栏公共组件
|
||||
class SearchActionBar extends StatefulWidget {
|
||||
final TextEditingController searchController;
|
||||
final String searchHint;
|
||||
final VoidCallback? onFilterPressed;
|
||||
final VoidCallback? onNewPressed;
|
||||
final VoidCallback? onSettingsPressed;
|
||||
final String newButtonText;
|
||||
final Function(String)? onSearchChanged;
|
||||
final bool showFilterButton;
|
||||
final bool showNewButton;
|
||||
final bool showSettingsButton;
|
||||
|
||||
const SearchActionBar({
|
||||
super.key,
|
||||
required this.searchController,
|
||||
this.searchHint = '搜索...',
|
||||
this.onFilterPressed,
|
||||
this.onNewPressed,
|
||||
this.onSettingsPressed,
|
||||
this.newButtonText = '新建',
|
||||
this.onSearchChanged,
|
||||
this.showFilterButton = true,
|
||||
this.showNewButton = true,
|
||||
this.showSettingsButton = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchActionBar> createState() => _SearchActionBarState();
|
||||
}
|
||||
|
||||
class _SearchActionBarState extends State<SearchActionBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.searchController.removeListener(_onSearchChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {}); // 触发重建以更新清除按钮显示状态
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context), // 🚀 修复:使用背景色而不是表面色
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDark ? WebTheme.darkGrey200 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 确保所有元素垂直居中
|
||||
children: [
|
||||
// 搜索框 - 占用大部分空间
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
// 根据主题模式设置背景,使用背景色而不是灰色
|
||||
color: WebTheme.getBackgroundColor(context), // 🚀 修复:使用背景色而不是灰色
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 搜索图标
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8),
|
||||
child: Icon(
|
||||
Icons.search,
|
||||
size: 18,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
// 搜索输入框
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.searchController,
|
||||
onChanged: widget.onSearchChanged,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey900,
|
||||
height: 1.0, // 确保文字垂直居中
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey500,
|
||||
height: 1.0, // 确保提示文字垂直居中
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 0,
|
||||
vertical: 10, // 调整垂直内边距确保居中
|
||||
),
|
||||
isDense: true, // 减少默认内边距
|
||||
),
|
||||
),
|
||||
),
|
||||
// 清除按钮
|
||||
if (widget.searchController.text.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.searchController.clear();
|
||||
widget.onSearchChanged?.call('');
|
||||
},
|
||||
child: Icon(
|
||||
Icons.clear,
|
||||
size: 18,
|
||||
color: isDark ? WebTheme.darkGrey400 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 操作按钮区域
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // 确保按钮垂直居中
|
||||
children: [
|
||||
// 过滤器按钮
|
||||
if (widget.showFilterButton) ...[
|
||||
_buildIconButton(
|
||||
icon: Icons.filter_list,
|
||||
onPressed: widget.onFilterPressed,
|
||||
tooltip: '过滤器',
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 新建按钮
|
||||
if (widget.showNewButton) ...[
|
||||
_buildNewButton(
|
||||
text: widget.newButtonText,
|
||||
onPressed: widget.onNewPressed,
|
||||
isDark: isDark,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
// 设置按钮
|
||||
if (widget.showSettingsButton)
|
||||
_buildIconButton(
|
||||
icon: Icons.settings,
|
||||
onPressed: widget.onSettingsPressed,
|
||||
tooltip: '设置',
|
||||
isDark: isDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback? onPressed,
|
||||
required String tooltip,
|
||||
required bool isDark,
|
||||
}) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: Center( // 确保图标居中
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: isDark ? WebTheme.darkGrey300 : WebTheme.grey700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNewButton({
|
||||
required String text,
|
||||
required VoidCallback? onPressed,
|
||||
required bool isDark,
|
||||
}) {
|
||||
return Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey900,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center, // 确保内容居中
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
size: 16,
|
||||
color: isDark ? WebTheme.darkGrey900 : WebTheme.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? WebTheme.darkGrey900 : WebTheme.white,
|
||||
height: 1.0, // 确保文字垂直居中
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
947
AINoval/lib/widgets/common/setting_preview_manager.dart
Normal file
947
AINoval/lib/widgets/common/setting_preview_manager.dart
Normal file
@@ -0,0 +1,947 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_type.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:ainoval/screens/editor/widgets/novel_setting_detail.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/novel_setting_repository.dart';
|
||||
import 'package:ainoval/services/api_service/repositories/storage_repository.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:ainoval/widgets/common/universal_card.dart';
|
||||
|
||||
/// 通用设定预览卡片管理器
|
||||
///
|
||||
/// 提供统一的设定预览卡片显示和管理功能,应用全局样式和主题
|
||||
/// 支持点击标题打开详情编辑卡片,确保Provider正确传递
|
||||
class SettingPreviewManager {
|
||||
static OverlayEntry? _overlayEntry;
|
||||
static bool _isShowing = false;
|
||||
|
||||
/// 显示设定预览卡片
|
||||
///
|
||||
/// [context] 上下文,必须包含SettingBloc、NovelSettingRepository、StorageRepository
|
||||
/// [settingId] 设定条目ID
|
||||
/// [novelId] 小说ID
|
||||
/// [position] 显示位置
|
||||
/// [onClose] 关闭回调
|
||||
/// [onDetailOpened] 详情卡片打开回调
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required String settingId,
|
||||
required String novelId,
|
||||
required Offset position,
|
||||
VoidCallback? onClose,
|
||||
VoidCallback? onDetailOpened,
|
||||
}) {
|
||||
if (_isShowing) {
|
||||
hide();
|
||||
}
|
||||
|
||||
try {
|
||||
// 🚀 预检查必要的Provider实例
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
final settingRepository = context.read<NovelSettingRepository>();
|
||||
final storageRepository = context.read<StorageRepository>();
|
||||
final editorLayoutManager = context.read<EditorLayoutManager>();
|
||||
|
||||
// 🎯 查找滚动上下文
|
||||
final scrollableState = Scrollable.maybeOf(context);
|
||||
AppLogger.d('SettingPreviewManager', '🔍 查找滚动上下文: ${scrollableState != null ? "找到" : "未找到"}');
|
||||
|
||||
AppLogger.i('SettingPreviewManager', '📍 显示设定预览卡片: $settingId');
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (overlayContext) => Stack(
|
||||
children: [
|
||||
// 🎯 智能背景遮罩 - 只在点击编辑区域时关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
AppLogger.d('SettingPreviewManager', '🎯 点击编辑区域,关闭预览卡片');
|
||||
hide();
|
||||
onClose?.call();
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 设定预览卡片 - 通过MultiProvider确保所有依赖都可用
|
||||
MultiProvider(
|
||||
providers: [
|
||||
BlocProvider<SettingBloc>.value(value: settingBloc),
|
||||
Provider<NovelSettingRepository>.value(value: settingRepository),
|
||||
Provider<StorageRepository>.value(value: storageRepository),
|
||||
ChangeNotifierProvider<EditorLayoutManager>.value(value: editorLayoutManager),
|
||||
],
|
||||
child: _UniversalSettingPreviewCard(
|
||||
settingId: settingId,
|
||||
novelId: novelId,
|
||||
position: position,
|
||||
scrollPosition: scrollableState?.position,
|
||||
onClose: () {
|
||||
hide();
|
||||
onClose?.call();
|
||||
},
|
||||
onDetailOpened: onDetailOpened,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
_isShowing = true;
|
||||
|
||||
AppLogger.i('SettingPreviewManager', '✅ 设定预览卡片已显示');
|
||||
} catch (e) {
|
||||
AppLogger.e('SettingPreviewManager', '显示设定预览卡片失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 隐藏设定预览卡片
|
||||
static void hide() {
|
||||
if (_overlayEntry != null) {
|
||||
_overlayEntry!.remove();
|
||||
_overlayEntry = null;
|
||||
_isShowing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否正在显示
|
||||
static bool get isShowing => _isShowing;
|
||||
}
|
||||
|
||||
/// 通用设定预览卡片组件
|
||||
///
|
||||
/// 采用全局样式和主题,提供一致的用户体验
|
||||
class _UniversalSettingPreviewCard extends StatefulWidget {
|
||||
final String settingId;
|
||||
final String novelId;
|
||||
final Offset position;
|
||||
final ScrollPosition? scrollPosition;
|
||||
final VoidCallback? onClose;
|
||||
final VoidCallback? onDetailOpened;
|
||||
|
||||
const _UniversalSettingPreviewCard({
|
||||
Key? key,
|
||||
required this.settingId,
|
||||
required this.novelId,
|
||||
required this.position,
|
||||
this.scrollPosition,
|
||||
this.onClose,
|
||||
this.onDetailOpened,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_UniversalSettingPreviewCard> createState() => _UniversalSettingPreviewCardState();
|
||||
}
|
||||
|
||||
class _UniversalSettingPreviewCardState extends State<_UniversalSettingPreviewCard>
|
||||
with TickerProviderStateMixin {
|
||||
static const String _tag = 'UniversalSettingPreviewCard';
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
late AnimationController _positionController;
|
||||
late Animation<Offset> _positionAnimation;
|
||||
|
||||
NovelSettingItem? _settingItem;
|
||||
SettingGroup? _settingGroup;
|
||||
bool _isLoading = true;
|
||||
|
||||
// 🎯 智能浮动定位相关状态
|
||||
Offset _currentPosition = Offset.zero;
|
||||
double _lastScrollOffset = 0;
|
||||
ScrollPosition? _scrollPosition;
|
||||
bool _isFollowingScroll = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 初始化位置
|
||||
_currentPosition = widget.position;
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// 🎯 智能定位动画控制器
|
||||
_positionController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.85,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_positionAnimation = Tween<Offset>(
|
||||
begin: _currentPosition,
|
||||
end: _currentPosition,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _positionController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_loadSettingData();
|
||||
_animationController.forward();
|
||||
|
||||
// 🎯 延迟初始化滚动监听,等待Widget完全构建
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeScrollListener();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_positionController.dispose();
|
||||
_scrollPosition?.removeListener(_onScrollChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 🎯 初始化滚动监听器
|
||||
void _initializeScrollListener() {
|
||||
try {
|
||||
AppLogger.d(_tag, '🔍 开始初始化滚动监听器...');
|
||||
|
||||
// 方式1: 使用传入的ScrollPosition
|
||||
if (widget.scrollPosition != null) {
|
||||
_scrollPosition = widget.scrollPosition!;
|
||||
_lastScrollOffset = _scrollPosition!.pixels;
|
||||
_scrollPosition!.addListener(_onScrollChanged);
|
||||
AppLogger.i(_tag, '✅ 滚动监听器初始化成功 - 方式1: 传入的ScrollPosition');
|
||||
AppLogger.d(_tag, '📍 初始滚动位置: ${_lastScrollOffset}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 方式2: 查找最近的ScrollableState
|
||||
final scrollableState = Scrollable.maybeOf(context);
|
||||
if (scrollableState != null) {
|
||||
_scrollPosition = scrollableState.position;
|
||||
_lastScrollOffset = _scrollPosition!.pixels;
|
||||
_scrollPosition!.addListener(_onScrollChanged);
|
||||
AppLogger.i(_tag, '✅ 滚动监听器初始化成功 - 方式2: Scrollable.maybeOf');
|
||||
AppLogger.d(_tag, '📍 初始滚动位置: ${_lastScrollOffset}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 方式2: 向上搜索父级Widget树寻找滚动区域
|
||||
BuildContext? searchContext = context;
|
||||
int searchDepth = 0;
|
||||
const maxSearchDepth = 5;
|
||||
|
||||
searchContext.visitAncestorElements((ancestor) {
|
||||
if (searchDepth >= maxSearchDepth) return false;
|
||||
|
||||
final scrollableState = Scrollable.maybeOf(ancestor);
|
||||
if (scrollableState != null) {
|
||||
_scrollPosition = scrollableState.position;
|
||||
_lastScrollOffset = _scrollPosition!.pixels;
|
||||
_scrollPosition!.addListener(_onScrollChanged);
|
||||
AppLogger.i(_tag, '✅ 滚动监听器初始化成功 - 方式2: 向上搜索深度$searchDepth');
|
||||
AppLogger.d(_tag, '📍 初始滚动位置: ${_lastScrollOffset}');
|
||||
return false; // 找到后停止搜索
|
||||
}
|
||||
|
||||
searchDepth++;
|
||||
return true; // 继续向上搜索
|
||||
});
|
||||
|
||||
// 如果已经找到滚动位置,直接返回
|
||||
if (_scrollPosition != null) return;
|
||||
|
||||
// 方式3: 延迟重试,等待Overlay完全加载
|
||||
AppLogger.w(_tag, '⚠️ 首次未找到滚动上下文,1秒后重试...');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (mounted) {
|
||||
_retryInitializeScrollListener();
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '初始化滚动监听器失败', e);
|
||||
// 延迟重试
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
_retryInitializeScrollListener();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 重试初始化滚动监听器
|
||||
void _retryInitializeScrollListener() {
|
||||
try {
|
||||
AppLogger.d(_tag, '🔄 重试初始化滚动监听器...');
|
||||
|
||||
final scrollableState = Scrollable.maybeOf(context);
|
||||
if (scrollableState != null) {
|
||||
_scrollPosition = scrollableState.position;
|
||||
_lastScrollOffset = _scrollPosition!.pixels;
|
||||
_scrollPosition!.addListener(_onScrollChanged);
|
||||
AppLogger.i(_tag, '✅ 滚动监听器重试初始化成功');
|
||||
AppLogger.d(_tag, '📍 初始滚动位置: ${_lastScrollOffset}');
|
||||
} else {
|
||||
AppLogger.e(_tag, '❌ 重试后仍未找到可滚动的上下文');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '重试初始化滚动监听器失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 处理滚动变化 - 智能调整卡片位置
|
||||
void _onScrollChanged() {
|
||||
if (!_isFollowingScroll || _scrollPosition == null || !mounted) return;
|
||||
|
||||
final currentScrollOffset = _scrollPosition!.pixels;
|
||||
final scrollDelta = currentScrollOffset - _lastScrollOffset;
|
||||
_lastScrollOffset = currentScrollOffset;
|
||||
|
||||
// 🔍 调试信息:记录滚动变化
|
||||
AppLogger.d(_tag, '🔄 滚动事件: 当前位置=${currentScrollOffset.toStringAsFixed(1)}, 变化=${scrollDelta.toStringAsFixed(1)}');
|
||||
|
||||
// 忽略极小的滚动变化,避免过度敏感
|
||||
if (scrollDelta.abs() < 0.5) return;
|
||||
|
||||
// 计算新位置
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const cardHeight = 220.0;
|
||||
const cardWidth = 340.0;
|
||||
const topMargin = 16.0;
|
||||
const bottomMargin = 16.0;
|
||||
|
||||
double newTop = _currentPosition.dy - scrollDelta;
|
||||
double newLeft = _currentPosition.dx;
|
||||
|
||||
// 🎯 智能边界处理 - 当向下滚动时卡片逐渐向顶部靠拢
|
||||
if (scrollDelta > 0) { // 向下滚动
|
||||
// 如果卡片即将滚出上边界,让它停留在顶部
|
||||
if (newTop < topMargin) {
|
||||
newTop = topMargin;
|
||||
}
|
||||
} else if (scrollDelta < 0) { // 向上滚动
|
||||
// 如果卡片即将滚出下边界,让它停留在底部
|
||||
if (newTop + cardHeight > screenSize.height - bottomMargin) {
|
||||
newTop = screenSize.height - cardHeight - bottomMargin;
|
||||
}
|
||||
}
|
||||
|
||||
// 水平位置边界检查
|
||||
if (newLeft + cardWidth > screenSize.width - 16) {
|
||||
newLeft = screenSize.width - cardWidth - 16;
|
||||
}
|
||||
if (newLeft < 16) {
|
||||
newLeft = 16;
|
||||
}
|
||||
|
||||
final newPosition = Offset(newLeft, newTop);
|
||||
|
||||
// 只有位置真正改变时才更新
|
||||
if (newPosition != _currentPosition) {
|
||||
_updatePosition(newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🎯 平滑更新卡片位置
|
||||
void _updatePosition(Offset newPosition) {
|
||||
if (!mounted) return;
|
||||
|
||||
AppLogger.d(_tag, '📍 更新卡片位置: ${_currentPosition.dx.toStringAsFixed(1)},${_currentPosition.dy.toStringAsFixed(1)} → ${newPosition.dx.toStringAsFixed(1)},${newPosition.dy.toStringAsFixed(1)}');
|
||||
|
||||
_positionAnimation = Tween<Offset>(
|
||||
begin: _currentPosition,
|
||||
end: newPosition,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _positionController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_currentPosition = newPosition;
|
||||
_positionController.forward(from: 0);
|
||||
}
|
||||
|
||||
/// 加载设定数据
|
||||
void _loadSettingData() {
|
||||
try {
|
||||
final settingBloc = context.read<SettingBloc>();
|
||||
final state = settingBloc.state;
|
||||
|
||||
AppLogger.d(_tag, '加载设定数据: ${widget.settingId}');
|
||||
|
||||
// 查找设定条目
|
||||
_settingItem = state.items.where((item) => item.id == widget.settingId).firstOrNull;
|
||||
|
||||
if (_settingItem != null) {
|
||||
// 查找设定组
|
||||
_settingGroup = state.groups.where(
|
||||
(group) => group.itemIds?.contains(widget.settingId) == true,
|
||||
).firstOrNull;
|
||||
|
||||
AppLogger.d(_tag, '找到设定: ${_settingItem!.name}, 组: ${_settingGroup?.name ?? "无"}');
|
||||
} else {
|
||||
AppLogger.w(_tag, '未找到设定: ${widget.settingId}');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '加载设定数据失败', e);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取设定类型图标
|
||||
IconData _getTypeIcon() {
|
||||
if (_settingItem?.type == null) return Icons.article;
|
||||
|
||||
final settingType = SettingType.fromValue(_settingItem!.type!);
|
||||
switch (settingType) {
|
||||
case SettingType.character:
|
||||
return Icons.person;
|
||||
case SettingType.location:
|
||||
return Icons.place;
|
||||
case SettingType.item:
|
||||
return Icons.inventory_2;
|
||||
case SettingType.lore:
|
||||
return Icons.public;
|
||||
case SettingType.event:
|
||||
return Icons.event;
|
||||
case SettingType.concept:
|
||||
return Icons.auto_awesome;
|
||||
case SettingType.faction:
|
||||
return Icons.groups;
|
||||
case SettingType.creature:
|
||||
return Icons.pets;
|
||||
case SettingType.magicSystem:
|
||||
return Icons.auto_fix_high;
|
||||
case SettingType.technology:
|
||||
return Icons.science;
|
||||
case SettingType.culture:
|
||||
return Icons.emoji_people;
|
||||
case SettingType.history:
|
||||
return Icons.history;
|
||||
case SettingType.organization:
|
||||
return Icons.apartment;
|
||||
case SettingType.worldview:
|
||||
return Icons.public;
|
||||
case SettingType.pleasurePoint:
|
||||
return Icons.whatshot;
|
||||
case SettingType.anticipationHook:
|
||||
return Icons.bolt;
|
||||
case SettingType.theme:
|
||||
return Icons.category;
|
||||
case SettingType.tone:
|
||||
return Icons.tonality;
|
||||
case SettingType.style:
|
||||
return Icons.brush;
|
||||
case SettingType.trope:
|
||||
return Icons.theater_comedy;
|
||||
case SettingType.plotDevice:
|
||||
return Icons.schema;
|
||||
case SettingType.powerSystem:
|
||||
return Icons.flash_on;
|
||||
case SettingType.timeline:
|
||||
return Icons.timeline;
|
||||
case SettingType.religion:
|
||||
return Icons.account_balance;
|
||||
case SettingType.politics:
|
||||
return Icons.gavel;
|
||||
case SettingType.economy:
|
||||
return Icons.attach_money;
|
||||
case SettingType.geography:
|
||||
return Icons.map;
|
||||
default:
|
||||
return Icons.article;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取设定类型显示名称
|
||||
String _getTypeDisplayName() {
|
||||
if (_settingItem?.type == null) return '其他';
|
||||
return SettingType.fromValue(_settingItem!.type!).displayName;
|
||||
}
|
||||
|
||||
/// 处理标题点击 - 修复Provider传递问题
|
||||
void _handleTitleTap() {
|
||||
AppLogger.d(_tag, '点击设定标题,打开详情卡片: ${_settingItem?.name}');
|
||||
|
||||
if (_settingItem == null) return;
|
||||
|
||||
// 关闭当前预览卡片
|
||||
_close();
|
||||
|
||||
// 延迟打开详情卡片,确保预览卡片完全关闭并且context仍然有效
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
// 🚀 修复:使用根context而不是当前组件的context,避免Provider丢失
|
||||
final rootContext = context;
|
||||
if (!rootContext.mounted) {
|
||||
AppLogger.w(_tag, '上下文已失效,无法打开详情卡片');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 🚀 在打开详情卡片前再次验证Provider可用性
|
||||
rootContext.read<SettingBloc>();
|
||||
rootContext.read<NovelSettingRepository>();
|
||||
rootContext.read<StorageRepository>();
|
||||
|
||||
AppLogger.d(_tag, '✅ Provider验证通过,打开详情卡片');
|
||||
|
||||
FloatingNovelSettingDetail.show(
|
||||
context: rootContext,
|
||||
itemId: _settingItem!.id,
|
||||
novelId: widget.novelId,
|
||||
groupId: _settingGroup?.id,
|
||||
isEditing: false,
|
||||
onSave: (item, groupId) {
|
||||
AppLogger.i(_tag, '设定详情保存成功: ${item.name}');
|
||||
},
|
||||
onCancel: () {
|
||||
AppLogger.d(_tag, '设定详情编辑取消');
|
||||
},
|
||||
);
|
||||
|
||||
widget.onDetailOpened?.call();
|
||||
} catch (e) {
|
||||
AppLogger.e(_tag, '打开详情卡片时Provider验证失败', e);
|
||||
// 尝试显示错误提示
|
||||
if (rootContext.mounted) {
|
||||
ScaffoldMessenger.of(rootContext).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('无法打开设定详情,请重试'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 关闭卡片
|
||||
void _close() {
|
||||
_animationController.reverse().then((_) {
|
||||
widget.onClose?.call();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
// 🎨 使用通用卡片组件 - 应用全局样式和主题
|
||||
const cardWidth = 340.0;
|
||||
const cardHeight = 220.0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_animationController, _positionController]),
|
||||
builder: (context, child) {
|
||||
// 🎯 使用动态位置或静态位置
|
||||
final position = _positionController.isAnimating
|
||||
? _positionAnimation.value
|
||||
: _currentPosition;
|
||||
|
||||
// 智能位置计算,确保卡片不超出屏幕边界
|
||||
double left = position.dx;
|
||||
double top = position.dy;
|
||||
|
||||
// 调整水平位置
|
||||
if (left + cardWidth > screenSize.width) {
|
||||
left = screenSize.width - cardWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
|
||||
// 调整垂直位置
|
||||
if (top + cardHeight > screenSize.height) {
|
||||
top = position.dy - cardHeight - 10; // 显示在鼠标上方
|
||||
}
|
||||
if (top < 16) {
|
||||
top = 16;
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Opacity(
|
||||
opacity: _opacityAnimation.value,
|
||||
child: GestureDetector(
|
||||
// 🎯 点击卡片区域不关闭卡片
|
||||
onTap: () {
|
||||
// 阻止事件冒泡
|
||||
},
|
||||
child: UniversalCard(
|
||||
config: UniversalCardConfig.preview.copyWith(
|
||||
width: cardWidth,
|
||||
showCloseButton: true,
|
||||
showHeader: false, // 我们自定义标题区域
|
||||
padding: EdgeInsets.zero, // 使用自定义padding
|
||||
),
|
||||
onClose: _close,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: cardHeight,
|
||||
),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建卡片内容
|
||||
Widget _buildCardContent() {
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'加载中...',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_settingItem == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 32,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'设定不存在',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 头部区域
|
||||
_buildHeader(),
|
||||
|
||||
// 分隔线
|
||||
Container(
|
||||
height: 1,
|
||||
color: WebTheme.grey200,
|
||||
),
|
||||
|
||||
// 内容区域
|
||||
Flexible(
|
||||
child: _buildContent(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头部区域
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
// 设定图片或类型图标
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _settingItem!.imageUrl != null && _settingItem!.imageUrl!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Image.network(
|
||||
_settingItem!.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Icon(
|
||||
_getTypeIcon(),
|
||||
size: 26,
|
||||
color: WebTheme.getTextColor(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_getTypeIcon(),
|
||||
size: 26,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 设定信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 设定名称(可点击)
|
||||
GestureDetector(
|
||||
onTap: _handleTitleTap,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Text(
|
||||
_settingItem!.name,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: WebTheme.getTextColor(context).withOpacity(0.4),
|
||||
decorationThickness: 1.2,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// 类型和设定组
|
||||
Row(
|
||||
children: [
|
||||
// 设定类型
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getTextColor(context).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
_getTypeDisplayName(),
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_settingGroup != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
// 设定组
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
_settingGroup!.name,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 关闭按钮
|
||||
GestureDetector(
|
||||
onTap: _close,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建内容区域
|
||||
Widget _buildContent() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 描述内容
|
||||
if (_settingItem!.description != null && _settingItem!.description!.isNotEmpty) ...[
|
||||
Text(
|
||||
'描述',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_settingItem!.description!,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else if (_settingItem!.content != null && _settingItem!.content!.isNotEmpty) ...[
|
||||
Text(
|
||||
'内容',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_settingItem!.content!,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无描述',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 提示文本
|
||||
Center(
|
||||
child: Text(
|
||||
'点击标题查看详情',
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 11,
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
608
AINoval/lib/widgets/common/settings_widgets.dart
Normal file
608
AINoval/lib/widgets/common/settings_widgets.dart
Normal file
@@ -0,0 +1,608 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 设置项卡片组件
|
||||
/// 提供统一的设置项容器样式
|
||||
class SettingsCard extends StatelessWidget {
|
||||
const SettingsCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.child,
|
||||
this.icon,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget child;
|
||||
final IconData? icon;
|
||||
final List<Widget>? actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey200
|
||||
: WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey100.withAlpha(128)
|
||||
: WebTheme.grey50,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: WebTheme.getTextColor(context, isPrimary: false),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actions != null) ...actions!,
|
||||
],
|
||||
),
|
||||
),
|
||||
// 内容区域
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 滑块设置组件
|
||||
/// 提供统一的滑块样式和标签
|
||||
class SettingsSlider extends StatelessWidget {
|
||||
const SettingsSlider({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.onChanged,
|
||||
this.divisions,
|
||||
this.unit = '',
|
||||
this.description,
|
||||
this.showValue = true,
|
||||
this.formatValue,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final double value;
|
||||
final double min;
|
||||
final double max;
|
||||
final ValueChanged<double> onChanged;
|
||||
final int? divisions;
|
||||
final String unit;
|
||||
final String? description;
|
||||
final bool showValue;
|
||||
final String Function(double)? formatValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showValue)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey200
|
||||
: WebTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
formatValue?.call(value) ?? '${value.toStringAsFixed(1)}$unit',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
activeTrackColor: WebTheme.getTextColor(context),
|
||||
inactiveTrackColor: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
thumbColor: WebTheme.getTextColor(context),
|
||||
overlayColor: WebTheme.getTextColor(context).withAlpha(51),
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
),
|
||||
child: Slider(
|
||||
value: value.clamp(min, max),
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开关设置组件
|
||||
/// 提供统一的开关样式和标签
|
||||
class SettingsSwitch extends StatelessWidget {
|
||||
const SettingsSwitch({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.description,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final String? description;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: WebTheme.getTextColor(context),
|
||||
inactiveTrackColor: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下拉选择设置组件
|
||||
/// 提供统一的下拉选择样式
|
||||
class SettingsDropdown<T> extends StatelessWidget {
|
||||
const SettingsDropdown({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
this.description,
|
||||
this.itemBuilder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final T value;
|
||||
final List<T> items;
|
||||
final ValueChanged<T?> onChanged;
|
||||
final String? description;
|
||||
final String Function(T)? itemBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButtonFormField<T>(
|
||||
value: value,
|
||||
decoration: WebTheme.getBorderlessInputDecoration(
|
||||
context: context,
|
||||
).copyWith(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
items: items.map((item) {
|
||||
return DropdownMenuItem<T>(
|
||||
value: item,
|
||||
child: Text(
|
||||
itemBuilder?.call(item) ?? item.toString(),
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
dropdownColor: WebTheme.getSurfaceColor(context),
|
||||
icon: Icon(
|
||||
Icons.expand_more,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 颜色选择设置组件
|
||||
/// 提供颜色选择器
|
||||
class SettingsColorPicker extends StatelessWidget {
|
||||
const SettingsColorPicker({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onChanged,
|
||||
this.description,
|
||||
this.colors,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final ValueChanged<Color> onChanged;
|
||||
final String? description;
|
||||
final List<Color>? colors;
|
||||
|
||||
static const List<Color> defaultColors = [
|
||||
Color(0xFF2196F3), // Blue
|
||||
Color(0xFF4CAF50), // Green
|
||||
Color(0xFFFF9800), // Orange
|
||||
Color(0xFFE91E63), // Pink
|
||||
Color(0xFF9C27B0), // Purple
|
||||
Color(0xFF607D8B), // Blue Grey
|
||||
Color(0xFF795548), // Brown
|
||||
Color(0xFF424242), // Grey
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorList = colors ?? defaultColors;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: colorList.map((colorOption) {
|
||||
final isSelected = color.value == colorOption.value;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(colorOption),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colorOption,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? WebTheme.getTextColor(context)
|
||||
: Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorOption.computeLuminance() > 0.5
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览组件
|
||||
/// 用于实时预览设置效果
|
||||
class SettingsPreview extends StatelessWidget {
|
||||
const SettingsPreview({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey100.withAlpha(128)
|
||||
: WebTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey200
|
||||
: WebTheme.grey200,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组标题组件
|
||||
/// 用于设置页面的分组标题
|
||||
class SettingsGroupTitle extends StatelessWidget {
|
||||
const SettingsGroupTitle({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 32, bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作按钮组件
|
||||
/// 提供保存、重置等操作按钮
|
||||
class SettingsActionBar extends StatelessWidget {
|
||||
const SettingsActionBar({
|
||||
super.key,
|
||||
this.onSave,
|
||||
this.onReset,
|
||||
this.onCancel,
|
||||
this.saveText = '保存',
|
||||
this.resetText = '重置',
|
||||
this.cancelText = '取消',
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
final VoidCallback? onSave;
|
||||
final VoidCallback? onReset;
|
||||
final VoidCallback? onCancel;
|
||||
final String saveText;
|
||||
final String resetText;
|
||||
final String cancelText;
|
||||
final bool isLoading;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.isDarkMode(context)
|
||||
? WebTheme.darkGrey200
|
||||
: WebTheme.grey200,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onReset != null) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : onReset,
|
||||
style: WebTheme.getSecondaryButtonStyle(context),
|
||||
child: Text(resetText),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
const Spacer(),
|
||||
if (onCancel != null) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : onCancel,
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
if (onSave != null)
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : onSave,
|
||||
style: WebTheme.getPrimaryButtonStyle(context),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
WebTheme.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(saveText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
216
AINoval/lib/widgets/common/smart_context_toggle.dart
Normal file
216
AINoval/lib/widgets/common/smart_context_toggle.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 智能上下文勾选组件
|
||||
/// 用于控制是否启用RAG智能检索上下文
|
||||
class SmartContextToggle extends StatelessWidget {
|
||||
/// 构造函数
|
||||
const SmartContextToggle({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.title = '智能上下文',
|
||||
this.description = '使用AI自动检索相关背景信息',
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
/// 当前状态
|
||||
final bool value;
|
||||
|
||||
/// 状态改变回调
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
/// 标题
|
||||
final String title;
|
||||
|
||||
/// 描述
|
||||
final String description;
|
||||
|
||||
/// 是否启用
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 勾选框和标题行
|
||||
Row(
|
||||
children: [
|
||||
// 自定义勾选框
|
||||
GestureDetector(
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: enabled
|
||||
? (value ? colorScheme.primary : Theme.of(context).colorScheme.outlineVariant)
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1.5,
|
||||
),
|
||||
color: enabled && value
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: enabled && value
|
||||
? Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: colorScheme.onPrimary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 标题和智能标识
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? () => onChanged(!value) : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: enabled
|
||||
? (isDark ? WebTheme.darkGrey800 : WebTheme.grey800)
|
||||
: (isDark ? WebTheme.darkGrey500 : WebTheme.grey500),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// AI智能标识
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: enabled
|
||||
? [
|
||||
colorScheme.primary.withOpacity(0.85),
|
||||
colorScheme.secondary.withOpacity(0.85),
|
||||
]
|
||||
: [
|
||||
colorScheme.onSurfaceVariant.withOpacity(0.25),
|
||||
colorScheme.onSurfaceVariant.withOpacity(0.25),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 10,
|
||||
color: enabled ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'AI',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: enabled ? colorScheme.onPrimary : colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 信息提示图标
|
||||
Tooltip(
|
||||
message: _getTooltipMessage(),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: isDark ? WebTheme.darkGrey500 : WebTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 描述文本
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: enabled
|
||||
? (isDark ? WebTheme.darkGrey600 : WebTheme.grey600)
|
||||
: (isDark ? WebTheme.darkGrey500 : WebTheme.grey500),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
// 启用状态下的额外说明
|
||||
if (enabled && value) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 12,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'AI将自动搜索相关的角色、场景、设定等背景信息',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取提示信息
|
||||
String _getTooltipMessage() {
|
||||
return '''智能上下文功能说明:
|
||||
• 启用后,AI会自动检索相关背景信息
|
||||
• 包括相关角色、场景、设定等内容
|
||||
• 提升AI生成内容的准确性和连贯性
|
||||
• 可能会增加一定的处理时间''';
|
||||
}
|
||||
}
|
||||
134
AINoval/lib/widgets/common/theme_toggle_button.dart
Normal file
134
AINoval/lib/widgets/common/theme_toggle_button.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/theme/theme_bloc.dart';
|
||||
import '../../blocs/theme/theme_event.dart';
|
||||
import '../../blocs/theme/theme_state.dart';
|
||||
import '../../utils/web_theme.dart';
|
||||
|
||||
class ThemeToggleButton extends StatelessWidget {
|
||||
final double? size;
|
||||
final Color? iconColor;
|
||||
final Color? backgroundColor;
|
||||
final bool showLabel;
|
||||
final String? tooltip;
|
||||
|
||||
const ThemeToggleButton({
|
||||
super.key,
|
||||
this.size,
|
||||
this.iconColor,
|
||||
this.backgroundColor,
|
||||
this.showLabel = false,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ThemeBloc, ThemeState>(
|
||||
builder: (context, state) {
|
||||
return _buildToggleButton(context, state);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToggleButton(BuildContext context, ThemeState state) {
|
||||
final isDarkTheme = WebTheme.isDarkMode(context);
|
||||
final iconData = _getIconData(state.themeMode);
|
||||
final label = _getLabel(state.themeMode);
|
||||
|
||||
// 确保按钮图标和背景有足够的对比度
|
||||
final buttonColor = backgroundColor ??
|
||||
(isDarkTheme ? WebTheme.darkGrey100 : WebTheme.grey100);
|
||||
final buttonIconColor = iconColor ??
|
||||
(isDarkTheme ? WebTheme.darkGrey800 : WebTheme.grey800);
|
||||
|
||||
if (showLabel) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDarkTheme ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => context.read<ThemeBloc>().add(ThemeToggled()),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
size: size ?? 20,
|
||||
color: buttonIconColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: buttonIconColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: buttonColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDarkTheme ? WebTheme.darkGrey300 : WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => context.read<ThemeBloc>().add(ThemeToggled()),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
iconData,
|
||||
size: size ?? 20,
|
||||
color: buttonIconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIconData(ThemeMode themeMode) {
|
||||
switch (themeMode) {
|
||||
case ThemeMode.light:
|
||||
return Icons.light_mode;
|
||||
case ThemeMode.dark:
|
||||
return Icons.dark_mode;
|
||||
case ThemeMode.system:
|
||||
return Icons.brightness_auto;
|
||||
}
|
||||
}
|
||||
|
||||
String _getLabel(ThemeMode themeMode) {
|
||||
switch (themeMode) {
|
||||
case ThemeMode.light:
|
||||
return '浅色';
|
||||
case ThemeMode.dark:
|
||||
return '深色';
|
||||
case ThemeMode.system:
|
||||
return '自动';
|
||||
}
|
||||
}
|
||||
}
|
||||
256
AINoval/lib/widgets/common/top_toast.dart
Normal file
256
AINoval/lib/widgets/common/top_toast.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 顶部吐司提示类型
|
||||
enum TopToastType {
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
}
|
||||
|
||||
/// 顶部吐司提示组件
|
||||
/// 在屏幕顶部居中显示简洁的提示消息,与整体设计风格保持一致
|
||||
class TopToast {
|
||||
static OverlayEntry? _currentOverlay;
|
||||
|
||||
/// 显示顶部提示
|
||||
///
|
||||
/// [context] - 上下文,用于获取主题和Overlay
|
||||
/// [message] - 提示消息文本
|
||||
/// [type] - 提示类型,决定图标和颜色
|
||||
/// [duration] - 显示时长,默认3秒
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
required String message,
|
||||
TopToastType type = TopToastType.info,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
// 如果有正在显示的toast,先移除它
|
||||
hide();
|
||||
|
||||
final overlay = Overlay.of(context);
|
||||
if (overlay == null) return;
|
||||
|
||||
_currentOverlay = OverlayEntry(
|
||||
builder: (context) => _TopToastWidget(
|
||||
message: message,
|
||||
type: type,
|
||||
onDismiss: hide,
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(_currentOverlay!);
|
||||
|
||||
// 自动隐藏
|
||||
Future.delayed(duration, () {
|
||||
hide();
|
||||
});
|
||||
}
|
||||
|
||||
/// 显示成功提示
|
||||
static void success(BuildContext context, String message) {
|
||||
show(context, message: message, type: TopToastType.success);
|
||||
}
|
||||
|
||||
/// 显示警告提示
|
||||
static void warning(BuildContext context, String message) {
|
||||
show(context, message: message, type: TopToastType.warning);
|
||||
}
|
||||
|
||||
/// 显示错误提示
|
||||
static void error(BuildContext context, String message) {
|
||||
show(context, message: message, type: TopToastType.error);
|
||||
}
|
||||
|
||||
/// 显示信息提示
|
||||
static void info(BuildContext context, String message) {
|
||||
show(context, message: message, type: TopToastType.info);
|
||||
}
|
||||
|
||||
/// 隐藏当前显示的提示
|
||||
static void hide() {
|
||||
_currentOverlay?.remove();
|
||||
_currentOverlay = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部吐司提示组件的内部实现
|
||||
class _TopToastWidget extends StatefulWidget {
|
||||
const _TopToastWidget({
|
||||
required this.message,
|
||||
required this.type,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final TopToastType type;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<_TopToastWidget> createState() => _TopToastWidgetState();
|
||||
}
|
||||
|
||||
class _TopToastWidgetState extends State<_TopToastWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _slideAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<double>(
|
||||
begin: -1.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
// 开始动画
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 获取提示类型对应的配置
|
||||
_ToastConfig _getConfig(bool isDark) {
|
||||
switch (widget.type) {
|
||||
case TopToastType.success:
|
||||
return _ToastConfig(
|
||||
icon: Icons.check_circle_outline,
|
||||
backgroundColor: WebTheme.success,
|
||||
textColor: Colors.white,
|
||||
);
|
||||
case TopToastType.warning:
|
||||
return _ToastConfig(
|
||||
icon: Icons.warning_outlined,
|
||||
backgroundColor: WebTheme.warning,
|
||||
textColor: Colors.white,
|
||||
);
|
||||
case TopToastType.error:
|
||||
return _ToastConfig(
|
||||
icon: Icons.error_outline,
|
||||
backgroundColor: WebTheme.error,
|
||||
textColor: Colors.white,
|
||||
);
|
||||
case TopToastType.info:
|
||||
return _ToastConfig(
|
||||
icon: Icons.info_outline,
|
||||
backgroundColor: isDark ? WebTheme.darkGrey100 : WebTheme.white,
|
||||
textColor: isDark ? WebTheme.darkGrey800 : WebTheme.grey800,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final config = _getConfig(isDark);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: 20 + (_slideAnimation.value * 60), // 从顶部向下滑入
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Opacity(
|
||||
opacity: _opacityAnimation.value,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 200,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: config.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
offset: const Offset(0, 4),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
border: widget.type == TopToastType.info
|
||||
? Border.all(
|
||||
color: isDark
|
||||
? WebTheme.darkGrey300
|
||||
: WebTheme.grey300,
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
config.icon,
|
||||
size: 18,
|
||||
color: config.textColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.message,
|
||||
style: TextStyle(
|
||||
color: config.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 提示配置类
|
||||
class _ToastConfig {
|
||||
const _ToastConfig({
|
||||
required this.icon,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
}
|
||||
976
AINoval/lib/widgets/common/unified_ai_model_dropdown.dart
Normal file
976
AINoval/lib/widgets/common/unified_ai_model_dropdown.dart
Normal file
@@ -0,0 +1,976 @@
|
||||
// import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../models/unified_ai_model.dart';
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
// import '../../models/public_model_config.dart';
|
||||
import '../../models/novel_structure.dart';
|
||||
import '../../models/novel_setting_item.dart';
|
||||
import '../../models/setting_group.dart';
|
||||
import '../../models/novel_snippet.dart';
|
||||
import '../../blocs/ai_config/ai_config_bloc.dart';
|
||||
import '../../blocs/public_models/public_models_bloc.dart';
|
||||
import '../../screens/chat/widgets/chat_settings_dialog.dart';
|
||||
import '../../config/provider_icons.dart';
|
||||
import '../../models/ai_request_models.dart';
|
||||
import '../../screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/screens/settings/settings_panel.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
// ==================== 统一 AI 模型下拉菜单 - 尺寸常量定义 ====================
|
||||
|
||||
/// 菜单整体尺寸配置
|
||||
class _MenuDimensions {
|
||||
/// 菜单固定宽度
|
||||
static const double menuWidth = 320.0;
|
||||
|
||||
/// 菜单默认最大高度
|
||||
static const double defaultMaxHeight = 900.0;
|
||||
|
||||
/// 屏幕边缘的安全边距,防止菜单被状态栏或导航栏遮挡
|
||||
static const double screenSafeMargin = 80.0;
|
||||
|
||||
/// 菜单最小高度(有设置按钮时)
|
||||
static const double minHeightWithSettings = 180.0;
|
||||
|
||||
/// 菜单最小高度(无设置按钮时)
|
||||
static const double minHeightWithoutSettings = 120.0;
|
||||
|
||||
/// 菜单与锚点的垂直间距
|
||||
static const double anchorVerticalOffset = 6.0;
|
||||
|
||||
/// 菜单水平边距
|
||||
static const double horizontalMargin = 16.0;
|
||||
}
|
||||
|
||||
/// 菜单内容区域尺寸配置
|
||||
class _ContentDimensions {
|
||||
/// 供应商分组标题高度
|
||||
static const double groupHeaderHeight = 36.0;
|
||||
|
||||
/// 单个模型项的高度(包含标签显示空间)
|
||||
static const double modelItemHeight = 40.0;
|
||||
|
||||
/// 底部操作按钮区域高度
|
||||
static const double bottomButtonHeight = 56.0;
|
||||
|
||||
/// 菜单内容的上下内边距
|
||||
static const double verticalPadding = 6.0;
|
||||
|
||||
/// 菜单内容的左右内边距
|
||||
static const double horizontalPadding = 4.0;
|
||||
}
|
||||
|
||||
/// 模型项内部尺寸配置
|
||||
class _ModelItemDimensions {
|
||||
/// 模型图标容器大小
|
||||
static const double iconContainerSize = 20.0;
|
||||
|
||||
/// 模型图标实际大小
|
||||
static const double iconSize = 12.0;
|
||||
|
||||
/// 模型图标与文字的间距
|
||||
static const double iconTextSpacing = 10.0;
|
||||
|
||||
/// 选中指示器图标大小
|
||||
static const double selectedIconSize = 16.0;
|
||||
|
||||
/// 模型项的水平内边距
|
||||
static const double itemHorizontalPadding = 12.0;
|
||||
|
||||
/// 模型项的垂直内边距
|
||||
static const double itemVerticalPadding = 10.0;
|
||||
|
||||
/// 模型项的外边距
|
||||
static const double itemMargin = 6.0;
|
||||
|
||||
/// 模型项的圆角半径
|
||||
static const double itemBorderRadius = 8.0;
|
||||
}
|
||||
|
||||
/// 标签样式尺寸配置
|
||||
class _TagDimensions {
|
||||
/// 标签水平内边距
|
||||
static const double tagHorizontalPadding = 6.0;
|
||||
|
||||
/// 标签垂直内边距
|
||||
static const double tagVerticalPadding = 2.0;
|
||||
|
||||
/// 标签圆角半径
|
||||
static const double tagBorderRadius = 8.0;
|
||||
|
||||
/// 标签边框宽度
|
||||
static const double tagBorderWidth = 0.5;
|
||||
|
||||
/// 标签之间的间距
|
||||
static const double tagSpacing = 4.0;
|
||||
|
||||
/// 标签行之间的间距
|
||||
static const double tagRunSpacing = 2.0;
|
||||
|
||||
/// 标签与模型名称的间距
|
||||
static const double tagTopSpacing = 2.0;
|
||||
}
|
||||
|
||||
/// 菜单外观样式配置
|
||||
class _MenuStyling {
|
||||
/// 菜单圆角半径
|
||||
static const double menuBorderRadius = 16.0;
|
||||
|
||||
/// 菜单边框宽度
|
||||
static const double menuBorderWidth = 0.8;
|
||||
|
||||
/// 分割线高度
|
||||
static const double dividerHeight = 8.0;
|
||||
|
||||
/// 分割线厚度
|
||||
static const double dividerThickness = 0.6;
|
||||
|
||||
/// 分割线缩进
|
||||
static const double dividerIndent = 16.0;
|
||||
|
||||
/// 分割线结束缩进
|
||||
static const double dividerEndIndent = 16.0;
|
||||
|
||||
/// 菜单阴影高度(暗色主题)
|
||||
static const double elevationDark = 12.0;
|
||||
|
||||
/// 菜单阴影高度(亮色主题)
|
||||
static const double elevationLight = 8.0;
|
||||
}
|
||||
|
||||
/// 底部操作区域尺寸配置
|
||||
class _BottomActionDimensions {
|
||||
/// 底部操作区域内边距
|
||||
static const double bottomPadding = 12.0;
|
||||
|
||||
/// 按钮垂直内边距
|
||||
static const double buttonVerticalPadding = 12.0;
|
||||
|
||||
/// 按钮圆角半径
|
||||
static const double buttonBorderRadius = 10.0;
|
||||
|
||||
/// 按钮边框宽度
|
||||
static const double buttonBorderWidth = 0.8;
|
||||
|
||||
/// 按钮图标大小
|
||||
static const double buttonIconSize = 18.0;
|
||||
|
||||
/// “添加我的私人模型”按钮的高度估算(用于高度计算)
|
||||
static const double secondaryButtonHeight = 44.0;
|
||||
}
|
||||
|
||||
/// 空状态显示尺寸配置
|
||||
class _EmptyStateDimensions {
|
||||
/// 空状态容器内边距
|
||||
static const double emptyPadding = 24.0;
|
||||
|
||||
/// 空状态图标大小
|
||||
static const double emptyIconSize = 48.0;
|
||||
|
||||
/// 空状态图标与文字的间距
|
||||
static const double emptyIconTextSpacing = 12.0;
|
||||
|
||||
/// 空状态标题与副标题的间距
|
||||
static const double emptyTitleSubtitleSpacing = 8.0;
|
||||
}
|
||||
|
||||
// ==================== 统一 AI 模型下拉菜单组件实现 ====================
|
||||
|
||||
/// 统一的AI模型下拉菜单组件,支持显示私有模型和公共模型
|
||||
/// 通过 [show] 静态方法弹出 Overlay 菜单
|
||||
class UnifiedAIModelDropdown {
|
||||
static OverlayEntry show({
|
||||
required BuildContext context,
|
||||
LayerLink? layerLink,
|
||||
Rect? anchorRect,
|
||||
UnifiedAIModel? selectedModel,
|
||||
required Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton = true,
|
||||
bool showAdjustAndGenerate = true,
|
||||
double maxHeight = _MenuDimensions.defaultMaxHeight,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
assert(layerLink != null || anchorRect != null, '必须提供 layerLink 或 anchorRect');
|
||||
|
||||
late OverlayEntry entry;
|
||||
bool _closed = false;
|
||||
|
||||
void safeClose() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
if (entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
onClose?.call();
|
||||
}
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 点击空白处关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: safeClose,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
if (layerLink != null) ...[
|
||||
Positioned(
|
||||
width: _MenuDimensions.menuWidth,
|
||||
child: CompositedTransformFollower(
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.bottomCenter,
|
||||
followerAnchor: Alignment.topCenter,
|
||||
offset: const Offset(0, _MenuDimensions.anchorVerticalOffset), // 向下偏移
|
||||
child: BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
// 结合当前屏幕高度动态限制菜单高度,避免超出屏幕导致无法滚动
|
||||
final screenH = MediaQuery.of(context).size.height;
|
||||
final double maxAllowableHeight = screenH - _MenuDimensions.screenSafeMargin;
|
||||
final menuHeight = _calculateMenuHeight(allModels, showSettingsButton, showAdjustAndGenerate, maxHeight)
|
||||
.clamp(0.0, maxAllowableHeight)
|
||||
.toDouble();
|
||||
return _buildMenuContainer(
|
||||
context,
|
||||
menuHeight,
|
||||
allModels,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
safeClose
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (anchorRect != null) ...[
|
||||
BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
// 结合当前屏幕高度动态限制菜单高度,避免超出屏幕导致无法滚动
|
||||
final screenH = MediaQuery.of(context).size.height;
|
||||
final double maxAllowableHeight = screenH - _MenuDimensions.screenSafeMargin;
|
||||
final menuHeight = _calculateMenuHeight(allModels, showSettingsButton, showAdjustAndGenerate, maxHeight)
|
||||
.clamp(0.0, maxAllowableHeight)
|
||||
.toDouble();
|
||||
return _buildPositionedMenu(
|
||||
context,
|
||||
anchorRect,
|
||||
menuHeight,
|
||||
allModels,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
safeClose
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// 合并私有模型和公共模型
|
||||
static List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
|
||||
final List<UnifiedAIModel> allModels = [];
|
||||
|
||||
// 添加已验证的私有模型
|
||||
final validatedConfigs = aiState.validatedConfigs;
|
||||
for (final config in validatedConfigs) {
|
||||
allModels.add(PrivateAIModel(config));
|
||||
}
|
||||
|
||||
// 添加公共模型
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
for (final publicModel in publicState.models) {
|
||||
allModels.add(PublicAIModel(publicModel));
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/// 按供应商分组模型,系统模型优先
|
||||
static Map<String, List<UnifiedAIModel>> _groupModelsByProvider(List<UnifiedAIModel> models) {
|
||||
final Map<String, List<UnifiedAIModel>> grouped = {};
|
||||
|
||||
for (var model in models) {
|
||||
final provider = model.provider;
|
||||
grouped.putIfAbsent(provider, () => []);
|
||||
grouped[provider]!.add(model);
|
||||
}
|
||||
|
||||
// 对每个供应商内的模型进行排序
|
||||
for (var list in grouped.values) {
|
||||
list.sort((a, b) {
|
||||
// 系统模型(公共模型)优先
|
||||
if (a.isPublic && !b.isPublic) return -1;
|
||||
if (!a.isPublic && b.isPublic) return 1;
|
||||
|
||||
// 如果都是公共模型,按优先级排序
|
||||
if (a.isPublic && b.isPublic) {
|
||||
final aPriority = (a as PublicAIModel).publicConfig.priority ?? 0;
|
||||
final bPriority = (b as PublicAIModel).publicConfig.priority ?? 0;
|
||||
if (aPriority != bPriority) {
|
||||
return bPriority.compareTo(aPriority); // 优先级高的在前
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都是私有模型,默认配置在前
|
||||
if (!a.isPublic && !b.isPublic) {
|
||||
final aIsDefault = (a as PrivateAIModel).userConfig.isDefault;
|
||||
final bIsDefault = (b as PrivateAIModel).userConfig.isDefault;
|
||||
if (aIsDefault && !bIsDefault) return -1;
|
||||
if (!aIsDefault && bIsDefault) return 1;
|
||||
}
|
||||
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// 计算菜单高度
|
||||
static double _calculateMenuHeight(
|
||||
List<UnifiedAIModel> models,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
double maxHeight,
|
||||
) {
|
||||
final grouped = _groupModelsByProvider(models);
|
||||
int totalItems = models.length;
|
||||
final bool hasPrivateModels = models.any((m) => !m.isPublic);
|
||||
final double addButtonHeight = showSettingsButton && !hasPrivateModels
|
||||
? (_BottomActionDimensions.secondaryButtonHeight + 8.0)
|
||||
: 0.0;
|
||||
final double adjustButtonHeight = showSettingsButton && showAdjustAndGenerate
|
||||
? _ContentDimensions.bottomButtonHeight
|
||||
: 0.0;
|
||||
final double contentHeight =
|
||||
(grouped.length * _ContentDimensions.groupHeaderHeight) +
|
||||
(totalItems * _ContentDimensions.modelItemHeight) +
|
||||
addButtonHeight +
|
||||
adjustButtonHeight +
|
||||
(_ContentDimensions.verticalPadding * 2);
|
||||
final double minHeight = showSettingsButton
|
||||
? _MenuDimensions.minHeightWithSettings
|
||||
: _MenuDimensions.minHeightWithoutSettings;
|
||||
return contentHeight.clamp(minHeight, maxHeight);
|
||||
}
|
||||
|
||||
static Widget _buildMenuContainer(
|
||||
BuildContext context,
|
||||
double menuHeight,
|
||||
List<UnifiedAIModel> models,
|
||||
UnifiedAIModel? selectedModel,
|
||||
Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings,
|
||||
List<SettingGroup> settingGroups,
|
||||
List<NovelSnippet> snippets,
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback onClose,
|
||||
) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Material(
|
||||
elevation: isDark ? _MenuStyling.elevationDark : _MenuStyling.elevationLight,
|
||||
borderRadius: BorderRadius.circular(_MenuStyling.menuBorderRadius),
|
||||
color: isDark
|
||||
? Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.95)
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shadowColor: Colors.black.withOpacity(isDark ? 0.3 : 0.15),
|
||||
child: Container(
|
||||
height: menuHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_MenuStyling.menuBorderRadius),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(isDark ? 0.2 : 0.3),
|
||||
width: _MenuStyling.menuBorderWidth,
|
||||
),
|
||||
),
|
||||
child: _UnifiedMenuContent(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
onModelSelected: onModelSelected,
|
||||
onClose: onClose,
|
||||
showSettingsButton: showSettingsButton,
|
||||
showAdjustAndGenerate: showAdjustAndGenerate,
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
chatConfig: chatConfig,
|
||||
onConfigChanged: onConfigChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildPositionedMenu(
|
||||
BuildContext context,
|
||||
Rect anchorRect,
|
||||
double menuHeight,
|
||||
List<UnifiedAIModel> models,
|
||||
UnifiedAIModel? selectedModel,
|
||||
Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings,
|
||||
List<SettingGroup> settingGroups,
|
||||
List<NovelSnippet> snippets,
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback onClose,
|
||||
) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
double left = anchorRect.left;
|
||||
if (left + _MenuDimensions.menuWidth > screenSize.width - _MenuDimensions.horizontalMargin) {
|
||||
left = screenSize.width - _MenuDimensions.menuWidth - _MenuDimensions.horizontalMargin;
|
||||
}
|
||||
|
||||
// 计算垂直放置位置,确保菜单完整显示在屏幕内
|
||||
double top = anchorRect.top - menuHeight - _MenuDimensions.anchorVerticalOffset; // 先尝试放在目标组件上方
|
||||
final double safeTop = MediaQuery.of(context).padding.top + 10;
|
||||
final double safeBottom = screenSize.height - 10;
|
||||
|
||||
// 如果上方空间不足则放到下方
|
||||
if (top < safeTop) {
|
||||
top = anchorRect.bottom + _MenuDimensions.anchorVerticalOffset;
|
||||
}
|
||||
|
||||
// 如果下方还是溢出,则将菜单整体上移
|
||||
if (top + menuHeight > safeBottom) {
|
||||
top = safeBottom - menuHeight;
|
||||
// 仍保证不碰到状态栏
|
||||
if (top < safeTop) {
|
||||
top = safeTop;
|
||||
}
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
width: _MenuDimensions.menuWidth,
|
||||
child: _buildMenuContainer(
|
||||
context,
|
||||
menuHeight,
|
||||
models,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
onClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ 内部菜单内容 ------------------
|
||||
class _UnifiedMenuContent extends StatelessWidget {
|
||||
const _UnifiedMenuContent({
|
||||
Key? key,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
required this.onClose,
|
||||
required this.showSettingsButton,
|
||||
required this.showAdjustAndGenerate,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<UnifiedAIModel> models;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final Function(UnifiedAIModel?) onModelSelected;
|
||||
final VoidCallback onClose;
|
||||
final bool showSettingsButton;
|
||||
final bool showAdjustAndGenerate;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final UniversalAIRequest? chatConfig;
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (models.isEmpty) {
|
||||
return _buildEmpty(context);
|
||||
}
|
||||
|
||||
final grouped = UnifiedAIModelDropdown._groupModelsByProvider(models);
|
||||
final providers = grouped.keys.toList();
|
||||
|
||||
// 供应商排序:有系统模型的供应商优先
|
||||
providers.sort((a, b) {
|
||||
final aHasPublic = grouped[a]!.any((m) => m.isPublic);
|
||||
final bHasPublic = grouped[b]!.any((m) => m.isPublic);
|
||||
if (aHasPublic && !bHasPublic) return -1;
|
||||
if (!aHasPublic && bHasPublic) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _ContentDimensions.horizontalPadding,
|
||||
vertical: _ContentDimensions.verticalPadding
|
||||
),
|
||||
itemCount: providers.length,
|
||||
separatorBuilder: (c, i) => Divider(
|
||||
height: _MenuStyling.dividerHeight,
|
||||
thickness: _MenuStyling.dividerThickness,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.12),
|
||||
indent: _MenuStyling.dividerIndent,
|
||||
endIndent: _MenuStyling.dividerEndIndent,
|
||||
),
|
||||
itemBuilder: (c, index) {
|
||||
final provider = providers[index];
|
||||
final providerModels = grouped[provider]!;
|
||||
return _ProviderGroup(
|
||||
provider: provider,
|
||||
models: providerModels,
|
||||
selectedModel: selectedModel,
|
||||
onModelSelected: (m) {
|
||||
onModelSelected(m);
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showSettingsButton) _buildBottomActions(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_EmptyStateDimensions.emptyPadding),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.model_training_outlined,
|
||||
size: _EmptyStateDimensions.emptyIconSize, color: cs.onSurfaceVariant.withOpacity(0.5)),
|
||||
const SizedBox(height: _EmptyStateDimensions.emptyIconTextSpacing),
|
||||
Text('无可用模型',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(height: _EmptyStateDimensions.emptyTitleSubtitleSpacing),
|
||||
Text('请先配置AI模型或等待公共模型加载',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withOpacity(0.7))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_BottomActionDimensions.bottomPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? cs.surface.withOpacity(0.8) : cs.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: cs.outlineVariant.withOpacity(isDark ? 0.15 : 0.2),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!models.any((m) => !m.isPublic)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onClose();
|
||||
// 优先尝试编辑器内打开
|
||||
try {
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
layoutManager.toggleSettingsPanel();
|
||||
return;
|
||||
} catch (_) {}
|
||||
// 回退:列表页等环境直接弹出设置对话框
|
||||
final userId = AppConfig.userId;
|
||||
if (userId == null || userId.isEmpty) {
|
||||
TopToast.info(context, '请先登录后再添加私人模型');
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: dialogContext.read<AiConfigBloc>()),
|
||||
],
|
||||
child: Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: SettingsPanel(
|
||||
stateManager: EditorStateManager(),
|
||||
userId: userId,
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('添加我的私人模型'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
foregroundColor: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
side: BorderSide(color: cs.primary.withOpacity(isDark ? 0.2 : 0.3), width: _BottomActionDimensions.buttonBorderWidth),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_BottomActionDimensions.buttonBorderRadius)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (showAdjustAndGenerate)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onClose(); // 先关闭 Overlay
|
||||
// 只有选中私有模型时才能进入设置对话框
|
||||
UserAIModelConfigModel? userModel;
|
||||
if (selectedModel != null && !selectedModel!.isPublic) {
|
||||
userModel = (selectedModel as PrivateAIModel).userConfig;
|
||||
}
|
||||
showChatSettingsDialog(
|
||||
context,
|
||||
selectedModel: userModel,
|
||||
onModelChanged: (m) {
|
||||
if (m != null) {
|
||||
onModelSelected(PrivateAIModel(m));
|
||||
}
|
||||
},
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
initialChatConfig: chatConfig,
|
||||
onConfigChanged: onConfigChanged,
|
||||
initialContextSelections: null, // 🚀 让ChatSettingsDialog自己构建上下文数据
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune_rounded, size: _BottomActionDimensions.buttonIconSize),
|
||||
label: const Text('调整并生成'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
backgroundColor: isDark ? cs.primaryContainer.withOpacity(0.08) : cs.primaryContainer.withOpacity(0.1),
|
||||
padding: const EdgeInsets.symmetric(vertical: _BottomActionDimensions.buttonVerticalPadding),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_BottomActionDimensions.buttonBorderRadius)),
|
||||
elevation: 0,
|
||||
side: BorderSide(color: cs.primary.withOpacity(isDark ? 0.2 : 0.3), width: _BottomActionDimensions.buttonBorderWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 供应商分组组件
|
||||
class _ProviderGroup extends StatelessWidget {
|
||||
const _ProviderGroup({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
final String provider;
|
||||
final List<UnifiedAIModel> models;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final Function(UnifiedAIModel?) onModelSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 检查是否有系统模型
|
||||
final hasPublicModels = models.any((m) => m.isPublic);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasPublicModels ? Icons.public : Icons.person_outline,
|
||||
size: 16,
|
||||
color: isDark ? cs.primary.withOpacity(0.8) : cs.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
provider.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${models.length}个',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...models.map((m) => _UnifiedModelItem(
|
||||
model: m,
|
||||
isSelected: selectedModel?.id == m.id,
|
||||
onTap: () => onModelSelected(m),
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnifiedModelItem extends StatelessWidget {
|
||||
const _UnifiedModelItem({
|
||||
Key? key,
|
||||
required this.model,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final UnifiedAIModel model;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(_ModelItemDimensions.itemBorderRadius),
|
||||
splashColor: cs.primary.withOpacity(0.08),
|
||||
highlightColor: cs.primary.withOpacity(0.04),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: _ModelItemDimensions.itemMargin, vertical: 1.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _ModelItemDimensions.itemHorizontalPadding,
|
||||
vertical: _ModelItemDimensions.itemVerticalPadding
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? cs.primaryContainer.withOpacity(0.2)
|
||||
: cs.primaryContainer.withOpacity(0.15))
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(_ModelItemDimensions.itemBorderRadius),
|
||||
border: isSelected
|
||||
? Border.all(color: cs.primary.withOpacity(0.2), width: 1.0)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: _getModelIcon(model.provider, context),
|
||||
),
|
||||
const SizedBox(width: _ModelItemDimensions.iconTextSpacing),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
model.displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? cs.primary
|
||||
: (isDark
|
||||
? cs.onSurface.withOpacity(0.9)
|
||||
: cs.onSurface),
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 显示所有标签
|
||||
if (model.modelTags.isNotEmpty) ...[
|
||||
const SizedBox(height: _TagDimensions.tagTopSpacing),
|
||||
Wrap(
|
||||
spacing: _TagDimensions.tagSpacing,
|
||||
runSpacing: _TagDimensions.tagRunSpacing,
|
||||
children: model.modelTags.map((tag) => _buildTag(tag, context)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(Icons.check_circle_rounded, size: _ModelItemDimensions.selectedIconSize, color: cs.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String tag, BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
Color tagColor;
|
||||
Color backgroundColor;
|
||||
Color borderColor;
|
||||
|
||||
if (tag == '私有') {
|
||||
tagColor = Colors.blue;
|
||||
backgroundColor = isDark ? Colors.blue.withOpacity(0.15) : Colors.blue.withOpacity(0.1);
|
||||
borderColor = Colors.blue.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (tag == '系统') {
|
||||
tagColor = Colors.green;
|
||||
backgroundColor = isDark ? Colors.green.withOpacity(0.15) : Colors.green.withOpacity(0.1);
|
||||
borderColor = Colors.green.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (tag == '推荐') {
|
||||
tagColor = Colors.orange;
|
||||
backgroundColor = isDark ? Colors.orange.withOpacity(0.15) : Colors.orange.withOpacity(0.1);
|
||||
borderColor = Colors.orange.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (tag == '免费') {
|
||||
tagColor = Colors.purple;
|
||||
backgroundColor = isDark ? Colors.purple.withOpacity(0.15) : Colors.purple.withOpacity(0.1);
|
||||
borderColor = Colors.purple.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else if (tag.contains('积分')) {
|
||||
tagColor = Colors.red;
|
||||
backgroundColor = isDark ? Colors.red.withOpacity(0.15) : Colors.red.withOpacity(0.1);
|
||||
borderColor = Colors.red.withOpacity(isDark ? 0.3 : 0.2);
|
||||
} else {
|
||||
tagColor = cs.outline;
|
||||
backgroundColor = isDark ? cs.surfaceVariant.withOpacity(0.3) : cs.surfaceVariant.withOpacity(0.5);
|
||||
borderColor = cs.outline.withOpacity(isDark ? 0.3 : 0.2);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _TagDimensions.tagHorizontalPadding,
|
||||
vertical: _TagDimensions.tagVerticalPadding
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(_TagDimensions.tagBorderRadius),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: _TagDimensions.tagBorderWidth,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tagColor.withOpacity(isDark ? 0.9 : 0.8),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getModelIcon(String provider, BuildContext context) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
width: _ModelItemDimensions.iconContainerSize,
|
||||
height: _ModelItemDimensions.iconContainerSize,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.white.withOpacity(0.9) : color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isDark ? color.withOpacity(0.3) : color.withOpacity(0.25),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ProviderIcons.getProviderIcon(provider, size: _ModelItemDimensions.iconSize, useHighQuality: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
395
AINoval/lib/widgets/common/universal_card.dart
Normal file
395
AINoval/lib/widgets/common/universal_card.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 通用卡片组件配置
|
||||
class UniversalCardConfig {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final BorderRadius? borderRadius;
|
||||
final List<BoxShadow>? shadows;
|
||||
final Border? border;
|
||||
final Color? backgroundColor;
|
||||
final bool showCloseButton;
|
||||
final bool showHeader;
|
||||
final double elevation;
|
||||
|
||||
const UniversalCardConfig({
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding = const EdgeInsets.all(20),
|
||||
this.margin,
|
||||
this.borderRadius,
|
||||
this.shadows,
|
||||
this.border,
|
||||
this.backgroundColor,
|
||||
this.showCloseButton = true,
|
||||
this.showHeader = true,
|
||||
this.elevation = 8.0,
|
||||
});
|
||||
|
||||
/// 复制并修改配置
|
||||
UniversalCardConfig copyWith({
|
||||
double? width,
|
||||
double? height,
|
||||
EdgeInsets? padding,
|
||||
EdgeInsets? margin,
|
||||
BorderRadius? borderRadius,
|
||||
List<BoxShadow>? shadows,
|
||||
Border? border,
|
||||
Color? backgroundColor,
|
||||
bool? showCloseButton,
|
||||
bool? showHeader,
|
||||
double? elevation,
|
||||
}) {
|
||||
return UniversalCardConfig(
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
padding: padding ?? this.padding,
|
||||
margin: margin ?? this.margin,
|
||||
borderRadius: borderRadius ?? this.borderRadius,
|
||||
shadows: shadows ?? this.shadows,
|
||||
border: border ?? this.border,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
showCloseButton: showCloseButton ?? this.showCloseButton,
|
||||
showHeader: showHeader ?? this.showHeader,
|
||||
elevation: elevation ?? this.elevation,
|
||||
);
|
||||
}
|
||||
|
||||
/// 预设配置 - 标准卡片
|
||||
static const standard = UniversalCardConfig(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
elevation: 8.0,
|
||||
padding: EdgeInsets.all(20),
|
||||
);
|
||||
|
||||
/// 预设配置 - 紧凑卡片
|
||||
static const compact = UniversalCardConfig(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
elevation: 4.0,
|
||||
padding: EdgeInsets.all(16),
|
||||
);
|
||||
|
||||
/// 预设配置 - 浮动预览卡片
|
||||
static const preview = UniversalCardConfig(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
elevation: 16.0,
|
||||
padding: EdgeInsets.all(20),
|
||||
showCloseButton: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 通用卡片组件
|
||||
///
|
||||
/// 提供统一的卡片样式和主题,支持自定义配置
|
||||
/// 应用 WebTheme 全局样式,确保视觉一致性
|
||||
class UniversalCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final UniversalCardConfig config;
|
||||
final String? title;
|
||||
final Widget? headerAction;
|
||||
final VoidCallback? onClose;
|
||||
final List<Widget>? actions;
|
||||
|
||||
const UniversalCard({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.config = UniversalCardConfig.standard,
|
||||
this.title,
|
||||
this.headerAction,
|
||||
this.onClose,
|
||||
this.actions,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: config.elevation,
|
||||
borderRadius: config.borderRadius ?? BorderRadius.circular(12),
|
||||
color: Colors.transparent,
|
||||
shadowColor: Colors.black.withOpacity(0.2),
|
||||
child: Container(
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
margin: config.margin,
|
||||
decoration: _getCardDecoration(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 可选的头部区域
|
||||
if (config.showHeader && (title != null || config.showCloseButton))
|
||||
_buildHeader(context),
|
||||
|
||||
// 主要内容区域
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: config.padding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
||||
// 可选的底部操作区域
|
||||
if (actions != null && actions!.isNotEmpty)
|
||||
_buildActions(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取卡片装饰样式
|
||||
BoxDecoration _getCardDecoration(BuildContext context) {
|
||||
return BoxDecoration(
|
||||
color: config.backgroundColor ?? WebTheme.white,
|
||||
borderRadius: config.borderRadius ?? BorderRadius.circular(12),
|
||||
border: config.border ?? Border.all(
|
||||
color: WebTheme.grey300,
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: config.shadows ?? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建头部区域
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
// 标题
|
||||
if (title != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
title!,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 头部操作
|
||||
if (headerAction != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
headerAction!,
|
||||
],
|
||||
|
||||
// 关闭按钮
|
||||
if (config.showCloseButton && onClose != null)
|
||||
_buildCloseButton(context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建关闭按钮
|
||||
Widget _buildCloseButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onClose,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作区域
|
||||
Widget _buildActions(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: WebTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化的卡片组件 - 用于无头部的场景
|
||||
class SimpleUniversalCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final UniversalCardConfig config;
|
||||
|
||||
const SimpleUniversalCard({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.config = UniversalCardConfig.compact,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: config.elevation,
|
||||
borderRadius: config.borderRadius ?? BorderRadius.circular(8),
|
||||
color: Colors.transparent,
|
||||
shadowColor: Colors.black.withOpacity(0.15),
|
||||
child: Container(
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
margin: config.margin,
|
||||
padding: config.padding,
|
||||
decoration: BoxDecoration(
|
||||
color: config.backgroundColor ?? WebTheme.white,
|
||||
borderRadius: config.borderRadius ?? BorderRadius.circular(8),
|
||||
border: config.border ?? Border.all(
|
||||
color: WebTheme.grey300,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: config.shadows ?? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片工具类 - 提供快速创建常用卡片的方法
|
||||
class UniversalCardUtils {
|
||||
/// 创建信息展示卡片
|
||||
static Widget createInfoCard({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
IconData? icon,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return SimpleUniversalCard(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
content,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建统计数据卡片
|
||||
static Widget createStatCard({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String value,
|
||||
IconData? icon,
|
||||
Color? valueColor,
|
||||
}) {
|
||||
return SimpleUniversalCard(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: valueColor ?? WebTheme.getTextColor(context),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
value,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: valueColor ?? WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: WebTheme.getAlignedTextStyle(
|
||||
baseStyle: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
612
AINoval/lib/widgets/common/user_avatar_menu.dart
Normal file
612
AINoval/lib/widgets/common/user_avatar_menu.dart
Normal file
@@ -0,0 +1,612 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/auth/auth_bloc.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:ainoval/screens/auth/enhanced_login_screen.dart';
|
||||
import 'package:ainoval/screens/user/user_settings_screen.dart';
|
||||
import 'package:ainoval/screens/settings/settings_panel.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
import 'package:ainoval/models/editor_settings.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 用户头像下拉菜单组件
|
||||
class UserAvatarMenu extends StatefulWidget {
|
||||
const UserAvatarMenu({
|
||||
Key? key,
|
||||
this.size = 16,
|
||||
this.showName = false,
|
||||
this.onMySubscription,
|
||||
this.onProfile,
|
||||
this.onAccountSettings,
|
||||
this.onHelp,
|
||||
this.onLogout,
|
||||
this.onOpenSettings,
|
||||
}) : super(key: key);
|
||||
|
||||
final double size;
|
||||
final bool showName;
|
||||
final VoidCallback? onMySubscription;
|
||||
final VoidCallback? onProfile;
|
||||
final VoidCallback? onAccountSettings;
|
||||
final VoidCallback? onHelp;
|
||||
final VoidCallback? onLogout;
|
||||
final VoidCallback? onOpenSettings;
|
||||
|
||||
@override
|
||||
State<UserAvatarMenu> createState() => _UserAvatarMenuState();
|
||||
}
|
||||
|
||||
class _UserAvatarMenuState extends State<UserAvatarMenu> {
|
||||
final GlobalKey _buttonKey = GlobalKey();
|
||||
OverlayEntry? _overlayEntry;
|
||||
bool _isMenuOpen = false;
|
||||
final GlobalKey _menuContentKey = GlobalKey();
|
||||
double? _resolvedMenuTop;
|
||||
double? _resolvedMenuLeft;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 只关闭overlay,不调用setState
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleMenu() {
|
||||
if (_isMenuOpen) {
|
||||
_closeMenu();
|
||||
} else {
|
||||
_openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
void _openMenu() {
|
||||
if (_buttonKey.currentContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final RenderBox renderBox = _buttonKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final Offset offset = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
const double baseMenuWidth = 240.0;
|
||||
// 默认对齐按钮右侧,向左展开,并作水平边界夹紧
|
||||
final double initialDesiredLeft = offset.dx + size.width - baseMenuWidth;
|
||||
final double initialLeft = initialDesiredLeft.clamp(8.0, screenWidth - baseMenuWidth - 8.0);
|
||||
_resolvedMenuLeft = initialLeft;
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Stack(
|
||||
children: [
|
||||
// 透明层,点击关闭菜单
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _closeMenu,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 菜单内容
|
||||
Positioned(
|
||||
top: _resolvedMenuTop ?? (offset.dy + size.height + 8),
|
||||
left: _resolvedMenuLeft,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
shadowColor: WebTheme.getShadowColor(context, opacity: 0.2),
|
||||
child: Container(
|
||||
key: _menuContentKey,
|
||||
width: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildMenuContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
setState(() {
|
||||
_isMenuOpen = true;
|
||||
});
|
||||
|
||||
// 计算菜单高度,若底部空间不足则向上展开
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final menuSize = _menuContentKey.currentContext?.size;
|
||||
if (menuSize == null) return;
|
||||
final media = MediaQuery.of(context);
|
||||
final screenHeight = media.size.height;
|
||||
final screenWidth = media.size.width;
|
||||
final spaceBelow = screenHeight - (offset.dy + size.height) - 8;
|
||||
if (spaceBelow < menuSize.height + 8) {
|
||||
final newTop = math.max(8.0, offset.dy - menuSize.height - 8);
|
||||
if (_resolvedMenuTop != newTop) {
|
||||
_resolvedMenuTop = newTop;
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
}
|
||||
} else {
|
||||
final newTop = offset.dy + size.height + 8;
|
||||
if (_resolvedMenuTop != newTop) {
|
||||
_resolvedMenuTop = newTop;
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
}
|
||||
}
|
||||
|
||||
// 根据实际菜单宽度再次夹紧水平位置,避免左/右越界
|
||||
final menuWidth = menuSize.width;
|
||||
final desiredLeft = offset.dx + size.width - menuWidth; // 右对齐按钮
|
||||
final clampedLeft = desiredLeft.clamp(8.0, screenWidth - menuWidth - 8.0);
|
||||
if (_resolvedMenuLeft != clampedLeft) {
|
||||
_resolvedMenuLeft = clampedLeft;
|
||||
_overlayEntry?.markNeedsBuild();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _closeMenu() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isMenuOpen = false;
|
||||
_resolvedMenuTop = null;
|
||||
_resolvedMenuLeft = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMenuContent() {
|
||||
final username = AppConfig.username ?? '游客';
|
||||
final userId = AppConfig.userId ?? '游客';
|
||||
final bool isAuthed = context.read<AuthBloc>().state is AuthAuthenticated;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 用户信息头部
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: WebTheme.getPrimaryColor(context).withOpacity(WebTheme.isDarkMode(context) ? 0.2 : 0.1),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 24,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
username,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'ID: $userId',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 分割线
|
||||
Divider(
|
||||
height: 1,
|
||||
color: WebTheme.getBorderColor(context),
|
||||
thickness: 1,
|
||||
),
|
||||
// 菜单项
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isAuthed) ...[
|
||||
_buildMenuItem(
|
||||
icon: Icons.person_outline,
|
||||
label: '个人资料',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onProfile != null) {
|
||||
widget.onProfile!.call();
|
||||
} else {
|
||||
_handleProfileTap();
|
||||
}
|
||||
},
|
||||
),
|
||||
_buildMenuItem(
|
||||
icon: Icons.workspace_premium,
|
||||
label: '我的订阅',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onMySubscription != null) {
|
||||
widget.onMySubscription!.call();
|
||||
} else {
|
||||
_openMySubscriptionPanel();
|
||||
}
|
||||
},
|
||||
),
|
||||
_buildMenuItem(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '账户设置',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onAccountSettings != null) {
|
||||
widget.onAccountSettings!.call();
|
||||
} else {
|
||||
_handleSettingsTap();
|
||||
}
|
||||
},
|
||||
),
|
||||
_buildMenuItem(
|
||||
icon: Icons.help_outline,
|
||||
label: '帮助中心',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onHelp != null) {
|
||||
widget.onHelp!.call();
|
||||
} else {
|
||||
_handleHelpTap();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: WebTheme.getBorderColor(context),
|
||||
thickness: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildMenuItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onLogout != null) {
|
||||
widget.onLogout!.call();
|
||||
} else {
|
||||
_handleLogout();
|
||||
}
|
||||
},
|
||||
isDestructive: true,
|
||||
),
|
||||
] else ...[
|
||||
_buildMenuItem(
|
||||
icon: Icons.login,
|
||||
label: '登录账号',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
_openLoginDialog();
|
||||
},
|
||||
),
|
||||
_buildMenuItem(
|
||||
icon: Icons.help_outline,
|
||||
label: '帮助中心',
|
||||
onTap: () {
|
||||
_closeMenu();
|
||||
if (widget.onHelp != null) {
|
||||
widget.onHelp!.call();
|
||||
} else {
|
||||
_handleHelpTap();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isDestructive
|
||||
? Theme.of(context).colorScheme.error
|
||||
: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDestructive
|
||||
? Theme.of(context).colorScheme.error
|
||||
: WebTheme.getTextColor(context),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleProfileTap() {
|
||||
// 通过onOpenSettings回调打开设置面板并定位到账户管理
|
||||
if (widget.onOpenSettings != null) {
|
||||
widget.onOpenSettings!.call();
|
||||
return;
|
||||
}
|
||||
// 回退:如果缺少回调,则尝试在当前上下文直接打开设置面板
|
||||
try {
|
||||
_openSettingsPanelFallback();
|
||||
} catch (_) {
|
||||
TopToast.info(context, '请通过设置面板查看个人资料');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSettingsTap() {
|
||||
if (widget.onOpenSettings != null) {
|
||||
widget.onOpenSettings!.call();
|
||||
return;
|
||||
}
|
||||
// 回退:优先尝试打开设置面板,其次再退回旧的设置页
|
||||
try {
|
||||
_openSettingsPanelFallback();
|
||||
return;
|
||||
} catch (_) {}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const UserSettingsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleHelpTap() {
|
||||
// TODO: 导航到帮助页面
|
||||
TopToast.info(context, '帮助中心功能开发中');
|
||||
}
|
||||
|
||||
void _openMySubscriptionPanel() {
|
||||
// 简单实现:打开设置面板并定位到“会员与订阅”标签页
|
||||
// 如果现有页面没有路由,先给出提示
|
||||
TopToast.info(context, '打开“我的订阅”,请在设置面板中查看会员与订阅标签');
|
||||
// TODO: 若有全局状态或路由可直接跳转到 SettingsPanel 并定位到会员页
|
||||
}
|
||||
|
||||
// 回退:在没有 onOpenSettings 的页面尝试直接弹出 SettingsPanel
|
||||
void _openSettingsPanelFallback() {
|
||||
// 需要 EditorLayoutManager/StateManager 等依赖在构造 SettingsPanel,
|
||||
// 在非编辑器页面使用最小依赖构造并通过 Dialog 弹出
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
// 延迟导入,避免循环依赖
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: SettingsPanel(
|
||||
stateManager: EditorStateManager(),
|
||||
userId: AppConfig.userId ?? 'current_user',
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
editorSettings: const EditorSettings(),
|
||||
onEditorSettingsChanged: (_) {},
|
||||
initialCategoryIndex: SettingsPanel.accountManagementCategoryIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLogout() {
|
||||
_showLogoutConfirmDialog();
|
||||
}
|
||||
|
||||
void _openLoginDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width >= 992
|
||||
? 960
|
||||
: MediaQuery.of(context).size.width - 32,
|
||||
height: MediaQuery.of(context).size.height - 32,
|
||||
child: const EnhancedLoginScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLogoutConfirmDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: WebTheme.getBackgroundColor(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.logout,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'确认退出',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'您确定要退出登录吗?退出后需要重新登录才能使用。',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: WebTheme.getSecondaryTextColor(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_performLogoutAndClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('退出登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _performLogoutAndClose() async {
|
||||
// 立即关闭对话框
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 显示简短的退出提示
|
||||
if (mounted) {
|
||||
TopToast.info(context, '正在退出登录...');
|
||||
}
|
||||
|
||||
// 稍微延迟后执行退出,确保UI更新完成
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
|
||||
if (mounted) {
|
||||
// 调用AuthBloc执行登出
|
||||
context.read<AuthBloc>().add(AuthLogout());
|
||||
|
||||
// 强制导航到登录页面,确保退出后立即跳转
|
||||
await Future.delayed(Duration(milliseconds: 200)); // 等待AuthBloc处理完毕
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/', // 回到根路由(登录页面)
|
||||
(route) => false, // 清除所有路由栈
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: _buttonKey,
|
||||
onTap: _toggleMenu,
|
||||
behavior: HitTestBehavior.opaque, // 确保整个区域都可点击
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8), // 增大点击区域
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _isMenuOpen
|
||||
? WebTheme.getSurfaceColor(context)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: widget.showName
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: widget.size,
|
||||
backgroundColor: WebTheme.getEmptyStateColor(context),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: widget.size * 1.2,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppConfig.username ?? '游客',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getTextColor(context),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_isMenuOpen ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
size: 16,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: CircleAvatar(
|
||||
radius: widget.size,
|
||||
backgroundColor: WebTheme.getEmptyStateColor(context),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: widget.size * 1.2,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
AINoval/lib/widgets/dialogs/change_password_dialog.dart
Normal file
106
AINoval/lib/widgets/dialogs/change_password_dialog.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/screens/user/change_password_screen.dart';
|
||||
import 'package:ainoval/widgets/forms/change_password_form.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
|
||||
/// 修改密码对话框
|
||||
/// 可以在任何地方调用此对话框来显示修改密码界面
|
||||
class ChangePasswordDialog {
|
||||
/// 显示修改密码对话框
|
||||
static void show(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 500,
|
||||
maxHeight: 700,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getBackgroundColor(context),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 对话框头部
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'修改密码',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
splashRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 对话框内容
|
||||
Expanded(
|
||||
child: ChangePasswordForm(
|
||||
showTitle: false,
|
||||
onSuccess: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示全屏修改密码页面(推荐用于移动端)
|
||||
static void showFullScreen(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ChangePasswordScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据屏幕尺寸自动选择显示方式
|
||||
static void showAdaptive(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
if (screenWidth > 768) {
|
||||
// 桌面端或平板端使用对话框
|
||||
show(context);
|
||||
} else {
|
||||
// 移动端使用全屏页面
|
||||
showFullScreen(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
520
AINoval/lib/widgets/editor/overlay_scene_beat_manager.dart
Normal file
520
AINoval/lib/widgets/editor/overlay_scene_beat_manager.dart
Normal file
@@ -0,0 +1,520 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/models/scene_beat_data.dart';
|
||||
import 'package:ainoval/models/ai_request_models.dart';
|
||||
import 'package:ainoval/models/unified_ai_model.dart';
|
||||
import 'package:ainoval/models/novel_structure.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/setting_group.dart';
|
||||
import 'package:ainoval/models/novel_snippet.dart';
|
||||
import 'package:ainoval/widgets/editor/overlay_scene_beat_panel.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
// 🚀 新增:导入编辑器状态相关类
|
||||
import 'package:ainoval/screens/editor/controllers/editor_screen_controller.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
|
||||
|
||||
/// 🚀 重构:纯数据管理器 - 只管理数据,不操作UI
|
||||
/// 全局单例,负责场景节拍数据的CRUD操作
|
||||
class SceneBeatDataManager {
|
||||
static SceneBeatDataManager? _instance;
|
||||
static SceneBeatDataManager get instance => _instance ??= SceneBeatDataManager._();
|
||||
|
||||
SceneBeatDataManager._();
|
||||
|
||||
// 🚀 核心:场景节拍数据缓存(场景ID -> 数据)
|
||||
final Map<String, SceneBeatData> _sceneDataCache = {};
|
||||
|
||||
// 🚀 核心:数据变化通知器(场景ID -> 通知器)
|
||||
final Map<String, ValueNotifier<SceneBeatData>> _dataNotifiers = {};
|
||||
|
||||
/// 获取场景数据的通知器(用于UI监听)
|
||||
ValueNotifier<SceneBeatData> getDataNotifier(String sceneId) {
|
||||
return _dataNotifiers.putIfAbsent(sceneId, () {
|
||||
final data = _sceneDataCache[sceneId] ?? SceneBeatData.createDefault(
|
||||
userId: AppConfig.userId ?? 'current-user', // 从AppConfig获取当前用户ID
|
||||
novelId: 'unknown', // TODO: 从场景上下文获取
|
||||
initialPrompt: '为当前场景生成场景节拍',
|
||||
);
|
||||
return ValueNotifier<SceneBeatData>(data);
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取场景数据(纯数据访问,不触发UI)
|
||||
SceneBeatData getSceneData(String sceneId) {
|
||||
final data = _sceneDataCache[sceneId];
|
||||
if (data != null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// 创建默认数据但不立即缓存
|
||||
return SceneBeatData.createDefault(
|
||||
userId: AppConfig.userId ?? 'current-user', // 从AppConfig获取当前用户ID
|
||||
novelId: 'unknown',
|
||||
initialPrompt: '为当前场景生成场景节拍',
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新场景数据(纯数据操作)
|
||||
void updateSceneData(String sceneId, SceneBeatData newData) {
|
||||
// 🚀 优化:检查数据是否真正发生变化
|
||||
final currentData = _sceneDataCache[sceneId];
|
||||
if (currentData != null && _isDataEqual(currentData, newData)) {
|
||||
AppLogger.v('SceneBeatDataManager', '📊 场景数据无变化,跳过更新: $sceneId');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('SceneBeatDataManager', '🔄 更新场景数据: $sceneId');
|
||||
|
||||
// 更新缓存
|
||||
_sceneDataCache[sceneId] = newData;
|
||||
|
||||
// 通知UI(如果有监听器的话)
|
||||
final notifier = _dataNotifiers[sceneId];
|
||||
if (notifier != null) {
|
||||
notifier.value = newData;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 判断两个SceneBeatData是否相等(基于关键字段)
|
||||
bool _isDataEqual(SceneBeatData data1, SceneBeatData data2) {
|
||||
return data1.requestData == data2.requestData &&
|
||||
data1.generatedContentDelta == data2.generatedContentDelta &&
|
||||
data1.selectedUnifiedModelId == data2.selectedUnifiedModelId &&
|
||||
data1.selectedLength == data2.selectedLength &&
|
||||
data1.temperature == data2.temperature &&
|
||||
data1.topP == data2.topP &&
|
||||
data1.enableSmartContext == data2.enableSmartContext &&
|
||||
data1.contextSelectionsData == data2.contextSelectionsData &&
|
||||
data1.status == data2.status &&
|
||||
data1.progress == data2.progress;
|
||||
}
|
||||
|
||||
/// 🚀 公开方法:判断两个SceneBeatData是否相等
|
||||
bool isDataEqual(SceneBeatData data1, SceneBeatData data2) {
|
||||
return _isDataEqual(data1, data2);
|
||||
}
|
||||
|
||||
/// 更新场景状态(便捷方法)
|
||||
void updateSceneStatus(String sceneId, SceneBeatStatus status) {
|
||||
final currentData = getSceneData(sceneId);
|
||||
final updatedData = currentData.updateStatus(status);
|
||||
updateSceneData(sceneId, updatedData);
|
||||
}
|
||||
|
||||
/// 清理场景数据
|
||||
void clearSceneData(String sceneId) {
|
||||
AppLogger.i('SceneBeatDataManager', '🗑️ 清理场景数据: $sceneId');
|
||||
_sceneDataCache.remove(sceneId);
|
||||
|
||||
final notifier = _dataNotifiers.remove(sceneId);
|
||||
notifier?.dispose();
|
||||
}
|
||||
|
||||
/// 清理所有数据
|
||||
void clearAllData() {
|
||||
AppLogger.i('SceneBeatDataManager', '🗑️ 清理所有场景节拍数据');
|
||||
_sceneDataCache.clear();
|
||||
|
||||
for (final notifier in _dataNotifiers.values) {
|
||||
notifier.dispose();
|
||||
}
|
||||
_dataNotifiers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 重构:UI管理器 - 只管理UI显示/隐藏,不处理数据
|
||||
/// 全局单例,负责浮动面板的显示状态管理
|
||||
class OverlaySceneBeatManager {
|
||||
static OverlaySceneBeatManager? _instance;
|
||||
static OverlaySceneBeatManager get instance => _instance ??= OverlaySceneBeatManager._();
|
||||
|
||||
OverlaySceneBeatManager._();
|
||||
|
||||
// 🚀 UI状态:当前显示的浮动面板
|
||||
OverlayEntry? _currentOverlay;
|
||||
|
||||
// 🚀 UI状态:当前场景ID(UI层面的概念)
|
||||
final ValueNotifier<String?> _currentSceneIdNotifier = ValueNotifier<String?>(null);
|
||||
|
||||
// 🚀 UI状态:显示状态
|
||||
bool _isVisible = false;
|
||||
|
||||
// 🚀 UI参数缓存(避免重复传递)
|
||||
Novel? _cachedNovel;
|
||||
List<NovelSettingItem> _cachedSettings = [];
|
||||
List<SettingGroup> _cachedSettingGroups = [];
|
||||
List<NovelSnippet> _cachedSnippets = [];
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? _cachedOnGenerate;
|
||||
|
||||
// 🚀 新增:编辑器状态监听
|
||||
EditorScreenController? _editorController;
|
||||
EditorLayoutManager? _layoutManager;
|
||||
VoidCallback? _editorControllerListener;
|
||||
VoidCallback? _layoutManagerListener;
|
||||
|
||||
/// 获取当前场景ID通知器(UI监听用)
|
||||
ValueNotifier<String?> get currentSceneIdNotifier => _currentSceneIdNotifier;
|
||||
|
||||
/// 获取当前场景ID
|
||||
String? get currentSceneId => _currentSceneIdNotifier.value;
|
||||
|
||||
/// 是否显示中
|
||||
bool get isVisible => _isVisible;
|
||||
|
||||
/// 🚀 新增:绑定编辑器状态监听
|
||||
void bindEditorState({
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🔗 绑定编辑器状态监听');
|
||||
|
||||
// 清理之前的监听器
|
||||
unbindEditorState();
|
||||
|
||||
_editorController = editorController;
|
||||
_layoutManager = layoutManager;
|
||||
|
||||
// 监听编辑器状态变化
|
||||
if (_editorController != null) {
|
||||
_editorControllerListener = () {
|
||||
_onEditorStateChanged();
|
||||
};
|
||||
_editorController!.addListener(_editorControllerListener!);
|
||||
}
|
||||
|
||||
// 监听布局管理器状态变化
|
||||
if (_layoutManager != null) {
|
||||
_layoutManagerListener = () {
|
||||
_onLayoutStateChanged();
|
||||
};
|
||||
_layoutManager!.addListener(_layoutManagerListener!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:解绑编辑器状态监听
|
||||
void unbindEditorState() {
|
||||
if (_editorController != null && _editorControllerListener != null) {
|
||||
_editorController!.removeListener(_editorControllerListener!);
|
||||
_editorController = null;
|
||||
_editorControllerListener = null;
|
||||
}
|
||||
|
||||
if (_layoutManager != null && _layoutManagerListener != null) {
|
||||
_layoutManager!.removeListener(_layoutManagerListener!);
|
||||
_layoutManager = null;
|
||||
_layoutManagerListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:处理编辑器状态变化
|
||||
void _onEditorStateChanged() {
|
||||
if (_editorController == null || !_isVisible) return;
|
||||
|
||||
// 检查是否切换到了其他视图
|
||||
final bool isInMainEditMode = !_editorController!.isPlanViewActive &&
|
||||
!_editorController!.isNextOutlineViewActive &&
|
||||
!_editorController!.isPromptViewActive;
|
||||
|
||||
if (!isInMainEditMode) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '📺 检测到视图切换,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:处理布局状态变化
|
||||
void _onLayoutStateChanged() {
|
||||
if (_layoutManager == null || !_isVisible) return;
|
||||
|
||||
// 检查是否有设置面板显示
|
||||
if (_layoutManager!.isSettingsPanelVisible) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '⚙️ 检测到设置面板显示,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
|
||||
// 检查是否有其他重要对话框显示
|
||||
if (_layoutManager!.isNovelSettingsVisible) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '📖 检测到小说设置显示,隐藏场景节拍面板');
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 显示浮动面板(只处理UI显示,不管理数据)
|
||||
void show({
|
||||
required BuildContext context,
|
||||
required String sceneId,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate,
|
||||
// 🚀 新增:可选的编辑器状态参数
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🎯 显示场景节拍面板: $sceneId');
|
||||
|
||||
// 🚀 绑定编辑器状态监听
|
||||
bindEditorState(
|
||||
editorController: editorController,
|
||||
layoutManager: layoutManager,
|
||||
);
|
||||
|
||||
// 🚀 检查当前是否在主编辑模式
|
||||
if (editorController != null) {
|
||||
final bool isInMainEditMode = !editorController.isPlanViewActive &&
|
||||
!editorController.isNextOutlineViewActive &&
|
||||
!editorController.isPromptViewActive;
|
||||
|
||||
if (!isInMainEditMode) {
|
||||
AppLogger.w('OverlaySceneBeatManager', '⚠️ 当前不在主编辑模式,跳过显示场景节拍面板');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 检查是否有设置面板显示
|
||||
if (layoutManager != null && layoutManager.isSettingsPanelVisible) {
|
||||
AppLogger.w('OverlaySceneBeatManager', '⚠️ 设置面板正在显示,跳过显示场景节拍面板');
|
||||
return;
|
||||
}
|
||||
|
||||
// 缓存参数
|
||||
_cachedNovel = novel;
|
||||
_cachedSettings = settings;
|
||||
_cachedSettingGroups = settingGroups;
|
||||
_cachedSnippets = snippets;
|
||||
_cachedOnGenerate = onGenerate;
|
||||
|
||||
// 如果已经显示,只切换场景
|
||||
if (_isVisible && _currentOverlay != null) {
|
||||
switchScene(sceneId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的浮动面板
|
||||
_currentOverlay = _createOverlayEntry(context, sceneId);
|
||||
|
||||
// 插入到Overlay中
|
||||
Overlay.of(context).insert(_currentOverlay!);
|
||||
|
||||
// 更新状态
|
||||
_isVisible = true;
|
||||
_currentSceneIdNotifier.value = sceneId;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ 场景节拍面板已显示');
|
||||
}
|
||||
|
||||
/// 🚀 切换场景(只更新场景ID,面板自动响应)
|
||||
void switchScene(String sceneId) {
|
||||
if (_currentSceneIdNotifier.value == sceneId) {
|
||||
AppLogger.v('OverlaySceneBeatManager', '场景ID相同,跳过切换: $sceneId');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '🔄 切换场景: ${_currentSceneIdNotifier.value} -> $sceneId');
|
||||
|
||||
// 只更新场景ID,UI会自动响应
|
||||
_currentSceneIdNotifier.value = sceneId;
|
||||
}
|
||||
|
||||
/// 🚀 隐藏面板(只处理UI隐藏)
|
||||
void hide() {
|
||||
if (!_isVisible || _currentOverlay == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '🫥 隐藏场景节拍面板');
|
||||
|
||||
// 移除浮动面板
|
||||
_currentOverlay!.remove();
|
||||
_currentOverlay = null;
|
||||
|
||||
// 更新状态
|
||||
_isVisible = false;
|
||||
_currentSceneIdNotifier.value = null;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ 场景节拍面板已隐藏');
|
||||
}
|
||||
|
||||
/// 🚀 切换显示状态
|
||||
void toggle({
|
||||
required BuildContext context,
|
||||
required String sceneId,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate,
|
||||
// 🚀 新增:可选的编辑器状态参数
|
||||
EditorScreenController? editorController,
|
||||
EditorLayoutManager? layoutManager,
|
||||
}) {
|
||||
if (_isVisible) {
|
||||
hide();
|
||||
} else {
|
||||
show(
|
||||
context: context,
|
||||
sceneId: sceneId,
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
onGenerate: onGenerate,
|
||||
editorController: editorController,
|
||||
layoutManager: layoutManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 创建浮动面板UI(新架构:UI独立管理)
|
||||
OverlayEntry _createOverlayEntry(BuildContext context, String initialSceneId) {
|
||||
return OverlayEntry(
|
||||
builder: (overlayContext) => ValueListenableBuilder<String?>(
|
||||
valueListenable: _currentSceneIdNotifier,
|
||||
builder: (context, currentSceneId, child) {
|
||||
if (currentSceneId == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SceneBeatFloatingPanel(
|
||||
sceneId: currentSceneId,
|
||||
novel: _cachedNovel,
|
||||
settings: _cachedSettings,
|
||||
settingGroups: _cachedSettingGroups,
|
||||
snippets: _cachedSnippets,
|
||||
onClose: hide,
|
||||
onGenerate: _cachedOnGenerate,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 修改:增强的释放资源方法
|
||||
void dispose() {
|
||||
AppLogger.i('OverlaySceneBeatManager', '🗑️ 开始释放UI管理器资源');
|
||||
|
||||
// 隐藏面板
|
||||
hide();
|
||||
|
||||
// 解绑编辑器状态监听
|
||||
unbindEditorState();
|
||||
|
||||
// 释放通知器
|
||||
_currentSceneIdNotifier.dispose();
|
||||
|
||||
// 清理缓存
|
||||
_cachedNovel = null;
|
||||
_cachedSettings = [];
|
||||
_cachedSettingGroups = [];
|
||||
_cachedSnippets = [];
|
||||
_cachedOnGenerate = null;
|
||||
|
||||
AppLogger.i('OverlaySceneBeatManager', '✅ UI管理器资源已释放');
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 新增:场景节拍浮动面板UI组件
|
||||
/// 职责:纯UI展示,通过监听数据管理器获取数据变化
|
||||
class SceneBeatFloatingPanel extends StatefulWidget {
|
||||
const SceneBeatFloatingPanel({
|
||||
super.key,
|
||||
required this.sceneId,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.onClose,
|
||||
this.onGenerate,
|
||||
});
|
||||
|
||||
final String sceneId;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final VoidCallback? onClose;
|
||||
final Function(String, UniversalAIRequest, UnifiedAIModel)? onGenerate;
|
||||
|
||||
@override
|
||||
State<SceneBeatFloatingPanel> createState() => _SceneBeatFloatingPanelState();
|
||||
}
|
||||
|
||||
class _SceneBeatFloatingPanelState extends State<SceneBeatFloatingPanel> {
|
||||
// 🚀 数据监听器(只监听当前场景的数据变化)
|
||||
late ValueNotifier<SceneBeatData> _dataNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupDataListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SceneBeatFloatingPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 🚀 只有场景ID变化时才重新设置监听器
|
||||
if (oldWidget.sceneId != widget.sceneId) {
|
||||
AppLogger.i('SceneBeatFloatingPanel', '🔄 场景切换,重新设置数据监听: ${oldWidget.sceneId} -> ${widget.sceneId}');
|
||||
_setupDataListener();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🚀 设置数据监听器(核心:数据和UI分离)
|
||||
void _setupDataListener() {
|
||||
// 获取当前场景的数据通知器
|
||||
_dataNotifier = SceneBeatDataManager.instance.getDataNotifier(widget.sceneId);
|
||||
|
||||
AppLogger.i('SceneBeatFloatingPanel', '📡 设置场景数据监听: ${widget.sceneId}');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🚀 核心:优化重建策略,减少不必要的重建
|
||||
return ValueListenableBuilder<SceneBeatData>(
|
||||
valueListenable: _dataNotifier,
|
||||
// 🚀 使用 child 参数缓存不需要重建的部分
|
||||
child: _buildStaticContent(),
|
||||
builder: (context, sceneBeatData, child) {
|
||||
// 🚀 直接返回面板,避免ParentData冲突
|
||||
return OverlaySceneBeatPanel(
|
||||
sceneId: widget.sceneId,
|
||||
data: sceneBeatData,
|
||||
novel: widget.novel,
|
||||
settings: widget.settings,
|
||||
settingGroups: widget.settingGroups,
|
||||
snippets: widget.snippets,
|
||||
onClose: widget.onClose,
|
||||
onGenerate: widget.onGenerate != null
|
||||
? (request, model) => widget.onGenerate!(widget.sceneId, request, model)
|
||||
: null,
|
||||
onDataChanged: (newData) {
|
||||
// 🚀 避免无谓的更新:只在数据真正改变时才更新
|
||||
if (_shouldUpdateData(sceneBeatData, newData)) {
|
||||
SceneBeatDataManager.instance.updateSceneData(widget.sceneId, newData);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 🚀 构建静态内容(不需要监听数据变化的部分)
|
||||
Widget _buildStaticContent() {
|
||||
// 这里可以放置不依赖于数据的静态组件
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// 🚀 判断是否需要更新数据(避免无意义的更新)
|
||||
bool _shouldUpdateData(SceneBeatData oldData, SceneBeatData newData) {
|
||||
// 🚀 简化:利用数据管理器的公开相等性检查方法
|
||||
return !SceneBeatDataManager.instance.isDataEqual(oldData, newData);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 🚀 不需要手动dispose _dataNotifier,由数据管理器统一管理
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
1762
AINoval/lib/widgets/editor/overlay_scene_beat_panel.dart
Normal file
1762
AINoval/lib/widgets/editor/overlay_scene_beat_panel.dart
Normal file
File diff suppressed because it is too large
Load Diff
323
AINoval/lib/widgets/editor/slash_command_menu.dart
Normal file
323
AINoval/lib/widgets/editor/slash_command_menu.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:ainoval/utils/logger.dart';
|
||||
|
||||
/// 斜杠命令类型
|
||||
enum SlashCommandType {
|
||||
sceneBeat,
|
||||
continue_,
|
||||
summary,
|
||||
refactor,
|
||||
dialogue,
|
||||
sceneDescription;
|
||||
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return '场景节拍';
|
||||
case SlashCommandType.continue_:
|
||||
return '续写';
|
||||
case SlashCommandType.summary:
|
||||
return '摘要';
|
||||
case SlashCommandType.refactor:
|
||||
return '重构';
|
||||
case SlashCommandType.dialogue:
|
||||
return '对话';
|
||||
case SlashCommandType.sceneDescription:
|
||||
return '描述';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return Icons.waves_outlined;
|
||||
case SlashCommandType.continue_:
|
||||
return Icons.edit_outlined;
|
||||
case SlashCommandType.summary:
|
||||
return Icons.summarize_outlined;
|
||||
case SlashCommandType.refactor:
|
||||
return Icons.transform_outlined;
|
||||
case SlashCommandType.dialogue:
|
||||
return Icons.chat_bubble_outline;
|
||||
case SlashCommandType.sceneDescription:
|
||||
return Icons.landscape_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
String get desc {
|
||||
switch (this) {
|
||||
case SlashCommandType.sceneBeat:
|
||||
return '一个关键时刻,重要的事情发生改变,推动故事发展';
|
||||
case SlashCommandType.continue_:
|
||||
return '基于当前上下文继续创作内容';
|
||||
case SlashCommandType.summary:
|
||||
return '生成当前内容的摘要';
|
||||
case SlashCommandType.refactor:
|
||||
return '重新整理和优化现有内容';
|
||||
case SlashCommandType.dialogue:
|
||||
return '生成角色之间的对话';
|
||||
case SlashCommandType.sceneDescription:
|
||||
return '添加场景或人物的详细描述';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 斜杠命令菜单组件
|
||||
class SlashCommandMenu extends StatefulWidget {
|
||||
const SlashCommandMenu({
|
||||
super.key,
|
||||
required this.position,
|
||||
required this.onCommandSelected,
|
||||
this.onDismiss,
|
||||
this.availableCommands = SlashCommandType.values,
|
||||
this.maxWidth = 280,
|
||||
});
|
||||
|
||||
/// 菜单显示位置
|
||||
final Offset position;
|
||||
|
||||
/// 命令被选中时的回调
|
||||
final Function(SlashCommandType) onCommandSelected;
|
||||
|
||||
/// 菜单被取消时的回调
|
||||
final VoidCallback? onDismiss;
|
||||
|
||||
/// 可用的命令列表
|
||||
final List<SlashCommandType> availableCommands;
|
||||
|
||||
/// 菜单最大宽度
|
||||
final double maxWidth;
|
||||
|
||||
@override
|
||||
State<SlashCommandMenu> createState() => _SlashCommandMenuState();
|
||||
}
|
||||
|
||||
class _SlashCommandMenuState extends State<SlashCommandMenu>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
));
|
||||
|
||||
_opacityAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _selectCommand(SlashCommandType command) {
|
||||
AppLogger.d('SlashCommandMenu', '选择命令: ${command.displayName}');
|
||||
widget.onCommandSelected(command);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _opacityAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.maxWidth,
|
||||
maxHeight: 400,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'AI 写作助手',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outline.withOpacity(0.1),
|
||||
),
|
||||
|
||||
// 命令列表
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.availableCommands.length,
|
||||
itemBuilder: (context, index) {
|
||||
final command = widget.availableCommands[index];
|
||||
final isSelected = index == _selectedIndex;
|
||||
|
||||
return _buildCommandItem(
|
||||
theme,
|
||||
command,
|
||||
isSelected,
|
||||
() => _selectCommand(command),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 提示文字
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Text(
|
||||
'使用 ↑↓ 选择,Enter 确认,Esc 取消',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandItem(
|
||||
ThemeData theme,
|
||||
SlashCommandType command,
|
||||
bool isSelected,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onHover: (hovering) {
|
||||
if (hovering) {
|
||||
setState(() {
|
||||
_selectedIndex = widget.availableCommands.indexOf(command);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: isSelected
|
||||
? theme.colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
command.icon,
|
||||
size: 16,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
command.displayName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
command.desc,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 12,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
AINoval/lib/widgets/editor/slash_command_overlay.dart
Normal file
47
AINoval/lib/widgets/editor/slash_command_overlay.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ainoval/widgets/editor/slash_command_menu.dart';
|
||||
|
||||
/// 斜杠命令覆盖层
|
||||
/// 用于在编辑器上显示命令选择菜单
|
||||
class SlashCommandOverlay {
|
||||
static OverlayEntry? _overlayEntry;
|
||||
|
||||
/// 显示斜杠命令菜单
|
||||
static void show({
|
||||
required BuildContext context,
|
||||
required Offset position,
|
||||
required Function(SlashCommandType) onCommandSelected,
|
||||
required VoidCallback onDismiss,
|
||||
required List<SlashCommandType> availableCommands,
|
||||
}) {
|
||||
// 如果已经显示了菜单,先隐藏
|
||||
hide();
|
||||
|
||||
_overlayEntry = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SlashCommandMenu(
|
||||
position: position,
|
||||
onCommandSelected: onCommandSelected,
|
||||
onDismiss: onDismiss,
|
||||
availableCommands: availableCommands,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_overlayEntry!);
|
||||
}
|
||||
|
||||
/// 隐藏斜杠命令菜单
|
||||
static void hide() {
|
||||
_overlayEntry?.remove();
|
||||
_overlayEntry = null;
|
||||
}
|
||||
|
||||
/// 检查是否正在显示菜单
|
||||
static bool get isShowing => _overlayEntry != null;
|
||||
}
|
||||
390
AINoval/lib/widgets/forms/change_password_form.dart
Normal file
390
AINoval/lib/widgets/forms/change_password_form.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/blocs/auth/auth_bloc.dart';
|
||||
import 'package:ainoval/services/auth_service.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 修改密码表单组件
|
||||
/// 可以在对话框或页面中复用的修改密码表单
|
||||
class ChangePasswordForm extends StatefulWidget {
|
||||
const ChangePasswordForm({
|
||||
Key? key,
|
||||
this.onSuccess,
|
||||
this.showTitle = true,
|
||||
}) : super(key: key);
|
||||
|
||||
final VoidCallback? onSuccess;
|
||||
final bool showTitle;
|
||||
|
||||
@override
|
||||
State<ChangePasswordForm> createState() => _ChangePasswordFormState();
|
||||
}
|
||||
|
||||
class _ChangePasswordFormState extends State<ChangePasswordForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _currentPasswordController = TextEditingController();
|
||||
final _newPasswordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
|
||||
bool _isCurrentPasswordVisible = false;
|
||||
bool _isNewPasswordVisible = false;
|
||||
bool _isConfirmPasswordVisible = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPasswordController.dispose();
|
||||
_newPasswordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 验证密码强度
|
||||
String? _validatePasswordStrength(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入新密码';
|
||||
}
|
||||
|
||||
if (value.length < 8) {
|
||||
return '密码长度至少为8位';
|
||||
}
|
||||
|
||||
// 检查是否包含数字
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||
return '密码必须包含至少一个数字';
|
||||
}
|
||||
|
||||
// 检查是否包含字母
|
||||
if (!RegExp(r'[a-zA-Z]').hasMatch(value)) {
|
||||
return '密码必须包含至少一个字母';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 提交修改密码表单
|
||||
Future<void> _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final authService = context.read<AuthService>();
|
||||
await authService.changePassword(
|
||||
_currentPasswordController.text,
|
||||
_newPasswordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
TopToast.success(context, '密码修改成功');
|
||||
widget.onSuccess?.call();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
String errorMessage;
|
||||
if (e.toString().contains('当前密码')) {
|
||||
errorMessage = '当前密码错误,请重新输入';
|
||||
} else if (e.toString().contains('认证已过期')) {
|
||||
errorMessage = '登录已过期,请重新登录';
|
||||
// 可以选择跳转到登录页面
|
||||
context.read<AuthBloc>().add(AuthLogout());
|
||||
} else {
|
||||
errorMessage = '密码修改失败:${e.toString().replaceAll('AuthException: ', '')}';
|
||||
}
|
||||
TopToast.error(context, errorMessage);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 头部图标和标题
|
||||
if (widget.showTitle) ...[
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.lock_outline,
|
||||
size: 40,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'修改密码',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'为了您的账户安全,请定期更换密码',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
// 当前密码输入框
|
||||
_buildPasswordField(
|
||||
controller: _currentPasswordController,
|
||||
label: '当前密码',
|
||||
hint: '请输入当前密码',
|
||||
isVisible: _isCurrentPasswordVisible,
|
||||
onVisibilityToggle: () {
|
||||
setState(() {
|
||||
_isCurrentPasswordVisible = !_isCurrentPasswordVisible;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入当前密码';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 新密码输入框
|
||||
_buildPasswordField(
|
||||
controller: _newPasswordController,
|
||||
label: '新密码',
|
||||
hint: '请输入新密码(至少8位,包含字母和数字)',
|
||||
isVisible: _isNewPasswordVisible,
|
||||
onVisibilityToggle: () {
|
||||
setState(() {
|
||||
_isNewPasswordVisible = !_isNewPasswordVisible;
|
||||
});
|
||||
},
|
||||
validator: _validatePasswordStrength,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 确认新密码输入框
|
||||
_buildPasswordField(
|
||||
controller: _confirmPasswordController,
|
||||
label: '确认新密码',
|
||||
hint: '请再次输入新密码',
|
||||
isVisible: _isConfirmPasswordVisible,
|
||||
onVisibilityToggle: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordVisible = !_isConfirmPasswordVisible;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请确认新密码';
|
||||
}
|
||||
if (value != _newPasswordController.text) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 提交按钮
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: WebTheme.getPrimaryColor(context),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
disabledBackgroundColor: WebTheme.getSecondaryTextColor(context).withOpacity(0.3),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'修改密码',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 安全提示
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: WebTheme.getPrimaryColor(context).withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security,
|
||||
size: 20,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'密码安全提示',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'• 密码长度至少8位\n'
|
||||
'• 包含字母和数字\n'
|
||||
'• 不要使用简单的密码\n'
|
||||
'• 定期更换密码以保证安全',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建密码输入框
|
||||
Widget _buildPasswordField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required bool isVisible,
|
||||
required VoidCallback onVisibilityToggle,
|
||||
required String? Function(String?) validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
obscureText: !isVisible,
|
||||
validator: validator,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
isVisible ? Icons.visibility_off : Icons.visibility,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
onPressed: onVisibilityToggle,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: WebTheme.getBackgroundColor(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getBorderColor(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: WebTheme.getPrimaryColor(context),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
fontSize: 16,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.7),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1035
AINoval/lib/widgets/setting/setting_relations_tab.dart
Normal file
1035
AINoval/lib/widgets/setting/setting_relations_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
500
AINoval/lib/widgets/setting/setting_tracking_tab.dart
Normal file
500
AINoval/lib/widgets/setting/setting_tracking_tab.dart
Normal file
@@ -0,0 +1,500 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:ainoval/models/novel_setting_item.dart';
|
||||
import 'package:ainoval/models/ai_context_tracking.dart';
|
||||
import 'package:ainoval/blocs/setting/setting_bloc.dart';
|
||||
import 'package:ainoval/utils/web_theme.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
/// 设定追踪配置标签页
|
||||
class SettingTrackingTab extends StatefulWidget {
|
||||
final NovelSettingItem settingItem;
|
||||
final String novelId;
|
||||
final Function(NovelSettingItem) onItemUpdated;
|
||||
|
||||
const SettingTrackingTab({
|
||||
Key? key,
|
||||
required this.settingItem,
|
||||
required this.novelId,
|
||||
required this.onItemUpdated,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SettingTrackingTab> createState() => _SettingTrackingTabState();
|
||||
}
|
||||
|
||||
class _SettingTrackingTabState extends State<SettingTrackingTab> {
|
||||
late NameAliasTracking _nameAliasTracking;
|
||||
late AIContextTracking _aiContextTracking;
|
||||
late SettingReferenceUpdate _referenceUpdatePolicy;
|
||||
bool _hasChanges = false;
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameAliasTracking = widget.settingItem.nameAliasTracking;
|
||||
_aiContextTracking = widget.settingItem.aiContextTracking;
|
||||
_referenceUpdatePolicy = widget.settingItem.referenceUpdatePolicy;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 名称/别名追踪设置
|
||||
_buildNameAliasTrackingSection(isDark),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// AI上下文追踪设置
|
||||
_buildAIContextTrackingSection(isDark),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 引用更新策略设置
|
||||
_buildReferenceUpdateSection(isDark),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 保存按钮
|
||||
if (_hasChanges) _buildSaveButton(isDark),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建名称/别名追踪设置区域
|
||||
Widget _buildNameAliasTrackingSection(bool isDark) {
|
||||
return _buildSettingSection(
|
||||
title: '名称/别名追踪',
|
||||
description: '控制是否通过名称和别名来追踪此设定条目',
|
||||
icon: Icons.label,
|
||||
iconColor: Colors.blue,
|
||||
child: Column(
|
||||
children: NameAliasTracking.values.map((option) {
|
||||
return _buildRadioTile<NameAliasTracking>(
|
||||
value: option,
|
||||
groupValue: _nameAliasTracking,
|
||||
title: option.displayName,
|
||||
description: option.description,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_nameAliasTracking = value;
|
||||
_hasChanges = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
isDark: isDark,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建AI上下文追踪设置区域
|
||||
Widget _buildAIContextTrackingSection(bool isDark) {
|
||||
return _buildSettingSection(
|
||||
title: 'AI上下文',
|
||||
description: '控制此设定条目如何包含在AI上下文中',
|
||||
icon: Icons.psychology,
|
||||
iconColor: Colors.purple,
|
||||
child: Column(
|
||||
children: AIContextTracking.values.map((option) {
|
||||
return _buildRadioTile<AIContextTracking>(
|
||||
value: option,
|
||||
groupValue: _aiContextTracking,
|
||||
title: option.displayName,
|
||||
description: option.description,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_aiContextTracking = value;
|
||||
_hasChanges = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
isDark: isDark,
|
||||
isRecommended: option == AIContextTracking.detected,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建引用更新策略设置区域
|
||||
Widget _buildReferenceUpdateSection(bool isDark) {
|
||||
return _buildSettingSection(
|
||||
title: '引用更新策略',
|
||||
description: '当修改此设定时,如何处理引用此设定的其他内容',
|
||||
icon: Icons.update,
|
||||
iconColor: Colors.orange,
|
||||
child: Column(
|
||||
children: SettingReferenceUpdate.values.map((option) {
|
||||
return _buildRadioTile<SettingReferenceUpdate>(
|
||||
value: option,
|
||||
groupValue: _referenceUpdatePolicy,
|
||||
title: option.displayName,
|
||||
description: option.description,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_referenceUpdatePolicy = value;
|
||||
_hasChanges = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
isDark: isDark,
|
||||
isRecommended: option == SettingReferenceUpdate.ask,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建设置区域的通用框架
|
||||
Widget _buildSettingSection({
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required Widget child,
|
||||
}) {
|
||||
final isDark = WebTheme.isDarkMode(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey100 : WebTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey200,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题区域
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 选项内容
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建单选按钮瓦片
|
||||
Widget _buildRadioTile<T>({
|
||||
required T value,
|
||||
required T groupValue,
|
||||
required String title,
|
||||
required String description,
|
||||
required ValueChanged<T?> onChanged,
|
||||
required bool isDark,
|
||||
bool isRecommended = false,
|
||||
}) {
|
||||
final isSelected = value == groupValue;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark ? WebTheme.darkGrey700 : Colors.blue.withOpacity(0.1))
|
||||
: WebTheme.getSurfaceColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: (isDark ? WebTheme.darkGrey600 : WebTheme.grey300),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => onChanged(value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 单选按钮
|
||||
Radio<T>(
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged,
|
||||
activeColor: Colors.blue,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 内容区域
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
if (isRecommended) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Colors.green.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'推荐',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建保存按钮
|
||||
Widget _buildSaveButton(bool isDark) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? WebTheme.darkGrey800 : WebTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isDark ? WebTheme.darkGrey700 : WebTheme.grey200,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'保存更改',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: WebTheme.getTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
'您的追踪配置已修改,点击保存以应用更改。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
// 重置按钮
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _resetChanges,
|
||||
child: Text(
|
||||
'重置',
|
||||
style: TextStyle(
|
||||
color: WebTheme.getSecondaryTextColor(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 保存按钮
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSaving ? null : _saveChanges,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: _isSaving
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('保存中...'),
|
||||
],
|
||||
)
|
||||
: Text('保存更改'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 重置更改
|
||||
void _resetChanges() {
|
||||
setState(() {
|
||||
_nameAliasTracking = widget.settingItem.nameAliasTracking;
|
||||
_aiContextTracking = widget.settingItem.aiContextTracking;
|
||||
_referenceUpdatePolicy = widget.settingItem.referenceUpdatePolicy;
|
||||
_hasChanges = false;
|
||||
});
|
||||
|
||||
TopToast.info(context, '已重置所有更改');
|
||||
}
|
||||
|
||||
/// 保存更改
|
||||
Future<void> _saveChanges() async {
|
||||
if (widget.settingItem.id == null) return;
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// 更新设定项目
|
||||
final updatedItem = widget.settingItem.copyWith(
|
||||
nameAliasTracking: _nameAliasTracking,
|
||||
aiContextTracking: _aiContextTracking,
|
||||
referenceUpdatePolicy: _referenceUpdatePolicy,
|
||||
);
|
||||
|
||||
// 先更新本地状态
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
// 立即通知父组件
|
||||
widget.onItemUpdated(updatedItem);
|
||||
|
||||
// 显示成功提示
|
||||
TopToast.success(context, '追踪配置已保存');
|
||||
|
||||
// 异步保存到后端,不阻塞UI
|
||||
_saveToBackendAsync(updatedItem);
|
||||
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
});
|
||||
|
||||
TopToast.error(context, '保存失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 异步保存到后端
|
||||
Future<void> _saveToBackendAsync(NovelSettingItem updatedItem) async {
|
||||
try {
|
||||
// 通过BLoC更新后端
|
||||
context.read<SettingBloc>().add(UpdateSettingItem(
|
||||
novelId: widget.novelId,
|
||||
itemId: widget.settingItem.id!,
|
||||
item: updatedItem,
|
||||
));
|
||||
} catch (e) {
|
||||
// 静默处理错误,不干扰用户体验
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user