马良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,121 @@
# 新小说列表页面 UI 测试说明
## 概述
本实现将 TypeScript React 代码转换为 Flutter保持了原有的样式、布局和交互逻辑。
## 文件结构
```
lib/
├── test_novel_list_app.dart # 测试启动类
├── screens/novel_list/
│ ├── novel_list_page_new.dart # 主页面
│ └── widgets/
│ ├── novel_grid_new.dart # 小说网格组件
│ ├── novel_input_new.dart # 小说输入组件
│ ├── category_tags_new.dart # 分类标签组件
│ └── community_feed_new.dart # 社区动态组件
└── widgets/common/
├── novel_card.dart # 小说卡片组件
├── badge.dart # 徽章组件
├── app_sidebar.dart # 侧边栏组件
├── dropdown_menu_widget.dart # 下拉菜单组件
└── animated_container_widget.dart # 动画容器组件
```
## 运行测试
1. 在终端中进入 AINoval 目录:
```bash
cd /mnt/h/GitHub/AINovalWriter/AINoval
```
2. 运行测试应用:
```bash
flutter run lib/test_novel_list_app.dart -d chrome
```
或者在其他平台运行:
```bash
# Android
flutter run lib/test_novel_list_app.dart -d android
# iOS
flutter run lib/test_novel_list_app.dart -d ios
# Windows
flutter run lib/test_novel_list_app.dart -d windows
```
## 实现的功能
### 1. 页面布局
- **侧边栏**:可折叠的导航侧边栏,包含主要功能入口
- **左侧面板**AI创作输入区域包含提示词输入、分类标签和社区精选
- **右侧面板**:小说管理区域,展示用户的小说作品
### 2. 组件特性
#### NovelCard小说卡片
- 悬停效果:鼠标悬停时卡片放大并显示阴影
- 状态标识:显示草稿、连载中、已完结状态
- 操作菜单:编辑、分享、删除功能
- 统计信息:字数、浏览量、更新时间、评分
#### NovelInput创作输入
- 渐变背景效果
- AI润色功能模拟
- 开始创作功能(模拟)
- 字数统计
- 动画脉冲效果
#### CategoryTags分类标签
- 点击标签快速填充提示词
- 缩放进入动画效果
- 16种小说分类
#### CommunityFeed社区动态
- 社区精选提示词展示
- 点赞、引用、评论交互
- 应用提示词功能
- 作者信息展示
### 3. 动画效果
- **fadeIn**:淡入动画,带有向上位移效果
- **scaleIn**:缩放进入动画
- **slideInRight**:从右侧滑入动画
- 所有动画都支持延迟启动
### 4. 主题支持
- 完整支持亮色/暗色主题
- 使用 WebTheme 统一管理样式
- 响应式布局适配
## 与原 TypeScript 版本的对比
### 保持一致的部分
1. 整体布局结构
2. 组件样式和颜色
3. 交互逻辑(悬停、点击等)
4. 动画效果
5. 响应式设计
### Flutter 特有的优化
1. 使用 Flutter 的动画系统实现更流畅的效果
2. 利用 Material Design 组件提供更好的触摸反馈
3. 适配移动端的交互体验
## 后续集成建议
1. **数据集成**:将模拟数据替换为真实的 BLoC 状态管理
2. **路由集成**:添加页面导航功能
3. **API集成**:连接后端服务实现真实的创作功能
4. **权限管理**:添加用户认证和权限控制
5. **国际化**:添加多语言支持
## 注意事项
- 所有图片使用网络地址,确保网络连接正常
- 测试应用独立运行,不依赖现有的业务逻辑
- 可以通过修改 `themeMode` 切换亮色/暗色主题

View File

@@ -0,0 +1,27 @@
import 'package:ainoval/screens/novel_list/widgets/analytics_dashboard.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
class AnalyticsScreen extends StatelessWidget {
const AnalyticsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: WebTheme.getBackgroundColor(context),
appBar: AppBar(
title: Text('数据分析', style: TextStyle(color: WebTheme.getTextColor(context))),
backgroundColor: WebTheme.getCardColor(context),
iconTheme: IconThemeData(color: WebTheme.getTextColor(context)),
),
body: const Padding(
padding: EdgeInsets.all(24.0),
child: AnalyticsDashboard(),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:ainoval/models/analytics_data.dart';
import 'package:ainoval/services/api_service/repositories/impl/analytics_repository_impl.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/analytics/analytics_card.dart';
import 'package:ainoval/widgets/analytics/token_usage_chart.dart';
import 'package:ainoval/widgets/analytics/function_usage_chart.dart';
import 'package:ainoval/widgets/analytics/model_usage_chart.dart';
import 'package:ainoval/widgets/analytics/token_usage_list.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
class AnalyticsDashboard extends StatefulWidget {
const AnalyticsDashboard({super.key});
@override
State<AnalyticsDashboard> createState() => _AnalyticsDashboardState();
}
class _AnalyticsDashboardState extends State<AnalyticsDashboard> {
final _analyticsRepo = AnalyticsRepositoryImpl();
bool _loading = true;
AnalyticsData? _overviewData;
List<TokenUsageData> _tokenData = [];
List<FunctionUsageData> _functionData = [];
List<ModelUsageData> _modelData = [];
List<TokenUsageRecord> _recordData = [];
Map<String, dynamic>? _todaySummary;
AnalyticsViewMode _tokenViewMode = AnalyticsViewMode.daily;
AnalyticsViewMode _functionViewMode = AnalyticsViewMode.daily;
AnalyticsViewMode _modelViewMode = AnalyticsViewMode.daily;
DateTimeRange? _dateRange;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _loading = true);
try {
final results = await Future.wait([
_analyticsRepo.getAnalyticsOverview(),
_analyticsRepo.getTokenUsageTrend(viewMode: _tokenViewMode),
_analyticsRepo.getFunctionUsageStats(viewMode: _functionViewMode),
_analyticsRepo.getModelUsageStats(viewMode: _modelViewMode),
_analyticsRepo.getTokenUsageRecords(limit: 50), // 增加记录数量,确保包含今日数据
]);
if (!mounted) return;
setState(() {
_overviewData = results[0] as AnalyticsData;
_tokenData = results[1] as List<TokenUsageData>;
_functionData = results[2] as List<FunctionUsageData>;
_modelData = results[3] as List<ModelUsageData>;
_recordData = results[4] as List<TokenUsageRecord>;
_todaySummary = null; // 不再使用后端汇总数据,前端自己计算
_loading = false;
});
} catch (e) {
if (mounted) {
setState(() => _loading = false);
TopToast.error(context, '加载数据失败: $e');
}
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
return Container(
color: WebTheme.getBackgroundColor(context),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOverviewCards(),
const SizedBox(height: 32),
_buildTokenUsageChart(),
const SizedBox(height: 32),
_buildChartsSection(),
const SizedBox(height: 32),
_buildTokenUsageList(),
const SizedBox(height: 32),
_buildInsightsSection(),
],
),
),
),
);
}
Widget _buildOverviewCards() {
if (_overviewData == null) return const SizedBox.shrink();
final crossAxisCount = _getColumnCount();
// 使用固定项高度,避免固定纵横比在不同宽度下导致轻微内容溢出
final double itemMainAxisExtent = crossAxisCount >= 4
? 180
: (crossAxisCount == 2 ? 200 : 220);
return GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 24,
mainAxisExtent: itemMainAxisExtent,
),
children: [
AnalyticsOverviewCard(
title: '总字数',
value: _formatLargeNumber(_overviewData!.totalWords),
changeValue: 15.2,
isUpTrend: true,
icon: Icons.article,
subtitle: '本月新增 ${_formatNumber(_overviewData!.monthlyNewWords)}',
),
AnalyticsOverviewCard(
title: 'Token 消耗',
value: _formatLargeNumber(_overviewData!.totalTokens),
changeValue: 23.8,
isUpTrend: true,
icon: Icons.flash_on,
subtitle: '本月新增 ${_formatNumber(_overviewData!.monthlyNewTokens)} tokens',
),
AnalyticsOverviewCard(
title: '功能使用次数',
value: _formatLargeNumber(_overviewData!.functionUsageCount),
changeValue: 12.5,
isUpTrend: true,
icon: Icons.trending_up,
subtitle: '${_overviewData!.mostPopularFunction}最受欢迎',
),
AnalyticsOverviewCard(
title: '写作天数',
value: _overviewData!.writingDays.toString(),
changeValue: 12.8,
isUpTrend: true,
icon: Icons.calendar_today,
subtitle: '连续写作 ${_overviewData!.consecutiveDays}',
),
],
);
}
Widget _buildTokenUsageChart() {
return AnalyticsCard(
title: '',
value: '',
child: TokenUsageChart(
data: _tokenData,
viewMode: _tokenViewMode,
onViewModeChanged: (mode) {
setState(() => _tokenViewMode = mode);
_loadTokenData();
},
dateRange: _dateRange,
onDateRangeChanged: (range) {
setState(() => _dateRange = range);
if (_tokenViewMode == AnalyticsViewMode.range) {
_loadTokenData();
}
},
),
);
}
Widget _buildChartsSection() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AnalyticsCard(
title: '功能使用统计',
value: '',
child: FunctionUsageChart(
data: _functionData,
viewMode: _functionViewMode,
onViewModeChanged: (mode) {
setState(() => _functionViewMode = mode);
_loadFunctionData();
},
),
),
),
const SizedBox(width: 32),
Expanded(
child: AnalyticsCard(
title: '大模型占比情况',
value: '',
child: ModelUsageChart(
data: _modelData,
viewMode: _modelViewMode,
onViewModeChanged: (mode) {
setState(() => _modelViewMode = mode);
_loadModelData();
},
),
),
),
],
);
}
Widget _buildTokenUsageList() {
return AnalyticsCard(
title: '',
value: '',
child: TokenUsageList(
records: _recordData,
todaySummary: _todaySummary,
),
);
}
Widget _buildInsightsSection() {
final width = MediaQuery.of(context).size.width;
final insightsCrossAxisCount = width > 1200 ? 3 : (width > 800 ? 2 : 1);
return GridView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: insightsCrossAxisCount,
crossAxisSpacing: 24,
mainAxisSpacing: 24,
// 固定每项高度,避免纵横比导致的轻微溢出
mainAxisExtent: 190,
),
children: [
AnalyticsInsightCard(
icon: Icons.trending_up,
title: '效率提升',
description: '智能续写功能使用率上升 15%,用户写作效率显著提升',
iconColor: Theme.of(context).primaryColor,
backgroundColor: Theme.of(context).primaryColor,
),
AnalyticsInsightCard(
icon: Icons.article,
title: '内容质量',
description: '语法检查和风格优化功能显著提升了内容整体质量',
iconColor: const Color(0xFF8B5CF6),
backgroundColor: const Color(0xFF8B5CF6),
),
AnalyticsInsightCard(
icon: Icons.flash_on,
title: '用户活跃',
description: '用户日均使用时长增加 28%,平台粘性持续增强',
iconColor: const Color(0xFF10B981),
backgroundColor: const Color(0xFF10B981),
),
],
);
}
int _getColumnCount() {
final width = MediaQuery.of(context).size.width;
if (width > 1200) return 4;
if (width > 800) return 2;
return 1;
}
String _formatNumber(int number) {
return number.toString().replaceAllMapped(
RegExp(r'(\d)(?=(\d{3})+(?!\d))'),
(match) => '${match[1]},',
);
}
String _formatLargeNumber(int number) {
if (number >= 1000000) {
return '${(number / 1000000).toStringAsFixed(1)}M';
} else if (number >= 1000) {
return '${(number / 1000).toStringAsFixed(1)}K';
}
return _formatNumber(number);
}
Future<void> _loadTokenData() async {
final data = await _analyticsRepo.getTokenUsageTrend(
viewMode: _tokenViewMode,
startDate: _dateRange?.start,
endDate: _dateRange?.end,
);
if (mounted) {
setState(() => _tokenData = data);
}
}
Future<void> _loadFunctionData() async {
final data = await _analyticsRepo.getFunctionUsageStats(
viewMode: _functionViewMode,
);
if (mounted) {
setState(() => _functionData = data);
}
}
Future<void> _loadModelData() async {
final data = await _analyticsRepo.getModelUsageStats(
viewMode: _modelViewMode,
);
if (mounted) {
setState(() => _modelData = data);
}
}
}

