import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/user_collect/model.dart'; import 'package:new_learn/core/api/user_note/api.dart'; import 'package:new_learn/core/api/user_note/model.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/components/rich_text_content.dart'; import 'package:new_learn/core/components/subject_tag.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/utils/img_url.dart'; /// ============================================================ /// 【NotePage】我的笔记 —— 对标 Vue pages/myPackage/note/note.vue /// /// 页面结构(对齐 Vue,从上到下): /// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载) /// 2. #F5F8FA 列表区:CommonPagedList 游标分页 /// 卡片 = SubjectTag + 时间 + title + 内容富文本(限高渐隐) /// + 右上角笔记类型角标 /// /// 与收藏/错题列表的差异: /// ① 没有顶部总数头; /// ② 列表不按类型过滤,type 由每条数据自带: /// 1=题目笔记(note-stem.png)2=讲义笔记(note-teaching.png), /// 仅用角标图标区分,详情页再按 type 渲染不同内容; /// ③ 标题字段 title、正文字段 content。 /// /// 分页:pageKey 当 pageIndex;prevPageId 闭包维护——首页/刷新重置为 0, /// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表回首页。 /// ============================================================ class NotePage extends StatefulWidget { const NotePage({super.key}); @override State createState() => _NotePageState(); } class _NotePageState extends State { static const int _pageSize = 10; /// 学科 Tab 列表("全部"为客户端插入,id=null) List _tabs = const [CollectTab(name: '全部')]; /// 当前选中的学科 id(null = 全部) int? _subjectId; /// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0 int _prevPageId = 0; @override void initState() { super.initState(); talker.debug('打开页面:我的笔记'); _loadTabs(); } /// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑) Future _loadTabs() async { try { final tabs = await UserNoteApi.instance.getSubjects(); if (!mounted) return; setState(() { _tabs = [const CollectTab(name: '全部'), ...tabs]; }); } catch (e) { talker.error('获取笔记学科Tab失败', e); if (!mounted) return; setState(() { _tabs = const [CollectTab(name: '全部')]; }); } } /// 分页加载(CommonPagedList 回调) Future> _load(int pageKey) async { // 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0) if (pageKey == 1) _prevPageId = 0; final res = await UserNoteApi.instance.getNoteList( subjectId: _subjectId, pageIndex: pageKey, pageSize: _pageSize, prevPageId: _prevPageId, ); // 更新游标为本页最后一条 id _prevPageId = res.data.isNotEmpty ? res.data.last.id : 0; return PagingResult( data: res.data, hasNext: res.total > pageKey * _pageSize, ); } /// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页 void _onTabTap(CollectTab tab) { if (_subjectId == tab.id) return; setState(() { _subjectId = tab.id; _prevPageId = 0; }); } /// 点击卡片 → 笔记详情页 /// TODO: 详情页待接入,需要按 item.type 区分: /// type=1 题目笔记 / type=2 讲义笔记,详情页内容结构不同; /// Vue 跳转:/pages/myPackage/noteDetail/index?id=${item.id}&type=${item.type} void _goDetail(UserNoteItem item) { talker.debug('笔记详情待接入:id=${item.id} type=${item.type}(1题目笔记/2讲义笔记)'); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.white, surfaceTintColor: Colors.white, elevation: 0, title: const Text('我的笔记', style: TextStyle(color: Color(0xFF1D2B47))), iconTheme: const IconThemeData(color: Color(0xFF1D2B47)), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go('/my'), ), ), body: Column( children: [ _buildTabBar(), // 列表区(#F5F8FA 灰底) Expanded( child: Container( color: const Color(0xFFF5F8FA), child: CommonPagedList( // 切学科时重建列表,状态自动回第一页 key: ValueKey('note_list_$_subjectId'), // Vue list-refresh-wrap padding-top:32rpx + listWrapper 28rpx padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), emptyWidget: _buildEmpty(), load: _load, buildItem: (context, item, index) => _NoteCard(item: item, onTap: () => _goDetail(item)), ), ), ), ], ), ); } /// 学科筛选胶囊 Tab 栏(横向滚动) Widget _buildTabBar() { return Container( color: Colors.white, height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx) child: ListView.separated( scrollDirection: Axis.horizontal, padding: EdgeInsets.symmetric(horizontal: 12.w), itemCount: _tabs.length, separatorBuilder: (_, _) => SizedBox(width: 12.w), itemBuilder: (context, i) { final tab = _tabs[i]; final active = _subjectId == tab.id; return Center( child: GestureDetector( onTap: () => _onTabTap(tab), child: Container( height: 32.h, alignment: Alignment.center, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: active ? const Color(0xFF3D7DF5) : const Color(0xFFF5F8FA), borderRadius: BorderRadius.circular(999), ), child: Text( tab.name, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w400, color: active ? Colors.white : const Color(0xFF687FAD), ), ), ), ), ); }, ), ); } /// 空状态:空态图 + 文案(对齐 Vue DataEmpty noNotess.png) Widget _buildEmpty() { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noNotess.png'), width: 160.w, fit: BoxFit.contain, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), SizedBox(height: 12.h), Text( '暂无笔记', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), ), ], ), ); } } /// 单条笔记卡片:SubjectTag + 时间 + title + 内容富文本 + 右上角类型角标 class _NoteCard extends StatelessWidget { const _NoteCard({required this.item, required this.onTap}); final UserNoteItem item; final VoidCallback onTap; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( bottom: 14.h, ), // Vue listItem margin-bottom: 28rpx child: Stack( clipBehavior: Clip.none, // 允许类型角标超出卡片顶部 children: [ Material( color: Colors.white, borderRadius: BorderRadius.circular(16.r), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16.r), // 去掉点击时默认的灰色水波纹/高亮(Vue 端卡片无按压反馈) splashColor: Colors.transparent, highlightColor: Colors.transparent, child: Padding( // Vue padding: t-32 l/r-28 b-28 padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 第一行:学科标签 + 时间 Row( children: [ SubjectTag(subject: item.subjectName), SizedBox(width: 12.w), Text( item.createTime, style: TextStyle( fontSize: 12.sp, // Vue font-24 color: const Color(0xFFB4C2E0), ), ), ], ), SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx // 笔记标题 Text( item.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.sp, // Vue font-32 fontWeight: FontWeight.w600, color: const Color(0xFF1D2B47), ), ), SizedBox( height: 16.h, ), // Vue question-stem-wrap margin-top: 32rpx // 笔记内容富文本(限高渐隐) _NoteContentBox(html: item.content), ], ), ), ), ), // 右上角类型角标:Vue note-type-icon top:-4rpx right:28rpx 56rpx Positioned(top: -2.h, right: 14.w, child: _buildTypeIcon()), ], ), ); } /// 笔记类型角标:type=1 题目笔记(note-stem)/ 2 讲义笔记(note-teaching) Widget _buildTypeIcon() { final icon = item.type == 2 ? '/StaticResource/2026-04-07/note-teaching.png' : '/StaticResource/2026-04-07/note-stem.png'; return CachedNetworkImage( imageUrl: ImgUrl.build(icon), width: 28.w, height: 28.h, fit: BoxFit.contain, placeholder: (_, _) => SizedBox(width: 28.w, height: 28.h), errorWidget: (_, _, _) => SizedBox(width: 28.w, height: 28.h), ); } } /// 笔记内容容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx), /// 超出部分底部渐隐遮罩(与收藏/错题列表题干样式一致) class _NoteContentBox extends StatelessWidget { const _NoteContentBox({required this.html}); final String html; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: EdgeInsets.all(12.w), // Vue padding: 24rpx decoration: BoxDecoration( color: const Color(0xFFFAFBFD), borderRadius: BorderRadius.circular(8.r), // Vue 16rpx border: Border.all(color: const Color(0xFFE3ECFF)), ), child: Stack( children: [ // 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉 ClipRect( child: ConstrainedBox( constraints: BoxConstraints(maxHeight: 70.h), child: OverflowBox( alignment: Alignment.topCenter, minHeight: 0, maxHeight: double.infinity, child: RichTextContent( html: html, textStyle: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w400, color: const Color(0xFF495A7A), height: 1.5, ), ), ), ), ), // 底部渐隐遮罩(Vue mask-layer 60rpx): // 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见 Positioned( left: 0, right: 0, bottom: 0, height: 30.h, child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)], ), borderRadius: BorderRadius.circular(8.r), ), ), ), ), ], ), ); } }