feat(my): add 我的笔记 page and related functions
1. 新增我的笔记页面,包含学科筛选Tab和游标分页列表 2. 新增user_note接口封装和对应数据模型 3. 在我的页面添加笔记入口跳转 4. 优化收藏/错题卡片的按压反馈效果
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:new_learn/core/api/user_collect/model.dart';
|
||||||
|
import 'package:new_learn/core/api/user_note/model.dart';
|
||||||
|
import 'package:new_learn/core/network/dio_client.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【user_note 模块接口】我的笔记 —— 对标 Vue3 端 api/userNote/index.ts
|
||||||
|
///
|
||||||
|
/// getSubjects —— 学科筛选项(顶部 Tab,无参)
|
||||||
|
/// GET /api/v1/UserNote/GetUserCollectSubject
|
||||||
|
///
|
||||||
|
/// getNoteList —— 笔记分页列表(游标分页)
|
||||||
|
/// GET /api/v1/UserNote/GetUserNotes
|
||||||
|
/// 参数:subjectId(可选) / pageIndex / pageSize / prevPageId
|
||||||
|
///
|
||||||
|
/// 列表不按笔记类型过滤;type(1题目/2讲义) 由每条数据自带,
|
||||||
|
/// 前端用卡片角标图标区分。
|
||||||
|
///
|
||||||
|
/// 注意:Vue 同文件里的 OSS 凭证/上传地址接口归属 OSS 模块
|
||||||
|
/// (Flutter 端见 core/network/oss_api.dart),不在此重复封装。
|
||||||
|
/// ============================================================
|
||||||
|
class UserNoteApi {
|
||||||
|
UserNoteApi._();
|
||||||
|
|
||||||
|
static final UserNoteApi instance = UserNoteApi._();
|
||||||
|
|
||||||
|
/// 学科筛选项
|
||||||
|
Future<List<CollectTab>> getSubjects() async {
|
||||||
|
final data = await DioClient.instance.get(
|
||||||
|
'/api/v1/UserNote/GetUserCollectSubject',
|
||||||
|
);
|
||||||
|
// 兼容裸数组 / {data: [...]} 响应壳
|
||||||
|
final List<dynamic> list = data is List
|
||||||
|
? data
|
||||||
|
: (data is Map<String, dynamic>
|
||||||
|
? (data['data'] as List<dynamic>? ?? const [])
|
||||||
|
: const []);
|
||||||
|
return list
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(CollectTab.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 笔记分页列表
|
||||||
|
///
|
||||||
|
/// [subjectId] 不传 = 全部学科;[prevPageId] 游标:首页传 0,
|
||||||
|
/// 翻页传上一页最后一条 id。
|
||||||
|
Future<UserNotePage> getNoteList({
|
||||||
|
int? subjectId,
|
||||||
|
required int pageIndex,
|
||||||
|
int pageSize = 10,
|
||||||
|
int prevPageId = 0,
|
||||||
|
}) async {
|
||||||
|
final query = <String, dynamic>{
|
||||||
|
'pageIndex': pageIndex,
|
||||||
|
'pageSize': pageSize,
|
||||||
|
'prevPageId': prevPageId,
|
||||||
|
};
|
||||||
|
// 对齐 Vue useList:subjectId 为 null 时不带该参数
|
||||||
|
if (subjectId != null) query['subjectId'] = subjectId;
|
||||||
|
|
||||||
|
final data = await DioClient.instance.get(
|
||||||
|
'/api/v1/UserNote/GetUserNotes',
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
|
return UserNotePage.fromJson(data as Map<String, dynamic>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/// ============================================================
|
||||||
|
/// 【user_note 模块模型】我的笔记 —— 对标 Vue3 端 api/userNote/type.ts
|
||||||
|
///
|
||||||
|
/// UserNoteItem 笔记列表项(type 1=题目笔记 2=讲义笔记)
|
||||||
|
/// UserNotePage 分页响应(游标分页:pageIndex + prevPageId)
|
||||||
|
///
|
||||||
|
/// 学科筛选 Tab 复用收藏模块的 CollectTab(见 api.dart 的 import,
|
||||||
|
/// 与 Vue 端 api/userNote/index.ts 从 ../userCollect/type 引入一致)。
|
||||||
|
/// ============================================================
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失)
|
||||||
|
int? _noteToInt(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is num) return v.toInt();
|
||||||
|
if (v is String && v.isNotEmpty) return int.tryParse(v);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的笔记列表项
|
||||||
|
class UserNoteItem {
|
||||||
|
const UserNoteItem({
|
||||||
|
this.id = 0,
|
||||||
|
this.subjectName = '',
|
||||||
|
this.type = 0,
|
||||||
|
this.title = '',
|
||||||
|
this.content = '',
|
||||||
|
this.createTime = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 笔记 id(游标分页 prevPageId 取本页最后一条的 id;详情页参数 id)
|
||||||
|
final int id;
|
||||||
|
|
||||||
|
/// 学科名(SubjectTag 配色用)
|
||||||
|
final String subjectName;
|
||||||
|
|
||||||
|
/// 笔记类型:1=题目笔记 2=讲义笔记(卡片角标/详情页区分用)
|
||||||
|
final int type;
|
||||||
|
|
||||||
|
/// 笔记标题
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// 笔记内容 HTML(RichTextContent 渲染)
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
/// 记笔记时间字符串
|
||||||
|
final String createTime;
|
||||||
|
|
||||||
|
factory UserNoteItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UserNoteItem(
|
||||||
|
id: _noteToInt(json['id']) ?? 0,
|
||||||
|
subjectName: json['subjectName'] as String? ?? '',
|
||||||
|
type: _noteToInt(json['type']) ?? 0,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
content: json['content'] as String? ?? '',
|
||||||
|
createTime: json['createTime'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的笔记分页响应
|
||||||
|
class UserNotePage {
|
||||||
|
const UserNotePage({
|
||||||
|
this.data = const [],
|
||||||
|
this.total = 0,
|
||||||
|
this.pageIndex = 1,
|
||||||
|
this.pageSize = 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<UserNoteItem> data;
|
||||||
|
final int total;
|
||||||
|
final int pageIndex;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||||
|
factory UserNotePage.fromJson(Map<String, dynamic> json) {
|
||||||
|
final payload = (json['data'] is Map<String, dynamic>)
|
||||||
|
? json['data'] as Map<String, dynamic>
|
||||||
|
: json;
|
||||||
|
return UserNotePage(
|
||||||
|
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(UserNoteItem.fromJson)
|
||||||
|
.toList(),
|
||||||
|
total: _noteToInt(payload['total']) ?? 0,
|
||||||
|
pageIndex: _noteToInt(payload['pageIndex']) ?? 1,
|
||||||
|
pageSize: _noteToInt(payload['pageSize']) ?? 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import 'package:new_learn/features/class/my_interact_page.dart';
|
|||||||
import 'package:new_learn/features/class/my_update_detail_page.dart';
|
import 'package:new_learn/features/class/my_update_detail_page.dart';
|
||||||
import 'package:new_learn/core/api/class/model.dart';
|
import 'package:new_learn/core/api/class/model.dart';
|
||||||
import 'package:new_learn/features/my/my_page.dart';
|
import 'package:new_learn/features/my/my_page.dart';
|
||||||
|
import 'package:new_learn/features/my/note/note_page.dart';
|
||||||
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
|
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
|
||||||
import 'package:new_learn/features/my/my_point/my_point_page.dart';
|
import 'package:new_learn/features/my/my_point/my_point_page.dart';
|
||||||
import 'package:new_learn/features/my/question_list/question_list_page.dart';
|
import 'package:new_learn/features/my/question_list/question_list_page.dart';
|
||||||
@@ -139,6 +140,8 @@ final GoRouter appRouter = GoRouter(
|
|||||||
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
|
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
||||||
|
// 我的笔记
|
||||||
|
GoRoute(path: '/myNote', builder: (_, _) => const NotePage()),
|
||||||
// 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage
|
// 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/myWrong',
|
path: '/myWrong',
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class MyPage extends ConsumerWidget {
|
|||||||
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png',
|
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png',
|
||||||
title: '我的笔记',
|
title: '我的笔记',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: 跳转到我的笔记页
|
context.push('/myNote');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
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<NotePage> createState() => _NotePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotePageState extends State<NotePage> {
|
||||||
|
static const int _pageSize = 10;
|
||||||
|
|
||||||
|
/// 学科 Tab 列表("全部"为客户端插入,id=null)
|
||||||
|
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
|
||||||
|
|
||||||
|
/// 当前选中的学科 id(null = 全部)
|
||||||
|
int? _subjectId;
|
||||||
|
|
||||||
|
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
|
||||||
|
int _prevPageId = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
talker.debug('打开页面:我的笔记');
|
||||||
|
_loadTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
|
||||||
|
Future<void> _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<PagingResult<UserNoteItem>> _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<UserNoteItem>(
|
||||||
|
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<UserNoteItem>(
|
||||||
|
// 切学科时重建列表,状态自动回第一页
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -326,58 +326,81 @@ class _QuestionListPageState extends State<QuestionListPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本
|
/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本
|
||||||
class _QuestionCard extends StatelessWidget {
|
///
|
||||||
|
/// 按压反馈:按下缩放到 0.97、抬起回弹,背景始终白色
|
||||||
|
/// (不用 InkWell 灰底,改用 GestureDetector 驱动 AnimatedScale)。
|
||||||
|
class _QuestionCard extends StatefulWidget {
|
||||||
const _QuestionCard({required this.item, required this.onTap});
|
const _QuestionCard({required this.item, required this.onTap});
|
||||||
|
|
||||||
final UserCollectItem item;
|
final UserCollectItem item;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_QuestionCard> createState() => _QuestionCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuestionCardState extends State<_QuestionCard> {
|
||||||
|
/// 是否处于按压状态(控制 0.97 缩放)
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
void _setPressed(bool v) {
|
||||||
|
if (_pressed != v) setState(() => _pressed = v);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final item = widget.item;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx
|
padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx
|
||||||
child: Material(
|
child: GestureDetector(
|
||||||
color: Colors.white,
|
onTapDown: (_) => _setPressed(true),
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
onTapUp: (_) => _setPressed(false),
|
||||||
child: InkWell(
|
onTapCancel: () => _setPressed(false),
|
||||||
onTap: onTap,
|
onTap: widget.onTap,
|
||||||
borderRadius: BorderRadius.circular(16.r),
|
child: AnimatedScale(
|
||||||
child: Padding(
|
scale: _pressed ? 0.97 : 1.0,
|
||||||
// Vue padding: t-32 l/r-28 b-28
|
duration: const Duration(milliseconds: 100),
|
||||||
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
curve: Curves.easeOut,
|
||||||
child: Column(
|
child: Material(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: Colors.white,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
// 第一行:学科标签 + 时间
|
child: Padding(
|
||||||
Row(
|
// Vue padding: t-32 l/r-28 b-28
|
||||||
children: [
|
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
|
||||||
SubjectTag(subject: item.subjectName),
|
child: Column(
|
||||||
const Spacer(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
item.createTime,
|
// 第一行:学科标签 + 时间
|
||||||
style: TextStyle(
|
Row(
|
||||||
fontSize: 12.sp, // Vue font-24
|
children: [
|
||||||
color: const Color(0xFFB4C2E0),
|
SubjectTag(subject: item.subjectName),
|
||||||
|
const Spacer(),
|
||||||
|
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.packName,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16.sp, // Vue font-32
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: const Color(0xFF1D2B47),
|
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
|
||||||
SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx
|
// 试卷/礼包名
|
||||||
// 题干富文本(限高渐隐)
|
Text(
|
||||||
_StemBox(html: item.questionStem),
|
item.packName,
|
||||||
],
|
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
|
||||||
|
// 题干富文本(限高渐隐)
|
||||||
|
_StemBox(html: item.questionStem),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user