feat:增加博客统计模块
This commit is contained in:
@@ -50,3 +50,52 @@ Future<List<BlogCategory>> queryBlogCategoryApi() {
|
||||
convertListResponse<BlogCategory>(data, BlogCategory.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<BlogStats> queryBlogOverviewStatsApi() {
|
||||
return HttpUtil().get<BlogStats>(
|
||||
"/stats/overview",
|
||||
converter: (data) => BlogStats.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChartData>> queryBlogApprovedStatsApi(int year) {
|
||||
return HttpUtil().get<List<ChartData>>(
|
||||
"/stats/approved/monthly",
|
||||
queryParameters: {"year": year},
|
||||
converter:
|
||||
(data) => convertListResponse<ChartData>(data, ChartData.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChartData>> queryBlogVisitStatsApi(int year) {
|
||||
return HttpUtil().get<List<ChartData>>(
|
||||
"/stats/visit/monthly",
|
||||
queryParameters: {"year": year},
|
||||
converter:
|
||||
(data) => convertListResponse<ChartData>(data, ChartData.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChartData>> queryBlogVisitRankStatsApi() {
|
||||
return HttpUtil().get<List<ChartData>>(
|
||||
"/stats/visit/rank",
|
||||
converter:
|
||||
(data) => convertListResponse<ChartData>(data, ChartData.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChartData>> queryBlogCategoryStatsApi() {
|
||||
return HttpUtil().get<List<ChartData>>(
|
||||
"/stats/category",
|
||||
converter:
|
||||
(data) => convertListResponse<ChartData>(data, ChartData.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ChartData>> queryBlogReadRankStatsApi() {
|
||||
return HttpUtil().get<List<ChartData>>(
|
||||
"/stats/read/rank",
|
||||
converter:
|
||||
(data) => convertListResponse<ChartData>(data, ChartData.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import 'package:blog_app/pages/blog_page.dart';
|
||||
import 'package:blog_app/pages/category_page.dart';
|
||||
import 'package:blog_app/pages/stats_page.dart';
|
||||
import 'package:blog_app/pages/timeline_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PageInfo {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Widget page;
|
||||
|
||||
const PageInfo({required this.title, required this.icon});
|
||||
const PageInfo({required this.title, required this.icon, required this.page});
|
||||
}
|
||||
|
||||
final List<PageInfo> pages = [
|
||||
PageInfo(title: '博客', icon: Icons.article),
|
||||
PageInfo(title: '分类', icon: Icons.folder),
|
||||
PageInfo(title: '个人中心', icon: Icons.person),
|
||||
PageInfo(title: '设置', icon: Icons.settings),
|
||||
PageInfo(title: '关于我们', icon: Icons.info),
|
||||
PageInfo(title: '博客', icon: Icons.article, page: BlogPage()),
|
||||
PageInfo(title: '分类', icon: Icons.folder, page: CategoryPage()),
|
||||
PageInfo(title: '归档', icon: Icons.archive, page: TimelinePage()),
|
||||
PageInfo(title: '统计', icon: Icons.insert_chart, page: StatsPage()),
|
||||
];
|
||||
|
||||
final List<Widget> pageWidgets = pages.map((item) => item.page).toList();
|
||||
|
||||
@@ -76,3 +76,34 @@ class BlogCategory {
|
||||
|
||||
Map<String, dynamic> toJson() => _$BlogCategoryToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(genericArgumentFactories: true)
|
||||
class BlogStats {
|
||||
@JsonKey(name: 'blogCount')
|
||||
final int blogCount;
|
||||
|
||||
@JsonKey(name: 'categoryCount')
|
||||
final int categoryCount;
|
||||
|
||||
@JsonKey(name: 'greatCount')
|
||||
final int greatCount;
|
||||
|
||||
@JsonKey(name: 'visitCount')
|
||||
final int visitCount;
|
||||
|
||||
@JsonKey(name: 'wordCount')
|
||||
final int wordCount;
|
||||
|
||||
const BlogStats({
|
||||
required this.blogCount,
|
||||
required this.categoryCount,
|
||||
required this.greatCount,
|
||||
required this.visitCount,
|
||||
required this.wordCount,
|
||||
});
|
||||
|
||||
factory BlogStats.fromJson(Map<String, dynamic> json) =>
|
||||
_$BlogStatsFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BlogStatsToJson(this);
|
||||
}
|
||||
|
||||
@@ -43,3 +43,19 @@ BlogCategory _$BlogCategoryFromJson(Map<String, dynamic> json) => BlogCategory(
|
||||
|
||||
Map<String, dynamic> _$BlogCategoryToJson(BlogCategory instance) =>
|
||||
<String, dynamic>{'name': instance.name, 'count': instance.count};
|
||||
|
||||
BlogStats _$BlogStatsFromJson(Map<String, dynamic> json) => BlogStats(
|
||||
blogCount: (json['blogCount'] as num).toInt(),
|
||||
categoryCount: (json['categoryCount'] as num).toInt(),
|
||||
greatCount: (json['greatCount'] as num).toInt(),
|
||||
visitCount: (json['visitCount'] as num).toInt(),
|
||||
wordCount: (json['wordCount'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BlogStatsToJson(BlogStats instance) => <String, dynamic>{
|
||||
'blogCount': instance.blogCount,
|
||||
'categoryCount': instance.categoryCount,
|
||||
'greatCount': instance.greatCount,
|
||||
'visitCount': instance.visitCount,
|
||||
'wordCount': instance.wordCount,
|
||||
};
|
||||
|
||||
@@ -35,3 +35,19 @@ class PageResult<T> {
|
||||
Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
|
||||
_$PageResultToJson(this, toJsonT);
|
||||
}
|
||||
|
||||
@JsonSerializable(genericArgumentFactories: true)
|
||||
class ChartData {
|
||||
@JsonKey(name: 'name')
|
||||
final String name;
|
||||
|
||||
@JsonKey(name: 'value')
|
||||
final double value;
|
||||
|
||||
const ChartData({required this.name, required this.value});
|
||||
|
||||
factory ChartData.fromJson(Map<String, dynamic> json) =>
|
||||
_$ChartDataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ChartDataToJson(this);
|
||||
}
|
||||
|
||||
@@ -27,3 +27,13 @@ Map<String, dynamic> _$PageResultToJson<T>(
|
||||
'current': instance.current,
|
||||
'pages': instance.pages,
|
||||
};
|
||||
|
||||
ChartData _$ChartDataFromJson(Map<String, dynamic> json) => ChartData(
|
||||
name: json['name'] as String,
|
||||
value: (json['value'] as num).toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChartDataToJson(ChartData instance) => <String, dynamic>{
|
||||
'name': instance.name,
|
||||
'value': instance.value,
|
||||
};
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FlutterLogo(size: 100),
|
||||
SizedBox(height: 24),
|
||||
Text(
|
||||
'我的Flutter应用',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'版本 2.1.0',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 32),
|
||||
Container(
|
||||
width: 200,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAboutItem('编译版本', '2.1.0 (20240115)'),
|
||||
_buildAboutItem('更新时间', '2024年1月15日'),
|
||||
_buildAboutItem('开发者', 'Flutter开发团队'),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: Icon(Icons.star),
|
||||
label: Text('给我们评分'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAboutItem(String label, String value) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.grey)),
|
||||
Text(value, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import 'package:blog_app/apis/blog.dart';
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/widget/blog.dart';
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BlogListPage extends StatefulWidget {
|
||||
final String category;
|
||||
|
||||
const BlogListPage({super.key, required this.category});
|
||||
|
||||
@override
|
||||
State<BlogListPage> createState() => _BlogListPageState();
|
||||
}
|
||||
|
||||
class _BlogListPageState extends State<BlogListPage> {
|
||||
late List<Blog> blogs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBlogList();
|
||||
}
|
||||
|
||||
Future<void> _loadBlogList() async {
|
||||
try {
|
||||
final result = await queryBlogByConditionApi(widget.category, null, null);
|
||||
setState(() {
|
||||
blogs = result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('获取博客列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'博客列表',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'共 ${blogs.length} 篇',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlogItem(BuildContext context, Blog blog, int index) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 25,
|
||||
child: Text(
|
||||
index.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 发布时间
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
blog.createTime,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
blog.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlogList() {
|
||||
return ListView.builder(
|
||||
itemCount: blogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final blog = blogs[index];
|
||||
return InkWell(
|
||||
onTap: () => navigatorToBlogDetail(context, blog.id),
|
||||
child: _buildBlogItem(context, blog, index + 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('博客详情')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
widget.category,
|
||||
style: TextStyle(fontSize: 20, color: colors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildTitle(),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: blogs.isEmpty ? buildEmpty() : _buildBlogList()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
lib/pages/category_list_page.dart
Normal file
74
lib/pages/category_list_page.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:blog_app/apis/blog.dart';
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/widget/blog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoryListPage extends StatefulWidget {
|
||||
final String category;
|
||||
|
||||
const CategoryListPage({super.key, required this.category});
|
||||
|
||||
@override
|
||||
State<CategoryListPage> createState() => _CategoryListPageState();
|
||||
}
|
||||
|
||||
class _CategoryListPageState extends State<CategoryListPage> {
|
||||
late List<Blog> blogs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBlogList();
|
||||
}
|
||||
|
||||
Future<void> _loadBlogList() async {
|
||||
try {
|
||||
final result = await queryBlogByConditionApi(widget.category, null, null);
|
||||
setState(() {
|
||||
blogs = result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('获取博客列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBlogList() {
|
||||
return ListView.builder(
|
||||
itemCount: blogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final blog = blogs[index];
|
||||
return InkWell(
|
||||
onTap: () => navigatorToBlogDetail(context, blog.id),
|
||||
child: buildBlogListItem(context, blog, index + 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('博客详情')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
widget.category,
|
||||
style: TextStyle(fontSize: 20, color: colors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
buildListTitle(blogs.length),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: blogs.isEmpty ? buildEmpty() : _buildBlogList()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:blog_app/apis/blog.dart';
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/pages/blog_list_page.dart';
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:blog_app/widget/blog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CategoryPage extends StatefulWidget {
|
||||
@@ -31,34 +30,12 @@ class _CategoryPageState extends State<CategoryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'共 ${categories.length} 个分类',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTitle(),
|
||||
buildCategoryTitle(context, categories.length),
|
||||
SizedBox(height: 10),
|
||||
// 分类列表
|
||||
Expanded(
|
||||
@@ -66,89 +43,11 @@ class _CategoryPageState extends State<CategoryPage> {
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
return _buildCategoryCard(context, category);
|
||||
return buildCategoryCard(context, category);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryCard(BuildContext context, BlogCategory category) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.folder, color: colors.primary, size: 28),
|
||||
),
|
||||
title: Text(
|
||||
category.name,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'${category.count} 篇博客',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
category.count.toString(),
|
||||
style: TextStyle(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => _navigateToBlogList(context, category),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToBlogList(BuildContext context, BlogCategory category) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlogListPage(category: category.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCategoryDetail(BuildContext context, BlogCategory category) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(category.name),
|
||||
content: Text('该分类下有 ${category.count} 篇博客'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// 这里可以添加跳转到该分类博客列表的逻辑
|
||||
},
|
||||
child: const Text('查看博客'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import 'package:blog_app/layout/drawer.dart';
|
||||
import 'package:blog_app/layout/menu.dart';
|
||||
import 'package:blog_app/pages/about_page.dart';
|
||||
import 'package:blog_app/pages/blog_page.dart';
|
||||
import 'package:blog_app/pages/category_page.dart';
|
||||
import 'package:blog_app/pages/settings_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
@@ -35,7 +31,7 @@ class _HomePageState extends State<HomePage> {
|
||||
_currentPageIndex = index;
|
||||
});
|
||||
},
|
||||
children: [BlogPage(), CategoryPage(), SettingsPage(), AboutPage()],
|
||||
children: pageWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MessagePage extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> messages = [
|
||||
{'title': '系统通知', 'content': '您的账号安全等级已提升', 'time': '10:30', 'unread': false},
|
||||
{'title': '活动提醒', 'content': '新活动即将开始,敬请期待', 'time': '昨天', 'unread': true},
|
||||
{'title': '版本更新', 'content': '新版本v2.1.0已发布', 'time': '2024-01-20', 'unread': false},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: message['unread'] ? Colors.blue : Colors.grey,
|
||||
child: Icon(
|
||||
Icons.notifications,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
message['title'],
|
||||
style: TextStyle(
|
||||
fontWeight: message['unread'] ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(message['content']),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
message['time'],
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (message['unread'])
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 4),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 个人信息卡片
|
||||
Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Colors.blue,
|
||||
child: Icon(Icons.person, size: 40, color: Colors.white),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'张小明',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('高级用户', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.star, size: 16, color: Colors.amber),
|
||||
SizedBox(width: 4),
|
||||
Text('会员等级: VIP3'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
|
||||
// 详细信息
|
||||
_buildInfoItem('手机号码', '138****1234', Icons.phone),
|
||||
_buildInfoItem('邮箱地址', 'zhang@example.com', Icons.email),
|
||||
_buildInfoItem('注册时间', '2024年1月15日', Icons.calendar_today),
|
||||
_buildInfoItem('所在地区', '北京市朝阳区', Icons.location_on),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(String title, String value, IconData icon) {
|
||||
return Card(
|
||||
margin: EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: Colors.blue),
|
||||
title: Text(title),
|
||||
subtitle: Text(value),
|
||||
trailing: Icon(Icons.edit, size: 20),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSettingsSection('账户设置', [
|
||||
_buildSettingsItem('隐私设置', Icons.privacy_tip),
|
||||
_buildSettingsItem('安全设置', Icons.security),
|
||||
_buildSettingsItem('账号绑定', Icons.link),
|
||||
]),
|
||||
|
||||
SizedBox(height: 20),
|
||||
|
||||
_buildSettingsSection('通知设置', [
|
||||
_buildSettingsItem('推送通知', Icons.notifications_active, hasSwitch: true),
|
||||
_buildSettingsItem('声音提醒', Icons.volume_up, hasSwitch: true),
|
||||
_buildSettingsItem('震动提醒', Icons.vibration, hasSwitch: true),
|
||||
]),
|
||||
|
||||
SizedBox(height: 20),
|
||||
|
||||
_buildSettingsSection('其他设置', [
|
||||
_buildSettingsItem('清理缓存', Icons.cleaning_services),
|
||||
_buildSettingsItem('语言设置', Icons.language),
|
||||
_buildSettingsItem('主题设置', Icons.color_lens),
|
||||
_buildSettingsItem('关于应用', Icons.info_outline),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection(String title, List<Widget> children) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsItem(String title, IconData icon, {bool hasSwitch = false}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: Colors.blue),
|
||||
title: Text(title),
|
||||
trailing: hasSwitch
|
||||
? Switch(value: true, onChanged: (value) {})
|
||||
: Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
}
|
||||
304
lib/pages/stats_page.dart
Normal file
304
lib/pages/stats_page.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:blog_app/apis/blog.dart';
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/models/common.dart';
|
||||
import 'package:blog_app/widget/chart.dart';
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:blog_app/widget/year_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatsPage extends StatefulWidget {
|
||||
const StatsPage({super.key});
|
||||
|
||||
@override
|
||||
StatsPageState createState() => StatsPageState();
|
||||
}
|
||||
|
||||
class StatsPageState extends State<StatsPage> {
|
||||
final double chartHeight = 400;
|
||||
final double statsHeight = 120;
|
||||
int currentYear = DateTime.now().year;
|
||||
|
||||
late List<ChartData> approvedStats = [];
|
||||
late List<ChartData> visitStats = [];
|
||||
late List<ChartData> visitRankStats = [];
|
||||
late List<ChartData> categoryStats = [];
|
||||
late List<ChartData> readRankStats = [];
|
||||
late BlogStats overviewStats = BlogStats(
|
||||
blogCount: 0,
|
||||
categoryCount: 0,
|
||||
greatCount: 0,
|
||||
visitCount: 0,
|
||||
wordCount: 0,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadStatsList();
|
||||
}
|
||||
|
||||
Future<void> _loadStatsList() async {
|
||||
try {
|
||||
final approvedResult = await queryBlogApprovedStatsApi(currentYear);
|
||||
final visitResult = await queryBlogVisitStatsApi(currentYear);
|
||||
final visitRankResult = await queryBlogVisitRankStatsApi();
|
||||
final categoryResult = await queryBlogCategoryStatsApi();
|
||||
final readRankResult = await queryBlogReadRankStatsApi();
|
||||
final overviewResult = await queryBlogOverviewStatsApi();
|
||||
|
||||
setState(() {
|
||||
approvedStats = approvedResult;
|
||||
visitStats = visitResult;
|
||||
visitRankStats = visitRankResult;
|
||||
categoryStats = categoryResult;
|
||||
readRankStats = readRankResult;
|
||||
overviewStats = overviewResult;
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('获取统计数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildApprovedStats(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildChartTitle(context, '每月博客发布统计', Icons.show_chart),
|
||||
const SizedBox(height: 3),
|
||||
buildChartDivider(context),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: lineChart(
|
||||
context: context,
|
||||
xAxisName: '月份',
|
||||
yAxisName: '数量',
|
||||
unit: '篇',
|
||||
data: approvedStats,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVisitStats(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildChartTitle(context, '每月博客访问统计', Icons.show_chart),
|
||||
const SizedBox(height: 3),
|
||||
buildChartDivider(context),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: lineChart(
|
||||
context: context,
|
||||
xAxisName: '月份',
|
||||
yAxisName: '访问量',
|
||||
unit: '人次',
|
||||
data: visitStats,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVisitRankStats(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildChartTitle(context, '博客访问数量排行', Icons.bar_chart),
|
||||
const SizedBox(height: 3),
|
||||
buildChartDivider(context),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: barChart(
|
||||
context: context,
|
||||
xAxisName: '名称',
|
||||
yAxisName: '访问量',
|
||||
unit: '人次',
|
||||
data: visitRankStats,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryStats(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildChartTitle(context, '博客类别统计', Icons.pie_chart),
|
||||
const SizedBox(height: 3),
|
||||
buildChartDivider(context),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: pieChart(context: context, unit: '篇', data: categoryStats),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadRankStats(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildChartTitle(context, '博客阅读时长排行', Icons.bar_chart),
|
||||
const SizedBox(height: 3),
|
||||
buildChartDivider(context),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: barChart(
|
||||
context: context,
|
||||
xAxisName: '名称',
|
||||
yAxisName: '时长',
|
||||
unit: '分钟',
|
||||
data: readRankStats,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStatsCard({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required int count,
|
||||
required String unit,
|
||||
}) {
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
unit,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBlogStatsGrid(BuildContext context) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: 2,
|
||||
children: [
|
||||
buildStatsCard(
|
||||
context: context,
|
||||
title: '博客数量',
|
||||
count: overviewStats.blogCount,
|
||||
unit: '篇',
|
||||
),
|
||||
buildStatsCard(
|
||||
context: context,
|
||||
title: '分类数量',
|
||||
count: overviewStats.categoryCount,
|
||||
unit: '个',
|
||||
),
|
||||
buildStatsCard(
|
||||
context: context,
|
||||
title: '访问量',
|
||||
count: overviewStats.visitCount,
|
||||
unit: '次',
|
||||
),
|
||||
buildStatsCard(
|
||||
context: context,
|
||||
title: '总字数',
|
||||
count: overviewStats.wordCount,
|
||||
unit: '字',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
YearSelector(
|
||||
initialYear: DateTime.now().year,
|
||||
minYear: 2000,
|
||||
maxYear: 2100,
|
||||
onYearChanged:
|
||||
(year) => {
|
||||
setState(() {
|
||||
currentYear = year;
|
||||
_loadStatsList();
|
||||
}),
|
||||
},
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
buildBlogStatsGrid(context),
|
||||
SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: buildCard(
|
||||
context: context,
|
||||
child: _buildApprovedStats(context),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: buildCard(
|
||||
context: context,
|
||||
child: _buildVisitStats(context),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: buildCard(
|
||||
context: context,
|
||||
child: _buildVisitRankStats(context),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: buildCard(
|
||||
context: context,
|
||||
child: _buildCategoryStats(context),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: chartHeight,
|
||||
child: buildCard(
|
||||
context: context,
|
||||
child: _buildReadRankStats(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/pages/timeline_page.dart
Normal file
85
lib/pages/timeline_page.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:blog_app/apis/blog.dart';
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/widget/blog.dart';
|
||||
import 'package:blog_app/widget/year_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:timelines_plus/timelines_plus.dart';
|
||||
|
||||
class TimelinePage extends StatefulWidget {
|
||||
const TimelinePage({super.key});
|
||||
|
||||
@override
|
||||
State<TimelinePage> createState() => _TimelinePageState();
|
||||
}
|
||||
|
||||
class _TimelinePageState extends State<TimelinePage> {
|
||||
late List<Blog> blogs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBlogList();
|
||||
}
|
||||
|
||||
Future<void> _loadBlogList() async {
|
||||
try {
|
||||
final result = await queryBlogByConditionApi(
|
||||
null,
|
||||
null,
|
||||
DateTime.now().year,
|
||||
);
|
||||
setState(() {
|
||||
blogs = result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw Exception('获取博客列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshRecord(int year) async {
|
||||
final result = await queryBlogByConditionApi(null, null, year);
|
||||
setState(() {
|
||||
blogs = result;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildBlogList() {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Timeline.tileBuilder(
|
||||
theme: TimelineThemeData(nodePosition: 0, indicatorPosition: 0),
|
||||
padding: EdgeInsets.all(6),
|
||||
builder: TimelineTileBuilder.connected(
|
||||
itemCount: blogs.length,
|
||||
connectorBuilder:
|
||||
(context, index, type) =>
|
||||
Connector.solidLine(thickness: 2, color: colors.primary),
|
||||
indicatorBuilder: (context, index) {
|
||||
return Indicator.dot(size: 12.0, color: colors.primary);
|
||||
},
|
||||
contentsBuilder: (context, index) {
|
||||
return buildBlogListItem(context, blogs[index], index + 1);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
YearSelector(
|
||||
initialYear: DateTime.now().year,
|
||||
minYear: 2000,
|
||||
maxYear: 2100,
|
||||
onYearChanged: (year) => refreshRecord(year),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
buildListTitle(blogs.length),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: blogs.isEmpty ? buildEmpty() : _buildBlogList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:blog_app/models/blog.dart';
|
||||
import 'package:blog_app/pages/blog_detail_page.dart';
|
||||
import 'package:blog_app/pages/category_list_page.dart';
|
||||
import 'package:blog_app/utils/date_utils.dart';
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -138,6 +139,134 @@ class BlogCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildCategoryTitle(BuildContext context, int count) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'共 $count 个分类',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildCategoryCard(BuildContext context, BlogCategory category) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.folder, color: colors.primary, size: 28),
|
||||
),
|
||||
title: Text(
|
||||
category.name,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
'${category.count} 篇博客',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary.withAlpha(50),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
category.count.toString(),
|
||||
style: TextStyle(color: colors.primary, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
onTap: () => navigateToBlogList(context, category),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildListTitle(int count) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'博客列表',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'共 $count 篇',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBlogListItem(BuildContext context, Blog blog, int index) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return buildCard(
|
||||
context: context,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 25,
|
||||
child: Text(
|
||||
index.toString(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 发布时间
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
blog.createTime,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
blog.title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void navigatorToBlogDetail(BuildContext context, int blogId) {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -145,6 +274,15 @@ void navigatorToBlogDetail(BuildContext context, int blogId) {
|
||||
);
|
||||
}
|
||||
|
||||
void navigateToBlogList(BuildContext context, BlogCategory category) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => CategoryListPage(category: category.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
|
||||
285
lib/widget/chart.dart
Normal file
285
lib/widget/chart.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
import 'package:blog_app/models/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
Widget buildChartTitle(BuildContext context, String title, IconData icon) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Theme.of(context).colorScheme.primary),
|
||||
Text(title),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildChartDivider(BuildContext context) {
|
||||
return Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
indent: 0,
|
||||
endIndent: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Widget lineChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// 图表标题
|
||||
// title: ChartTitle(text: '2023年上半年销售额(万元)'),
|
||||
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 启用图例
|
||||
legend: Legend(isVisible: true, position: LegendPosition.top),
|
||||
|
||||
// 启用交互提示(点击数据点显示详情)
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 折线图数据系列
|
||||
series: [
|
||||
LineSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 线条颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
// 线条宽度
|
||||
width: 3,
|
||||
|
||||
// 数据点样式
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
shape: DataMarkerType.circle,
|
||||
height: 6,
|
||||
width: 6,
|
||||
),
|
||||
|
||||
// 折线名称(会显示在图例中)
|
||||
name: yAxisName,
|
||||
|
||||
// 启用数据标签(直接显示数值)
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000, // 动画时长(毫秒)
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget barChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 启用交互提示
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 柱状图数据系列
|
||||
series: [
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
|
||||
// 名称
|
||||
name: yAxisName,
|
||||
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
|
||||
// 柱子宽度(0-1之间,1表示占满类别间隔)
|
||||
width: 0.6,
|
||||
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget doubleBarChart({
|
||||
required BuildContext context,
|
||||
required String xAxisName,
|
||||
required String yAxisName,
|
||||
required String unit,
|
||||
required List<ChartData> data1,
|
||||
required List<ChartData> data2,
|
||||
required String series1Name,
|
||||
required String series2Name,
|
||||
}) {
|
||||
return SfCartesianChart(
|
||||
// X轴配置(类别轴)
|
||||
primaryXAxis: CategoryAxis(majorGridLines: const MajorGridLines(width: 0)),
|
||||
|
||||
// Y轴配置(数值轴)
|
||||
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName($unit)')),
|
||||
|
||||
// 图例配置
|
||||
legend: Legend(
|
||||
isVisible: true,
|
||||
position: LegendPosition.top,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
),
|
||||
|
||||
// 启用交互提示
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'series.name: point.y $unit',
|
||||
),
|
||||
|
||||
// 双柱状图数据系列
|
||||
series: <ColumnSeries<ChartData, String>>[
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data1,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 系列名称
|
||||
name: series1Name,
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
// 柱子宽度
|
||||
width: 0.3,
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
ColumnSeries<ChartData, String>(
|
||||
dataSource: data2,
|
||||
// X轴数据映射
|
||||
xValueMapper: (ChartData chart, _) => chart.name,
|
||||
// Y轴数据映射
|
||||
yValueMapper: (ChartData chart, _) => chart.value,
|
||||
// 系列名称
|
||||
name: series2Name,
|
||||
// 柱子颜色
|
||||
color: Theme.of(context).colorScheme.inversePrimary,
|
||||
// 柱子宽度
|
||||
width: 0.3,
|
||||
// 柱子边框
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.black12,
|
||||
// 数据标签
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
isVisible: true,
|
||||
color: Colors.white,
|
||||
opacity: 0,
|
||||
alignment: ChartAlignment.center,
|
||||
),
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget pieChart({
|
||||
required BuildContext context,
|
||||
required String unit,
|
||||
required List<ChartData> data,
|
||||
}) {
|
||||
// 计算 value 的总和
|
||||
double sumValue = data.fold(0.0, (sum, item) => sum + item.value);
|
||||
|
||||
return SfCircularChart(
|
||||
// 饼图标题
|
||||
// title: ChartTitle(text: '菜谱类别占比分布'),
|
||||
|
||||
// 启用图例
|
||||
legend: const Legend(isVisible: true, position: LegendPosition.right),
|
||||
|
||||
// 启用交互提示(点击扇区显示详情)
|
||||
tooltipBehavior: TooltipBehavior(
|
||||
enable: true,
|
||||
format: 'point.x: point.y $unit',
|
||||
),
|
||||
|
||||
// 饼图系列配置
|
||||
series: [
|
||||
PieSeries<ChartData, String>(
|
||||
dataSource: data,
|
||||
// 类别映射(饼图扇区名称)
|
||||
xValueMapper: (ChartData data, _) => data.name,
|
||||
// 数值映射(扇区大小占比)
|
||||
yValueMapper: (ChartData data, _) => data.value,
|
||||
|
||||
// 扇区半径(0-1之间,1表示充满容器)
|
||||
// radius: '50%',
|
||||
|
||||
// 启用扇区分离效果
|
||||
explode: true,
|
||||
// 指定分离的扇区索引(这里分离第一个扇区)
|
||||
explodeIndex: 0,
|
||||
// 分离距离
|
||||
explodeOffset: '5%',
|
||||
|
||||
dataLabelMapper: (ChartData data, _) {
|
||||
final percentage = (data.value / sumValue * 100).toStringAsFixed(0);
|
||||
return '$percentage%';
|
||||
},
|
||||
|
||||
// 数据标签(显示在扇区上的文本)
|
||||
dataLabelSettings: DataLabelSettings(isVisible: true),
|
||||
|
||||
// 动画效果
|
||||
animationDuration: 2000,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -16,3 +16,21 @@ Widget buildCard({required BuildContext context, required Widget child}) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget circleIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
return ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: CircleBorder(),
|
||||
elevation: 0,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 0),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white),
|
||||
);
|
||||
}
|
||||
119
lib/widget/year_selector.dart
Normal file
119
lib/widget/year_selector.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:blog_app/widget/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class YearSelector extends StatefulWidget {
|
||||
final int initialYear;
|
||||
final int? minYear;
|
||||
final int? maxYear;
|
||||
final Function(int) onYearChanged;
|
||||
|
||||
const YearSelector({
|
||||
super.key,
|
||||
required this.initialYear,
|
||||
required this.onYearChanged,
|
||||
this.minYear,
|
||||
this.maxYear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<YearSelector> createState() => _YearSelectorState();
|
||||
}
|
||||
|
||||
// SingleTickerProviderStateMixin 动画控制器
|
||||
class _YearSelectorState extends State<YearSelector>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late int _currentYear;
|
||||
|
||||
// 用于动画效果
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentYear = widget.initialYear;
|
||||
|
||||
// 初始化动画控制器
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
// 缩放动画
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 切换到上一年
|
||||
void _previousYear() {
|
||||
if (widget.minYear == null || _currentYear > widget.minYear!) {
|
||||
_animateYearChange(() {
|
||||
setState(() {
|
||||
_currentYear--;
|
||||
});
|
||||
widget.onYearChanged(_currentYear);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换到下一年
|
||||
void _nextYear() {
|
||||
if (widget.maxYear == null || _currentYear < widget.maxYear!) {
|
||||
_animateYearChange(() {
|
||||
setState(() {
|
||||
_currentYear++;
|
||||
});
|
||||
widget.onYearChanged(_currentYear);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 年份变化时的动画效果
|
||||
void _animateYearChange(VoidCallback onComplete) {
|
||||
_animationController.forward().then((_) {
|
||||
onComplete();
|
||||
_animationController.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.chevron_left,
|
||||
onPressed: () => _previousYear(),
|
||||
),
|
||||
// 年份显示
|
||||
AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(scale: _scaleAnimation.value, child: child);
|
||||
},
|
||||
child: Text(
|
||||
'$_currentYear',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
circleIconButton(
|
||||
context: context,
|
||||
icon: Icons.chevron_right,
|
||||
onPressed: () => _nextYear(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user