马良AI写作初始化仓库

This commit is contained in:
邓滨杰
2025-09-10 00:07:52 +08:00
parent 3c06bb1a03
commit 39c0f8840f
1309 changed files with 318528 additions and 0 deletions

View 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,
),
),
],
),
);
}
}

View 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);
}
}
}

View 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();
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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,
};
}
}

View 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,
);
}
}
}

View 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;
}

View 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;
}
}

View 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,
),
),
// 游客模式在展开时显示小提示“需登录”
// 访客提示徽标已移除,保持简洁
],
],
),
),
),
),
);
}
}

View 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,
),
);
}
}

View 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,
),
),
),
);
}
}

View 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;
}
}

View 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),
),
],
),
),
),
);
}
}

View 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),
),
),
);
}
}

View 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),
),
],
),
],
);
}
}

View 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);
},
),
],
);
}
}

View 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'),
),
],
),
],
);
}
}

View 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;
}
}

View File

@@ -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!;
// 支持平铺IDflat_ 前缀与层级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);
},
),
),
);
}
}

View 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,
}

View 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),
),
),
],
),
),
),
),
],
);
}
}

View 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,
),
],
),
),
),
);
}
}

View 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),
),
),
],
),
),
),
),
);
}
}

View 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,
),
),
],
),
),
),
);
}
}

View 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,
),
),
],
),
),
),
),
],
),
);
}
}

View 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();
}
}

View 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(', ')}');
}
}

View 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!,
],
],
),
);
}
}

View 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 ?? '重试'),
),
],
],
),
),
);
}
}

View 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,
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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),
),
),
],
),
),
),
);
}
}

View 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');
}
}
}

View 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';

View 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,
),
),
);
}
}

View 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,
),
],
],
);
}
}

View 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,
),
),
);
}
}

View 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,
),
],
);
}
}

View 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,
),
);
}
}

View 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),
),
);
}
}

View 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, // 自定义
}

View File

@@ -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;
}
}

View 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),
),
),
),
),
],
),
);
}
}

View 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),
),
),
);
}
}

View 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),
),
),
);
}
}

View 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();
},
);
}
}

View 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')),
);
}
}
}

View 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),
),
],
),
),
),
),
);
}
}

View 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 '刚刚';
}
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
);
}

View 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('模板已保存')));
}
}

View 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),
],
);
}
}

View 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,
),
),
);
}
}

View 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,
});
}

View 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, // 确保文字垂直居中
),
),
],
),
),
),
),
);
}
}

View 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,
),
),
),
),
],
),
);
}
}

View 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),
),
],
),
);
}
}

View 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生成内容的准确性和连贯性
• 可能会增加一定的处理时间''';
}
}

View 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 '自动';
}
}
}

View 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;
}

View 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),
),
);
}
}

View 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,
),
],
),
);
}
}

View 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),
),
),
),
);
}
}

View 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);
}
}
}

View 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状态当前场景IDUI层面的概念
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');
// 只更新场景IDUI会自动响应
_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();
}
}

File diff suppressed because it is too large Load Diff

View 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,
),
],
],
),
),
);
}
}

View 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;
}

View 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,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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) {
// 静默处理错误,不干扰用户体验
}
}
}