View File

@@ -0,0 +1,80 @@
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/animated_container_widget.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:ainoval/widgets/common/badge.dart';
class CategoryTagsNew extends StatelessWidget {
final Function(String) onTagClick;
const CategoryTagsNew({
Key? key,
required this.onTagClick,
}) : super(key: key);
static const List<Map<String, String>> categories = [
{'name': '现代都市', 'prompt': '创作一个现代都市背景的小说,主角是一位在大城市奋斗的年轻人...'},
{'name': '古风仙侠', 'prompt': '创作一个古风仙侠小说,描述一位修仙者的成长历程...'},
{'name': '科幻未来', 'prompt': '创作一个科幻未来题材的小说背景设定在2100年的地球...'},
{'name': '悬疑推理', 'prompt': '创作一个悬疑推理小说,围绕一起神秘的案件展开...'},
{'name': '校园青春', 'prompt': '创作一个校园青春小说,讲述高中生活中的友情与成长...'},
{'name': '历史架空', 'prompt': '创作一个历史架空小说,设定在一个虚构的古代王朝...'},
{'name': '玄幻魔法', 'prompt': '创作一个玄幻魔法小说,主角意外获得了强大的魔法力量...'},
{'name': '军事战争', 'prompt': '创作一个军事战争小说,描述一场激烈的现代战争...'},
{'name': '商战职场', 'prompt': '创作一个商战职场小说,主角在大企业中的奋斗历程...'},
{'name': '穿越重生', 'prompt': '创作一个穿越重生小说,主角回到了十年前的自己...'},
{'name': '末世求生', 'prompt': '创作一个末世求生小说,描述人类在灾难后的生存斗争...'},
{'name': '异世冒险', 'prompt': '创作一个异世界冒险小说,主角被传送到了陌生的世界...'},
{'name': '武侠江湖', 'prompt': '创作一个武侠江湖小说,讲述侠客行走江湖的故事...'},
{'name': '娱乐圈', 'prompt': '创作一个娱乐圈题材的小说,主角是一位新人演员...'},
{'name': '电竞游戏', 'prompt': '创作一个电竞游戏小说,描述职业选手的比赛生涯...'},
{'name': '灵异恐怖', 'prompt': '创作一个灵异恐怖小说,主角遭遇了超自然现象...'},
];
@override
Widget build(BuildContext context) {
return AnimatedContainerWidget(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择小说分类',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: categories.asMap().entries.map((entry) {
final index = entry.key;
final category = entry.value;
return AnimatedContainerWidget(
animationType: AnimationType.scaleIn,
delay: Duration(milliseconds: index * 50),
child: Badge(
text: category['name']!,
variant: BadgeVariant.outline,
onTap: () => onTagClick(category['prompt']!),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
fontSize: 14,
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
'点击标签快速填充创作提示词,或直接在上方输入框中输入您的想法',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/animated_container_widget.dart';
class CommunityPost {
final String id;
final String title;
final String content;
final Author author;
final int likes;
final int quotes;
final int comments;
final bool isLiked;
final String timeAgo;
final String category;
CommunityPost({
required this.id,
required this.title,
required this.content,
required this.author,
required this.likes,
required this.quotes,
required this.comments,
required this.isLiked,
required this.timeAgo,
required this.category,
});
}
class Author {
final String name;
final String avatar;
final String username;
Author({
required this.name,
required this.avatar,
required this.username,
});
}
class CommunityFeedNew extends StatefulWidget {
final Function(String) onApplyPrompt;
const CommunityFeedNew({
Key? key,
required this.onApplyPrompt,
}) : super(key: key);
@override
State<CommunityFeedNew> createState() => _CommunityFeedNewState();
}
class _CommunityFeedNewState extends State<CommunityFeedNew> {
final List<CommunityPost> _posts = [
CommunityPost(
id: '1',
title: '玄幻小说开头万能模板',
content: '写一个少年在家族被灭后意外获得神秘力量的故事开头要有悬念感和代入感字数控制在500字左右...',
author: Author(
name: '笔墨生花',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop',
username: '@writer_master',
),
likes: 142,
quotes: 28,
comments: 15,
isLiked: false,
timeAgo: '2小时前',
category: '玄幻修仙',
),
CommunityPost(
id: '2',
title: '现代都市情感描写技巧',
content: '帮我写一段都市男女主角初次相遇的情景,要体现出心动的感觉,环境设定在咖啡厅,要求自然不做作...',
author: Author(
name: '城市夜语',
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b9d25e62?w=400&h=400&fit=crop',
username: '@city_romance',
),
likes: 89,
quotes: 12,
comments: 8,
isLiked: true,
timeAgo: '4小时前',
category: '现代都市',
),
CommunityPost(
id: '3',
title: '科幻世界观构建提示',
content: '构建一个2080年的未来世界包含AI管理城市、虚拟现实普及、太空殖民等元素要求逻辑自洽...',
author: Author(
name: '未来预言家',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop',
username: '@sci_fi_master',
),
likes: 203,
quotes: 45,
comments: 32,
isLiked: false,
timeAgo: '6小时前',
category: '科幻未来',
),
CommunityPost(
id: '4',
title: '古风诗词对白生成',
content: '为古装剧本创作古风对白,男女主角在月下相遇的情景,要求用词典雅,意境优美,符合古代语言风格...',
author: Author(
name: '古韵悠长',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=400&h=400&fit=crop',
username: '@ancient_poet',
),
likes: 156,
quotes: 34,
comments: 19,
isLiked: true,
timeAgo: '8小时前',
category: '古风仙侠',
),
];
void _toggleLike(String postId) {
setState(() {
final index = _posts.indexWhere((post) => post.id == postId);
if (index != -1) {
final post = _posts[index];
_posts[index] = CommunityPost(
id: post.id,
title: post.title,
content: post.content,
author: post.author,
likes: post.isLiked ? post.likes - 1 : post.likes + 1,
quotes: post.quotes,
comments: post.comments,
isLiked: !post.isLiked,
timeAgo: post.timeAgo,
category: post.category,
);
}
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'社区精选',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
TextButton(
onPressed: () {
// Handle view more
},
child: Text(
'查看更多',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
),
const SizedBox(height: 16),
// Posts List
Container(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.separated(
shrinkWrap: true,
itemCount: _posts.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final post = _posts[index];
return AnimatedContainerWidget(
animationType: AnimationType.fadeIn,
delay: Duration(milliseconds: index * 100),
child: _buildPostCard(context, post),
);
},
),
),
],
);
}
Widget _buildPostCard(BuildContext context, CommunityPost post) {
final isDark = WebTheme.isDarkMode(context);
return Container(
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context).withOpacity(0.5),
width: 1,
),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 16,
backgroundImage: NetworkImage(post.author.avatar),
backgroundColor: WebTheme.getEmptyStateColor(context),
),
const SizedBox(width: 12),
// Author Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
post.author.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 8),
Text(
post.timeAgo,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
Text(
post.author.username,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
// More Options
IconButton(
icon: Icon(
Icons.more_horiz,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
onPressed: () {
// Handle more options
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
post.content,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
height: 1.5,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(height: 12),
// Actions
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Action Buttons
Row(
children: [
// Like Button
_buildActionButton(
context,
icon: post.isLiked ? Icons.favorite : Icons.favorite_border,
label: post.likes.toString(),
color: post.isLiked ? Theme.of(context).colorScheme.error : null,
onTap: () => _toggleLike(post.id),
),
const SizedBox(width: 16),
// Quote Button
_buildActionButton(
context,
icon: Icons.format_quote,
label: post.quotes.toString(),
onTap: () {
// Handle quote
},
),
const SizedBox(width: 16),
// Comment Button
_buildActionButton(
context,
icon: Icons.comment_outlined,
label: post.comments.toString(),
onTap: () {
// Handle comment
},
),
const SizedBox(width: 16),
// Apply Button
_buildActionButton(
context,
icon: Icons.flash_on,
label: '应用',
color: WebTheme.getPrimaryColor(context),
onTap: () => widget.onApplyPrompt(post.content),
),
],
),
// Category Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context),
borderRadius: BorderRadius.circular(12),
),
child: Text(
post.category,
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
),
),
],
),
);
}
Widget _buildActionButton(
BuildContext context, {
required IconData icon,
required String label,
Color? color,
VoidCallback? onTap,
}) {
final defaultColor = WebTheme.getSecondaryTextColor(context);
final buttonColor = color ?? defaultColor;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: buttonColor,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: buttonColor,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,933 @@
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/screens/editor/editor_screen.dart';
import 'package:ainoval/utils/date_formatter.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// 继续写作区域组件
class ContinueWritingSection extends StatelessWidget {
const ContinueWritingSection({
super.key,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
// 如果屏幕非常窄,则直接隐藏此区域
if (screenWidth < 350) {
return const SizedBox.shrink();
}
return BlocBuilder<NovelListBloc, NovelListState>(
builder: (context, state) {
if (state is NovelListLoaded && state.novels.isNotEmpty) {
final recentNovels = List<NovelSummary>.from(state.novels)
..sort((a, b) => b.lastEditTime.compareTo(a.lastEditTime));
if (recentNovels.length > 3) {
recentNovels.removeRange(3, recentNovels.length);
}
return Container(
color: WebTheme.getSurfaceColor(context),
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeader(
icon: Icons.edit_note,
title: '继续写作',
),
const SizedBox(height: 12),
// 使用LayoutBuilder获取可用空间
LayoutBuilder(builder: (context, constraints) {
// 根据可用宽度动态计算卡片高度和数量
double cardHeight;
int visibleCards;
if (constraints.maxWidth < 450) {
cardHeight = 120.0; // 进一步增加高度
visibleCards = 1; // 只显示一张卡片
} else if (constraints.maxWidth < 600) {
cardHeight = 140.0; // 进一步增加高度
visibleCards = 2; // 显示两张卡片
} else {
cardHeight = 160.0; // 进一步增加高度
visibleCards = 3; // 显示所有卡片
}
// 限制显示的卡片数量
final displayNovels =
recentNovels.take(visibleCards).toList();
return SizedBox(
height: cardHeight,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: displayNovels.length,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemBuilder: (context, index) {
final novel = displayNovels[index];
// 计算卡片宽度: 窄屏幕下宽度更窄,确保卡片不会过大
double cardWidth;
if (constraints.maxWidth < 450) {
cardWidth =
constraints.maxWidth * 0.85; // 非常窄的屏幕使用85%宽度
} else if (constraints.maxWidth < 600) {
cardWidth = constraints.maxWidth * 0.6; // 窄屏幕使用60%宽度
} else {
cardWidth = 280.0; // 宽屏幕使用固定宽度
}
return RecentNovelCard(
novel: novel,
index: index,
cardWidth: cardWidth,
);
},
),
);
}),
],
),
);
}
return const SizedBox.shrink();
},
);
}
}
/// 最近编辑过的小说卡片
class RecentNovelCard extends StatelessWidget {
const RecentNovelCard({
super.key,
required this.novel,
required this.index,
this.cardWidth = 280.0,
});
final NovelSummary novel;
final int index;
final double cardWidth;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = _getRandomPastelColor(context, novel.id, index);
final bool isNarrow = cardWidth < 250;
return Container(
width: cardWidth,
margin: const EdgeInsets.only(left: 4, right: 12),
child: Card(
elevation: 3,
shadowColor: (WebTheme.isDarkMode(context) ? WebTheme.black : WebTheme.grey400).withOpacity(0.15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _navigateToEditor(context),
splashColor: WebTheme.getTextColor(context).withOpacity(0.1),
highlightColor: Colors.transparent,
child: Row(
children: [
// 封面区域 - 宽度等比例缩放
SizedBox(
width: isNarrow
? cardWidth * 0.28
: cardWidth * 0.33, // 很窄的卡片封面占比更小
child: RecentNovelCover(
novel: novel, bgColor: bgColor, index: index),
),
// 信息区域
Expanded(
child: RecentNovelInfo(
novel: novel,
isCompact: isNarrow, // 窄卡片使用紧凑布局
),
),
],
),
),
),
);
}
// 导航到编辑器
void _navigateToEditor(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EditorScreen(novel: novel),
),
).then((_) {
// 导航返回时刷新小说列表
context.read<NovelListBloc>().add(LoadNovels());
});
}
// 获取动态的柔和颜色
Color _getRandomPastelColor(BuildContext context, String id, int index) {
final theme = Theme.of(context);
final List<Color> lightColors = [
const Color(0xFFBBDEFB), // Light Blue
const Color(0xFFC8E6C9), // Light Green
const Color(0xFFFFE0B2), // Light Orange
const Color(0xFFF8BBD0), // Light Pink
const Color(0xFFE1BEE7), // Light Purple
const Color(0xFFB2DFDB), // Light Teal
const Color(0xFFFFF9C4), // Light Yellow
const Color(0xFFB3E5FC), // Light Cyan
const Color(0xFFFFCCBC), // Light Deep Orange
const Color(0xFFC5CAE9), // Light Indigo
];
final List<Color> darkColors = [
const Color(0xFF1E3A8A), // Dark Blue
const Color(0xFF166534), // Dark Green
const Color(0xFF9A3412), // Dark Orange
const Color(0xFF9D174D), // Dark Pink
const Color(0xFF7C2D92), // Dark Purple
const Color(0xFF0F766E), // Dark Teal
const Color(0xFF92400E), // Dark Yellow
const Color(0xFF0E7490), // Dark Cyan
const Color(0xFFEA580C), // Dark Deep Orange
const Color(0xFF3730A3), // Dark Indigo
];
final colors = theme.brightness == Brightness.dark ? darkColors : lightColors;
return colors[index % colors.length];
}
}
/// 区域标题头组件
class SectionHeader extends StatelessWidget {
const SectionHeader({
super.key,
required this.icon,
required this.title,
});
final IconData icon;
final String title;
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final bool isNarrow = screenWidth < 450;
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.symmetric(horizontal: isNarrow ? 16 : 24),
child: Row(
children: [
Container(
padding: EdgeInsets.all(isNarrow ? 6 : 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: theme.colorScheme.outlineVariant,
width: 1.0,
),
),
child: Icon(
icon,
color: theme.colorScheme.onSurfaceVariant,
size: isNarrow ? 16 : 18,
),
),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: isNarrow ? 16 : 18,
fontWeight: FontWeight.bold,
color: WebTheme.getTextColor(context),
),
),
],
),
);
}
}
/// 最近小说封面组件
class RecentNovelCover extends StatelessWidget {
const RecentNovelCover({
super.key,
required this.novel,
required this.bgColor,
required this.index,
});
final NovelSummary novel;
final Color bgColor;
final int index;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: bgColor,
gradient: LinearGradient(
colors: [
bgColor,
bgColor.withOpacity(0.7),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Stack(
fit: StackFit.expand,
children: [
// 优先显示封面图片(如果有)
if (novel.coverUrl.isNotEmpty)
Image.network(
novel.coverUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
// 加载失败时使用生成的设计
return _buildCoverDesign(bgColor, novel.id, index);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
strokeWidth: 2,
color: WebTheme.getTextColor(context),
),
);
},
)
else
// 使用生成的设计作为默认封面
_buildCoverDesign(bgColor, novel.id, index),
// 进度条
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
value: novel.completionPercentage,
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
color: WebTheme.getTextColor(context),
minHeight: 2,
),
),
],
),
);
}
// 构建封面设计
Widget _buildCoverDesign(Color baseColor, String id, int index) {
final designType = index % 5;
switch (designType) {
case 0:
return _buildCircleDesign(baseColor);
case 1:
return _buildStripeDesign(baseColor);
case 2:
return _buildWaveDesign(baseColor);
case 3:
return _buildGridDesign(baseColor);
default:
return _buildGeometricDesign(baseColor);
}
}
// 圆形设计
Widget _buildCircleDesign(Color baseColor) {
return Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: _CirclePainter(
baseColor: baseColor,
color: baseColor.withOpacity(0.5),
),
size: const Size.square(200),
),
Center(
child: Icon(
Icons.auto_stories,
size: 24,
color: WebTheme.black.withOpacity(0.15),
),
),
],
);
}
// 条纹设计
Widget _buildStripeDesign(Color baseColor) {
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Opacity(
opacity: 0.7,
child: Stack(
children: [
Positioned(
top: 0,
left: 15,
bottom: 0,
child: Container(
width: 3,
color: baseColor.withGreen(180).withOpacity(0.8),
),
),
Positioned(
top: 0,
left: 28,
bottom: 20,
child: Container(
width: 4,
color: baseColor.withBlue(180).withOpacity(0.7),
),
),
Positioned(
top: 40,
right: 10,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: baseColor.withRed(200),
),
),
),
Positioned(
bottom: 15,
left: 40,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: baseColor.withGreen(200),
),
),
),
],
),
),
),
Center(
child: Icon(
Icons.menu_book,
size: 24,
color: WebTheme.black.withOpacity(0.15),
),
),
],
);
}
// 波浪设计
Widget _buildWaveDesign(Color baseColor) {
return Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: 0.5,
child: ClipPath(
clipper: _WaveClipper(),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [baseColor.withRed(200), baseColor.withBlue(200)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
),
Center(
child: Icon(
Icons.book_outlined,
size: 24,
color: WebTheme.black.withOpacity(0.15),
),
),
],
);
}
// 网格设计
Widget _buildGridDesign(Color baseColor) {
return Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: _GridPainter(
color: baseColor.withOpacity(0.5),
lineWidth: 0.8,
spacing: 8.0,
),
size: const Size.square(200),
),
Center(
child: Icon(
Icons.chrome_reader_mode,
size: 24,
color: WebTheme.black.withOpacity(0.15),
),
),
],
);
}
// 几何设计
Widget _buildGeometricDesign(Color baseColor) {
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Opacity(
opacity: 0.6,
child: Transform.rotate(
angle: -0.5,
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: 10,
left: 10,
child: Container(
width: 40,
height: 40,
color: baseColor.withBlue(200).withGreen(150),
),
),
Positioned(
bottom: 15,
right: 15,
child: Container(
width: 60,
height: 25,
color: baseColor.withRed(220).withGreen(180),
),
),
Positioned(
top: 35,
right: 30,
child: Container(
width: 15,
height: 50,
color: baseColor.withGreen(200).withRed(150),
),
),
],
),
),
),
),
Center(
child: Icon(
Icons.edit_document,
size: 24,
color: WebTheme.black.withOpacity(0.15),
),
),
],
);
}
}
/// 最近小说信息组件
class RecentNovelInfo extends StatelessWidget {
const RecentNovelInfo({
super.key,
required this.novel,
this.isCompact = false,
});
final NovelSummary novel;
final bool isCompact;
@override
Widget build(BuildContext context) {
// 使用 LayoutBuilder 来获取可用空间
return LayoutBuilder(
builder: (context, constraints) {
// 根据可用高度决定显示哪些信息
final availableHeight = constraints.maxHeight;
if (isCompact) {
// 超紧凑模式 - 只显示最重要的信息
return Padding(
padding: const EdgeInsets.all(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 标题始终显示
Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 2),
// 时间或系列名(二选一)
if (novel.seriesName.isNotEmpty)
_buildSeriesInfo(context)
else
_buildTimeInfo(context),
// 进度条始终显示
const SizedBox(height: 3),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: novel.completionPercentage,
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
color: WebTheme.getTextColor(context),
minHeight: 2,
),
),
// 如果空间足够,显示字数
if (availableHeight > 70) ...[
const SizedBox(height: 3),
Row(
children: [
Icon(
Icons.text_fields,
size: 10,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 3),
Text(
'${_formatNumber(novel.wordCount)}',
style: TextStyle(
fontSize: 9,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
],
],
),
);
} else {
// 标准模式 - 使用 Flexible 控制子组件大小
return Padding(
padding: const EdgeInsets.all(10), // 稍微减小内边距
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
novel.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 4),
// 时间信息
_buildTimeInfo(context),
// 使用 Flexible 包装可能溢出的内容
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 3),
// 字数和系列信息
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.getTextColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Icon(
Icons.text_fields,
size: 12,
color: WebTheme.getTextColor(context),
),
const SizedBox(width: 4),
Text(
'${_formatNumber(novel.wordCount)}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
],
),
),
if (novel.seriesName.isNotEmpty && availableHeight > 100) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
novel.seriesName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
],
),
// 结构信息(如果空间足够)
if (availableHeight > 90) ...[
const SizedBox(height: 3),
_buildStructureInfo(context),
],
],
),
),
// 进度条
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: novel.completionPercentage,
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
color: WebTheme.getTextColor(context),
minHeight: 3,
),
),
],
),
);
}
},
);
}
// 构建系列信息组件
Widget _buildSeriesInfo(BuildContext context) {
return Row(
children: [
Icon(
Icons.bookmark_border,
size: 10,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 3),
Expanded(
child: Text(
novel.seriesName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
);
}
// 构建时间信息组件
Widget _buildTimeInfo(BuildContext context) {
return Row(
children: [
Icon(
Icons.access_time,
size: isCompact ? 10 : 12,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 3),
Expanded(
child: Text(
isCompact
? DateFormatter.formatRelative(novel.lastEditTime)
: '上次: ${DateFormatter.formatRelative(novel.lastEditTime)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isCompact ? 9 : 11,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
);
}
// 构建字数信息组件
// removed unused _buildWordCountInfo to satisfy lints
// 构建卷、章节、场景数量信息组件
Widget _buildStructureInfo(BuildContext context) {
return Row(
children: [
Icon(
Icons.library_books_outlined,
size: isCompact ? 9 : 10,
color: WebTheme.getSecondaryTextColor(context),
),
const SizedBox(width: 3),
Expanded(
child: Text(
'${novel.actCount}卷 / ${novel.chapterCount}章 / ${novel.sceneCount}场景',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isCompact ? 8 : 9,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
);
}
// 格式化数字显示
String _formatNumber(int number) {
if (number < 1000) {
return number.toString();
} else if (number < 10000) {
return '${(number / 1000).toStringAsFixed(1)}K';
} else {
return '${(number / 10000).toStringAsFixed(1)}';
}
}
}
// 波浪裁剪器
class _WaveClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
var path = Path();
path.lineTo(0, size.height * 0.8);
var firstControlPoint = Offset(size.width / 4, size.height);
var firstEndPoint = Offset(size.width / 2.2, size.height * 0.85);
path.quadraticBezierTo(
firstControlPoint.dx,
firstControlPoint.dy,
firstEndPoint.dx,
firstEndPoint.dy,
);
var secondControlPoint =
Offset(size.width - (size.width / 3.5), size.height * 0.65);
var secondEndPoint = Offset(size.width, size.height * 0.7);
path.quadraticBezierTo(
secondControlPoint.dx,
secondControlPoint.dy,
secondEndPoint.dx,
secondEndPoint.dy,
);
path.lineTo(size.width, 0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
// 网格绘制器
class _GridPainter extends CustomPainter {
_GridPainter({
required this.color,
required this.lineWidth,
required this.spacing,
});
final Color color;
final double lineWidth;
final double spacing;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = lineWidth
..style = PaintingStyle.stroke;
// 水平线
for (double y = 0; y <= size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
// 垂直线
for (double x = 0; x <= size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
}
@override
bool shouldRepaint(CustomPainter oldPainter) => false;
}
// 圆形绘制器
class _CirclePainter extends CustomPainter {
_CirclePainter({
required this.color,
required this.baseColor,
});
final Color color;
final Color baseColor;
@override
void paint(Canvas canvas, Size size) {
final centerX = size.width / 2;
final centerY = size.height / 2;
// 绘制多个同心圆
for (int i = 5; i > 0; i--) {
final radius = (size.width / 2) * (i / 5);
final paint = Paint()
..color = i % 2 == 0 ? color : baseColor.withOpacity(0.3)
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, centerY), radius, paint);
}
}
@override
bool shouldRepaint(CustomPainter oldPainter) => false;
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 空小说列表视图组件
class EmptyNovelView extends StatelessWidget {
const EmptyNovelView({
super.key,
required this.onCreateTap,
});
final VoidCallback onCreateTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.outlineVariant,
width: 1.0,
),
),
child: Icon(
Icons.auto_stories,
size: 64,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
Text(
'没有找到小说',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 8),
Text(
'开始创建您的第一部小说作品吧',
style: TextStyle(
fontSize: 16,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: onCreateTap,
icon: const Icon(Icons.add),
label: const Text('创建小说'),
style: WebTheme.getSecondaryButtonStyle(context).copyWith(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
textStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 16),
),
foregroundColor: WidgetStateProperty.all(
WebTheme.getTextColor(context),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FilterNovelsDialog extends StatefulWidget {
const FilterNovelsDialog({super.key});
@override
State<FilterNovelsDialog> createState() => _FilterNovelsDialogState();
}
class _FilterNovelsDialogState extends State<FilterNovelsDialog> {
late FilterOption _currentFilterOption;
final TextEditingController _seriesController = TextEditingController();
@override
void initState() {
super.initState();
_currentFilterOption = (context.read<NovelListBloc>().state as NovelListLoaded).filterOption;
_seriesController.text = _currentFilterOption.series ?? '';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('过滤选项'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('按系列过滤:'),
TextField(
controller: _seriesController,
decoration: const InputDecoration(
hintText: '输入系列名称',
),
onChanged: (value) {
// 用户输入时可以实时更新预览,或者在点击应用时更新
},
),
// 在这里可以添加更多过滤条件,例如字数范围、完成状态等
// SwitchListTile for completion status, RangeSlider for word count, etc.
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
TextButton(
onPressed: () {
final newFilterOption = FilterOption(
series: _seriesController.text.trim().isNotEmpty ? _seriesController.text.trim() : null,
// 其他过滤条件从UI元素获取
);
context.read<NovelListBloc>().add(FilterNovels(filterOption: newFilterOption));
Navigator.of(context).pop();
},
child: const Text('应用'),
),
],
);
}
@override
void dispose() {
_seriesController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/theme_toggle_button.dart';
/// 标题栏组件
class HeaderSection extends StatelessWidget {
const HeaderSection({
super.key,
required this.onCreateNovel,
required this.onImportNovel,
});
final VoidCallback onCreateNovel;
final VoidCallback onImportNovel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.08),
offset: const Offset(0, 2),
blurRadius: 4,
),
],
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outlineVariant,
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.menu_book,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 12),
Text(
'你的小说',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
Row(
children: [
// 主题切换按钮
const ThemeToggleButton(),
const SizedBox(width: 12),
// 测试按钮
OutlinedButton.icon(
onPressed: () {
},
icon: Icon(
Icons.bug_report,
color: theme.colorScheme.onSurface,
),
label: const Text('测试'),
style: WebTheme.getSecondaryButtonStyle(context),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: onImportNovel,
icon: Icon(
Icons.file_upload,
color: theme.colorScheme.onSurface,
),
label: const Text('导入'),
style: WebTheme.getSecondaryButtonStyle(context),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: onCreateNovel,
icon: Icon(
Icons.add,
color: theme.colorScheme.onSurface,
),
label: const Text('创建小说'),
style: WebTheme.getSecondaryButtonStyle(context),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,448 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// Removed unused import
import 'package:ainoval/blocs/novel_import/novel_import_bloc.dart';
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
/// 小说导入对话框
class ImportNovelDialog extends StatefulWidget {
/// 创建小说导入对话框
const ImportNovelDialog({super.key});
@override
State<ImportNovelDialog> createState() => _ImportNovelDialogState();
}
class _ImportNovelDialogState extends State<ImportNovelDialog> {
// 存储BLoC引用避免在dispose中访问context
late final NovelImportBloc _importBloc;
@override
void initState() {
super.initState();
// 获取并保存BLoC引用
_importBloc = context.read<NovelImportBloc>();
// 初始化时延迟检查,确保 context 已经准备好
WidgetsBinding.instance.addPostFrameCallback((_) {
// 检查状态并在需要时重置
final state = _importBloc.state;
if (state is NovelImportSuccess || state is NovelImportFailure) {
_importBloc.add(ResetImportState());
}
});
}
@override
void dispose() {
// 确保在对话框关闭时只触发一次重置
final state = _importBloc.state;
if (state is NovelImportInProgress && state.status != 'CANCELLING') {
_importBloc.add(ResetImportState());
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<NovelImportBloc, NovelImportState>(
listener: (context, state) {
if (state is NovelImportSuccess) {
// 延迟关闭对话框,给用户一个成功的视觉反馈
Future.delayed(const Duration(milliseconds: 800), () {
if (context.mounted) {
// 重置导入状态,确保下次打开对话框时状态为初始状态
_importBloc.add(ResetImportState());
context.read<NovelListBloc>().add(LoadNovels());
Navigator.of(context).pop();
// 显示成功消息
TopToast.success(context, '导入成功: ${state.message}');
}
});
}
},
builder: (context, state) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 400,
minHeight: 200,
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题
Row(
children: [
const Icon(Icons.upload_file, size: 24),
const SizedBox(width: 12),
const Text(
'导入小说',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (state is! NovelImportInProgress && state is! NovelImportSuccess)
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
// 状态和内容
_buildDialogContent(context, state),
const SizedBox(height: 16),
// 按钮
_buildDialogActions(context, state),
],
),
),
),
);
},
);
}
/// 构建对话框内容
Widget _buildDialogContent(BuildContext context, NovelImportState state) {
if (state is NovelImportInitial) {
return Column(
children: [
Icon(
Icons.upload_file,
size: 64,
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
),
const SizedBox(height: 16),
const Text(
'导入TXT格式的小说文件系统将自动识别章节结构。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'支持的文件格式: TXT (UTF-8编码)',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: WebTheme.getSecondaryTextColor(context)),
),
],
);
} else if (state is NovelImportInProgress) {
return Column(
children: [
const SizedBox(height: 16),
// 进度指示器
LinearProgressIndicator(
value: state.status == 'PREPARING' || state.status == 'UPLOADING'
? null // 不确定进度时使用不确定进度条
: (state.progress != null && state.progress! > 0) ? state.progress : null,
backgroundColor: WebTheme.isDarkMode(context) ? WebTheme.darkGrey100 : WebTheme.grey100,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 24),
// 状态图标
_buildStatusIcon(context, state.status),
const SizedBox(height: 16),
// 状态文本
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
// 二级状态文本
Text(
_getStatusDescription(state.status),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
// 添加调试信息,在开发环境下显示
if (state.jobId != null)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: WebTheme.getSurfaceColor(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'任务ID: ${state.jobId}',
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: WebTheme.getSecondaryTextColor(context),
),
),
Text(
'状态: ${state.status}',
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
),
),
],
);
} else if (state is NovelImportSuccess) {
return Column(
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 16),
Text(
'导入成功',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.secondary,
),
),
const SizedBox(height: 8),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14),
),
],
);
} else if (state is NovelImportFailure) {
return Column(
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'导入失败',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Theme.of(context).colorScheme.error.withOpacity(0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'可能的原因:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
const SizedBox(height: 8),
Text(
'• 文件编码不是UTF-8\n'
'• 文件格式不正确\n'
'• 文件可能已损坏\n'
'• 服务器暂时无法处理',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onErrorContainer,
height: 1.5,
),
),
],
),
),
],
);
}
return const SizedBox.shrink();
}
/// 构建状态图标
Widget _buildStatusIcon(BuildContext context, String status) {
IconData iconData;
Color iconColor;
switch (status) {
case 'PREPARING':
iconData = Icons.file_present;
iconColor = Theme.of(context).colorScheme.primary;
break;
case 'UPLOADING':
iconData = Icons.cloud_upload;
iconColor = Theme.of(context).colorScheme.primary;
break;
case 'PROCESSING':
iconData = Icons.auto_stories;
iconColor = WebTheme.getTextColor(context);
break;
case 'SAVING':
iconData = Icons.save;
iconColor = WebTheme.getTextColor(context);
break;
case 'INDEXING':
iconData = Icons.search;
iconColor = WebTheme.getSecondaryTextColor(context);
break;
default:
iconData = Icons.sync;
iconColor = WebTheme.getTextColor(context);
}
return SizedBox(
width: 48,
height: 48,
child: Stack(
children: [
Icon(
iconData,
size: 48,
color: iconColor,
),
if (status == 'PROCESSING' || status == 'INDEXING')
Positioned.fill(
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
),
),
],
),
);
}
/// 获取状态描述
String _getStatusDescription(String status) {
switch (status) {
case 'PREPARING':
return '正在准备文件数据...';
case 'UPLOADING':
return '正在上传文件到服务器...';
case 'PROCESSING':
return '正在分析文件内容,识别章节结构...';
case 'SAVING':
return '正在保存小说结构和章节内容...';
case 'INDEXING':
return '正在为小说内容创建索引以便AI更好地理解...';
default:
return '处理中...';
}
}
/// 构建对话框按钮
Widget _buildDialogActions(BuildContext context, NovelImportState state) {
if (state is NovelImportInitial) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.cancel),
label: const Text('取消'),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: () => _importBloc.add(ImportNovelFile()),
icon: const Icon(Icons.upload_file),
label: const Text('选择文件'),
),
],
);
} else if (state is NovelImportInProgress) {
return Center(
child: TextButton.icon(
onPressed: () {
_importBloc.add(ResetImportState());
TopToast.info(context, '已取消导入');
Navigator.of(context).pop();
},
icon: const Icon(Icons.cancel),
label: const Text('取消导入'),
),
);
} else if (state is NovelImportFailure) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
OutlinedButton.icon(
onPressed: () {
_importBloc.add(ResetImportState());
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
label: const Text('关闭'),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: () {
_importBloc.add(ResetImportState());
_importBloc.add(ImportNovelFile());
},
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
);
} else if (state is NovelImportSuccess) {
return FilledButton.icon(
onPressed: () {
_importBloc.add(ResetImportState());
context.read<NovelListBloc>().add(LoadNovels());
Navigator.of(context).pop();
},
icon: const Icon(Icons.check),
label: const Text('完成'),
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
/// 小说列表加载状态组件
class LoadingView extends StatelessWidget {
const LoadingView({
super.key,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 3,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 16),
Text(
'加载您的小说库...',
style: TextStyle(
fontSize: 14,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,652 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/widgets/common/animated_container_widget.dart';
import 'package:ainoval/widgets/common/model_display_selector.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/models/strategy_template_info.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_bloc.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_event.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_state.dart';
import '../../setting_generation/novel_settings_generator_screen.dart';
class NovelInputNew extends StatefulWidget {
final String prompt;
final Function(String) onPromptChanged;
final UnifiedAIModel? selectedModel;
final Function(UnifiedAIModel?)? onModelSelected;
const NovelInputNew({
Key? key,
required this.prompt,
required this.onPromptChanged,
this.selectedModel,
this.onModelSelected,
}) : super(key: key);
@override
State<NovelInputNew> createState() => _NovelInputNewState();
}
class _NovelInputNewState extends State<NovelInputNew> with TickerProviderStateMixin {
late TextEditingController _controller;
bool _isGenerating = false;
bool _isPolishing = false;
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
String _selectedStrategy = ''; // 默认为空,将从后端获取策略列表后设置
bool _suppressControllerListener = false; // 避免程序化同步时反向通知父组件
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.prompt);
_controller.addListener(() {
if (_suppressControllerListener) return;
widget.onPromptChanged(_controller.text);
});
_pulseController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
_pulseAnimation = Tween<double>(
begin: 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
// 首帧后启动心跳动画,避免在构建期/重启切换期驱动渲染
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_pulseController.repeat(reverse: true);
}
});
// 初始化时加载可用策略(仅已登录时)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final String? userId = AppConfig.userId; // 未登录为 null
if (userId != null && userId.isNotEmpty) {
context.read<SettingGenerationBloc>().add(const LoadStrategiesEvent());
}
});
}
@override
void reassemble() {
super.reassemble();
if (!mounted) return;
// 热重载/重启后,停止并在下一帧重启动画,避免在已释放的视图上渲染
_pulseController.stop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_pulseController.repeat(reverse: true);
}
});
}
@override
void didUpdateWidget(NovelInputNew oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.prompt != oldWidget.prompt && widget.prompt != _controller.text) {
_suppressControllerListener = true;
_controller.value = TextEditingValue(
text: widget.prompt,
selection: TextSelection.collapsed(offset: widget.prompt.length),
);
_suppressControllerListener = false;
}
}
@override
void dispose() {
if (_pulseController.isAnimating) {
_pulseController.stop();
}
_controller.dispose();
_pulseController.dispose();
super.dispose();
}
// Future<void> _handleGenerate() async {
// if (_controller.text.trim().isEmpty) return;
//
// setState(() {
// _isGenerating = true;
// });
// // 模拟生成过程
// await Future.delayed(const Duration(seconds: 2));
// setState(() {
// _isGenerating = false;
// });
// }
// Future<void> _handlePolish() async {
// if (_controller.text.trim().isEmpty) return;
//
// setState(() {
// _isPolishing = true;
// });
// // 模拟AI润色过程
// await Future.delayed(const Duration(milliseconds: 1500));
//
// final polishedPrompt = '经过AI润色${_controller.text}。增加更多细节描述,包含丰富的情感色彩和生动的场景描写,让故事更加引人入胜。';
// _controller.text = polishedPrompt;
//
// setState(() {
// _isPolishing = false;
// });
// }
void _handleGenerateSettings() {
if (_controller.text.trim().isEmpty || widget.selectedModel == null) return;
// 打开设定生成器对话框,并传递选择的策略
_showSettingGeneratorDialog(context);
}
void _showSettingGeneratorDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _SettingGeneratorDialog(
initialPrompt: _controller.text.trim(),
selectedModel: widget.selectedModel,
selectedStrategy: _selectedStrategy,
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedContainerWidget(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
// Icon with animation
Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
WebTheme.getPrimaryColor(context).withOpacity(0.3 * _pulseAnimation.value),
WebTheme.getSecondaryColor(context).withOpacity(0.2 * _pulseAnimation.value),
],
),
),
);
},
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
WebTheme.getPrimaryColor(context),
WebTheme.getSecondaryColor(context),
],
),
),
child: Icon(
Icons.auto_awesome,
size: 32,
color: WebTheme.white,
),
),
],
),
const SizedBox(height: 24),
// Title
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'AI小说设定助手',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
foreground: Paint()
..shader = LinearGradient(
colors: [
WebTheme.getPrimaryColor(context),
WebTheme.getPrimaryColor(context).withOpacity(0.8),
WebTheme.getSecondaryColor(context),
],
).createShader(const Rect.fromLTWH(0, 0, 400, 70)),
),
),
],
),
const SizedBox(height: 8),
// Subtitle
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_awesome,
size: 16,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 8),
Text(
'设定生成,黄金三章',
style: TextStyle(
fontSize: 18,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(width: 8),
Icon(
Icons.auto_awesome,
size: 16,
color: WebTheme.getPrimaryColor(context),
),
],
),
const SizedBox(height: 16),
// Description
Container(
constraints: const BoxConstraints(maxWidth: 800),
child: Text(
'输入您的创意想法或者选择下方的分类标签让AI为您创作精彩的小说设定和开篇黄金三章',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1.6,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
],
),
),
// Input Area
Container(
constraints: const BoxConstraints(maxWidth: 1000),
child: Stack(
children: [
// Background blur effect
Container(
margin: const EdgeInsets.all(8),
height: 240,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
WebTheme.getPrimaryColor(context).withOpacity(0.1),
WebTheme.getSecondaryColor(context).withOpacity(0.05),
],
),
),
),
// Text Field
Container(
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context).withOpacity(0.8),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: WebTheme.getBorderColor(context),
width: 2,
),
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
TextField(
controller: _controller,
maxLines: 8,
style: TextStyle(
fontSize: 18,
height: 1.6,
color: WebTheme.getTextColor(context),
),
decoration: InputDecoration(
hintText: '请输入您的小说创意想法,例如:一个现代都市的年轻程序员意外获得了穿越时空的能力...',
hintStyle: TextStyle(
color: WebTheme.getSecondaryTextColor(context).withOpacity(0.6),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(24),
),
),
// Bottom Actions
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: WebTheme.getEmptyStateColor(context).withOpacity(0.5),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
child: Row(
children: [
// 左侧区域:模型选择器 + 策略选择器 (占4份)
Expanded(
flex: 4,
child: Row(
children: [
// Model Selection Button
Expanded(
flex: 2,
child: ModelDisplaySelector(
selectedModel: widget.selectedModel,
onModelSelected: widget.onModelSelected,
size: ModelDisplaySize.small,
height: 48, // 增加一半高度保持一致
showIcon: true,
showTags: true,
showSettingsButton: true,
placeholder: '选择AI模型',
),
),
const SizedBox(width: 8),
// Strategy Selection Dropdown
Expanded(
flex: 1,
child: _buildStrategySelector(),
),
],
),
),
// 中间留空区域 (占3份)
const Expanded(
flex: 3,
child: SizedBox(),
),
// 右侧区域:生成设定按钮 (占2份)
Expanded(
flex: 2,
child: SizedBox(
height: 48, // 确保按钮高度与其他组件一致
child: OutlinedButton.icon(
onPressed: _controller.text.trim().isEmpty ||
widget.selectedModel == null ||
_isGenerating ||
_isPolishing
? null
: _handleGenerateSettings,
icon: const Icon(Icons.psychology, size: 18),
label: const Text('生成设定'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
side: BorderSide(
color: Theme.of(context).colorScheme.secondary.withOpacity(0.3),
width: 1.5,
),
),
),
),
),
// // Polish Button
// Flexible(
// child: OutlinedButton.icon(
// onPressed: _controller.text.trim().isEmpty || _isPolishing || _isGenerating
// ? null
// : _handlePolish,
// icon: _isPolishing
// ? SizedBox(
// width: 16,
// height: 16,
// child: CircularProgressIndicator(
// strokeWidth: 2,
// valueColor: AlwaysStoppedAnimation<Color>(
// WebTheme.getPrimaryColor(context),
// ),
// ),
// )
// : const Icon(Icons.auto_fix_high, size: 18),
// label: Text(_isPolishing ? 'AI润色中...' : 'AI润色'),
// style: OutlinedButton.styleFrom(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
// side: BorderSide(
// color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
// width: 1.5,
// ),
// ),
// ),
// ),
// // Generate Button
// Flexible(
// child: ElevatedButton.icon(
// onPressed: _controller.text.trim().isEmpty || _isGenerating || _isPolishing
// ? null
// : _handleGenerate,
// icon: _isGenerating
// ? SizedBox(
// width: 18,
// height: 18,
// child: CircularProgressIndicator(
// strokeWidth: 2,
// valueColor: AlwaysStoppedAnimation<Color>(
// WebTheme.white,
// ),
// ),
// )
// : const Icon(Icons.send, size: 18),
// label: Text(_isGenerating ? 'AI正在创作中...' : '开始创作'),
// style: ElevatedButton.styleFrom(
// padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
// backgroundColor: WebTheme.getPrimaryColor(context),
// foregroundColor: WebTheme.white,
// elevation: 0,
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8),
// ),
// ),
// ),
// ),
],
),
),
],
),
),
],
),
),
],
),
);
}
/// 构建策略选择器
Widget _buildStrategySelector() {
return BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
List<StrategyTemplateInfo> strategies = [];
bool isLoading = false;
if (state is SettingGenerationInitial) {
isLoading = true;
} else if (state is SettingGenerationReady) {
strategies = state.strategies;
} else if (state is SettingGenerationInProgress) {
strategies = state.strategies;
} else if (state is SettingGenerationCompleted) {
strategies = state.strategies;
}
// 如果策略为空,显示加载状态而不是使用硬编码默认值
if (strategies.isEmpty && !isLoading) {
isLoading = true;
}
// 智能选择当前策略:优先选择“番茄小说/网文/tomato”否则回退到“九线法”再否则选第一个
if (strategies.isNotEmpty && (_selectedStrategy.isEmpty || !strategies.any((s) => s.promptTemplateId == _selectedStrategy))) {
// 1) 优先匹配番茄网文策略
final tomatoStrategy = strategies.where((s) =>
s.name.contains('番茄') ||
s.name.contains('网文') ||
s.name.toLowerCase().contains('tomato')
).toList();
if (tomatoStrategy.isNotEmpty) {
_selectedStrategy = tomatoStrategy.first.promptTemplateId;
} else {
// 2) 次选:九线法
final nineLineStrategy = strategies.where((s) =>
s.name.contains('九线法') ||
s.name.contains('nine-line') ||
s.name.toLowerCase().contains('nine')
).toList();
if (nineLineStrategy.isNotEmpty) {
_selectedStrategy = nineLineStrategy.first.promptTemplateId;
} else {
// 3) 兜底:第一个
_selectedStrategy = strategies.first.promptTemplateId;
}
}
}
return Container(
height: 48, // 增加一半高度 (32 * 1.5)
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context).withOpacity(0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context).withOpacity(0.3),
width: 1,
),
),
child: isLoading
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getPrimaryColor(context),
),
),
),
const SizedBox(width: 6),
Text(
'加载中...',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
)
: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedStrategy.isEmpty ? null : _selectedStrategy,
isExpanded: true,
style: TextStyle(
fontSize: 12,
color: WebTheme.getTextColor(context),
),
dropdownColor: WebTheme.getSurfaceColor(context),
icon: Icon(
Icons.arrow_drop_down,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
items: strategies.map((strategy) {
return DropdownMenuItem(
value: strategy.promptTemplateId,
child: Tooltip(
message: strategy.description,
child: Text(
strategy.name,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStrategy = value;
});
// 记录用户的选择以便调试
print('用户选择策略: $value');
}
},
),
),
);
},
);
}
}
/// 设定生成器对话框包装器
class _SettingGeneratorDialog extends StatelessWidget {
final String initialPrompt;
final UnifiedAIModel? selectedModel;
final String selectedStrategy;
const _SettingGeneratorDialog({
required this.initialPrompt,
this.selectedModel,
required this.selectedStrategy,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
children: [
// Setting generator content
Expanded(
child: NovelSettingsGeneratorScreen(
initialPrompt: initialPrompt,
selectedModel: selectedModel,
selectedStrategy: selectedStrategy,
autoStart: true, // 自动开始生成
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
class NovelListErrorView extends StatelessWidget {
const NovelListErrorView({
super.key,
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: ErrorCard(
title: '加载失败',
message: message,
icon: Icons.error_outline_rounded,
primaryAction: RetryButton(onRetry: onRetry),
secondaryAction: const HelpButton(),
),
);
}
}
/// 通用错误展示卡片
class ErrorCard extends StatelessWidget {
const ErrorCard({
super.key,
required this.title,
required this.message,
required this.icon,
required this.primaryAction,
this.secondaryAction,
this.maxWidth = 320,
});
final String title;
final String message;
final IconData icon;
final Widget primaryAction;
final Widget? secondaryAction;
final double maxWidth;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
constraints: BoxConstraints(maxWidth: maxWidth),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 图标部分
ErrorIconContainer(
icon: icon,
iconColor: theme.colorScheme.error,
backgroundColor: theme.colorScheme.errorContainer.withOpacity(0.2),
),
const SizedBox(height: 24),
// 标题
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// 消息内容
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
height: 1.4,
),
),
const SizedBox(height: 24),
// 主操作按钮
primaryAction,
// 次要操作按钮
if (secondaryAction != null) ...[
const SizedBox(height: 8),
secondaryAction!,
],
],
),
);
}
}
/// 错误图标容器
class ErrorIconContainer extends StatelessWidget {
const ErrorIconContainer({
super.key,
required this.icon,
required this.iconColor,
required this.backgroundColor,
this.size = 48,
this.padding = 16,
});
final IconData icon;
final Color iconColor;
final Color backgroundColor;
final double size;
final double padding;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: size,
color: iconColor,
),
);
}
}
/// 重试按钮
class RetryButton extends StatelessWidget {
const RetryButton({
super.key,
required this.onRetry,
});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('重新加载'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
/// 帮助按钮
class HelpButton extends StatelessWidget {
const HelpButton({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return TextButton(
onPressed: () {
// 添加一个帮助选项
TopToast.info(context, '帮助功能将在下一个版本中实现');
},
child: Text(
'需要帮助?',
style: TextStyle(
color: theme.colorScheme.primary.withOpacity(0.8),
),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:ainoval/widgets/common/app_search_field.dart';
import 'package:ainoval/widgets/common/app_filter_button.dart';
import 'package:ainoval/widgets/common/app_view_toggle.dart';
import 'package:ainoval/widgets/common/app_toolbar.dart';
/// 搜索和过滤工具栏组件
class SearchFilterBar extends StatelessWidget {
const SearchFilterBar({
super.key,
required this.searchController,
required this.isGridView,
required this.onSearchChanged,
required this.onViewTypeChanged,
required this.onFilterPressed,
required this.onSortPressed,
required this.onGroupPressed,
this.onRefreshPressed,
});
final TextEditingController searchController;
final bool isGridView;
final ValueChanged<String> onSearchChanged;
final ValueChanged<bool> onViewTypeChanged;
final VoidCallback onFilterPressed;
final VoidCallback onSortPressed;
final VoidCallback onGroupPressed;
final VoidCallback? onRefreshPressed;
@override
Widget build(BuildContext context) {
return AppToolbar(
children: [
// 搜索框
Expanded(
child: AppSearchField(
controller: searchController,
onChanged: onSearchChanged,
hintText: '搜索名称/系列...',
),
),
const SizedBox(width: 16),
// 过滤器按钮组
Wrap(
spacing: 8,
children: [
AppFilterButton(
label: '过滤',
icon: Icons.filter_list,
onPressed: onFilterPressed,
),
AppFilterButton(
label: '排序',
icon: Icons.sort,
onPressed: onSortPressed,
),
AppFilterButton(
label: '分组',
icon: Icons.group_work,
onPressed: onGroupPressed,
),
if (onRefreshPressed != null)
AppFilterButton(
label: '刷新',
icon: Icons.refresh,
onPressed: onRefreshPressed!,
),
],
),
const SizedBox(width: 12),
// 视图切换按钮
AppViewToggle(
isGridView: isGridView,
onViewTypeChanged: onViewTypeChanged,
),
],
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SortNovelsDialog extends StatelessWidget {
const SortNovelsDialog({super.key});
@override
Widget build(BuildContext context) {
final currentSortOption = (context.read<NovelListBloc>().state as NovelListLoaded).sortOption;
return AlertDialog(
title: const Text('排序方式'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: SortOption.values.map((option) {
return RadioListTile<SortOption>(
title: Text(_getSortOptionText(option)),
value: option,
groupValue: currentSortOption,
onChanged: (SortOption? value) {
if (value != null) {
context.read<NovelListBloc>().add(SortNovels(sortOption: value));
Navigator.of(context).pop();
}
},
);
}).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
],
);
}
String _getSortOptionText(SortOption option) {
switch (option) {
case SortOption.lastEdited:
return '最后编辑';
case SortOption.title:
return '标题';
case SortOption.wordCount:
return '字数';
case SortOption.creationDate:
return '创建日期';
case SortOption.actCount:
return '卷数';
case SortOption.chapterCount:
return '章节数';
case SortOption.sceneCount:
return '场景数';
default:
return '';
}
}
}