feat:增加博客统计模块

This commit is contained in:
2025-11-15 16:56:47 +08:00
parent 2cfb2ce592
commit d512a77f5f
22 changed files with 1196 additions and 524 deletions

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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('查看博客'),
),
],
),
);
}
}

View File

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

View File

@@ -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: () {},
),
);
},
);
}
}

View File

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

View File

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

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

View File

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

View File

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

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

View File

@@ -693,6 +693,30 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
syncfusion_flutter_charts:
dependency: "direct main"
description:
name: syncfusion_flutter_charts
sha256: "68fdb029dad34a46e4c9cfad8ad66fe29db7b303bd96849261ab2b23a168d0e8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.2.7"
syncfusion_flutter_core:
dependency: transitive
description:
name: syncfusion_flutter_core
sha256: bfd026c0f9822b49ff26fed11cd3334519acb6a6ad4b0c81d9cd18df6af1c4c0
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.2.7"
syncfusion_localizations:
dependency: "direct main"
description:
name: syncfusion_localizations
sha256: bb32b07879b4c1dee5d4c8ad1c57343a4fdae55d65a87f492727c11b68f23164
url: "https://pub.flutter-io.cn"
source: hosted
version: "30.2.7"
term_glyph:
dependency: transitive
description:
@@ -709,6 +733,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.4"
timelines_plus:
dependency: "direct main"
description:
name: timelines_plus
sha256: d621d8724bc8f64957127c1195436996548e166296682d081bedcdb0abe1b638
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.8"
timing:
dependency: transitive
description:

View File

@@ -41,6 +41,9 @@ dependencies:
json_annotation: ^4.9.0
intl: ^0.19.0
easy_refresh: ^3.4.0
timelines_plus: ^1.0.7
syncfusion_localizations: ^30.1.37
syncfusion_flutter_charts: ^30.1.41
dev_dependencies:
flutter_test: