Files
崔宝新 14b8e56bc3 feat(my): 新增错题本和我的收藏页面,重构学科标签组件
1.  为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage
2.  抽离全局学科标签组件,移除class模块内的重复实现
3.  新增用户收藏/错题本接口层和数据模型
4.  完成QuestionListPage分页列表页实现,支持学科筛选和游标分页
5.  调整考试页面卡片布局和图标偏移参数
2026-09-10 17:14:11 +08:00

588 lines
17 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/resolve_problem_box.dart';
import 'package:new_learn/core/components/subject_tag.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【班级动态组件】由 class_update_view.dart 抽离的展示组件
///
/// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏)
/// CardHeader / SubjectTag / AlbumDesc / AlbumGrid / LikeCommentBar / ...
/// ResolveProblemBox 已抽离到 core/components/resolve_problem_box.dart
/// LikeCommentBar / MetaButton / CommentPreview / CommentInput
///
/// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。
/// 头像复用 core/components/user_circle_avatar.dart。
/// ============================================================
/// 动态卡片(对标 SubjectCard + ResolveProblem/文字 + Album + LikeAndComment)
class PostCard extends StatelessWidget {
const PostCard({
super.key,
required this.post,
required this.onLike,
required this.onComment,
this.showCommentComposer = true,
this.hasCardBackground = true,
this.showLikeCommentBar = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
final bool showCommentComposer;
/// 是否显示白色卡片背景(列表页 true;详情页与留言区共用一张大卡片 → false)
final bool hasCardBackground;
/// 是否显示点赞/评论标记栏(列表页 true;详情页不需要 → false)
final bool showLikeCommentBar;
String get _avatar {
final url = ImgUrl.build(post.avatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
String get _name => post.userName.isEmpty ? '默认姓名' : post.userName;
String get _subject => post.subjectName.isEmpty ? '数学' : post.subjectName;
String get _time {
final t = post.createTime;
if (t.isEmpty) return '';
final s = t.replaceFirst('T', ' ').replaceFirst('Z', '');
return s.length > 16 ? s.substring(0, 16) : s;
}
@override
Widget build(BuildContext context) {
final card = Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 85.h,
child: IgnorePointer(
// 卡片顶部右上角光晕,对标 Vue SubjectCard::before 的双层 radial-gradient
child: CustomPaint(painter: _CardTopGlowPainter()),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CardHeader(
avatar: _avatar,
name: _name,
subject: _subject,
time: _time,
),
if (post.type == 1) ...[
SizedBox(height: 5.h),
ResolveProblemBox(desc: post.content),
],
if (post.type == 2 || post.type == 3) ...[
SizedBox(height: 15.h),
AlbumDesc(text: post.content),
],
if (post.urls.isNotEmpty) ...[
SizedBox(height: 15.h),
AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()),
],
SizedBox(height: 10.h),
if (showLikeCommentBar)
LikeCommentBar(
post: post,
onLike: onLike,
onComment: onComment,
showComposer: showCommentComposer,
),
],
),
),
],
);
if (!hasCardBackground) return card;
return Container(
margin: EdgeInsets.only(bottom: 10.h),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
child: card,
);
}
}
/// 卡片顶部光晕:对标 Vue `SubjectCard::before` 的两层 radial-gradient
/// (椭圆均以右上角为圆心,CSS 先写的在上层):
/// radial-gradient(120% 85% at 100% 0%,
/// rgba(206,233,255,1) 0%, rgba(206,233,255,.26) 10%, rgba(255,255,255,0) 58%)
/// radial-gradient(75% 55% at 100% 0%,
/// rgba(196,226,255,.88) 0%, rgba(255,255,255,1) 64%)
class _CardTopGlowPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
// 先画下层(更小、偏白的光晕),再画上层(更大的浅蓝光晕)
_paintGlow(
canvas,
size,
rx: size.width * 0.75,
ry: size.height * 0.55,
colors: [const Color(0xFFC4E2FF).withValues(alpha: 0.88), Colors.white],
stops: const [0, 0.64],
);
_paintGlow(
canvas,
size,
rx: size.width * 1.20,
ry: size.height * 0.85,
colors: [
const Color(0xFFCEE9FF),
const Color(0xFFCEE9FF).withValues(alpha: 0.26),
Colors.white.withValues(alpha: 0.0),
],
stops: const [0, 0.10, 0.58],
);
}
/// 用 canvas.scale 把圆形径向渐变拉成 CSS 的椭圆(圆心 = 右上角)
void _paintGlow(
Canvas canvas,
Size size, {
required double rx,
required double ry,
required List<Color> colors,
required List<double> stops,
}) {
canvas.save();
canvas.translate(size.width, 0);
canvas.scale(rx, ry);
final Paint paint = Paint()
..shader = ui.Gradient.radial(Offset.zero, 1.0, colors, stops);
canvas.drawRect(const Rect.fromLTWH(-1, 0, 1, 1), paint);
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
/// 卡片标题:头像 + 姓名 + 学科标签 + 时间
class CardHeader extends StatelessWidget {
const CardHeader({
super.key,
required this.avatar,
required this.name,
required this.subject,
required this.time,
});
final String avatar;
final String name;
final String subject;
final String time;
@override
Widget build(BuildContext context) {
return Row(
children: [
UserCircleAvatar(url: avatar, radius: 20.w),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
),
SizedBox(width: 8.w),
SubjectTag(subject: subject),
],
),
SizedBox(height: 2.h),
Text(
time,
style: const TextStyle(fontSize: 12, color: Color(0xFF495A7A)),
),
],
),
),
],
);
}
}
/// 纯文字笔记(album-desc,最多 2 行)
class AlbumDesc extends StatelessWidget {
const AlbumDesc({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
color: Color(0xFF495A7A),
height: 42 / 30,
),
);
}
}
/// 九宫格相册(最多展示 9 张,第 9 张叠加 +N)
class AlbumGrid extends StatelessWidget {
const AlbumGrid({super.key, required this.urls});
final List<String> urls;
@override
Widget build(BuildContext context) {
final displayed = urls.take(9).toList();
return Wrap(
spacing: 9.w,
runSpacing: 9.h,
children: [
for (int i = 0; i < displayed.length; i++)
GestureDetector(
onTap: () => _preview(context, urls, i),
child: _gridItem(displayed[i], i, urls.length),
),
],
);
}
/// 点击相册图:进入整组大图预览,定位到点中的那张
void _preview(BuildContext context, List<String> allUrls, int index) {
ImagePreviewViewer.show(
context,
items: [
for (final u in allUrls)
PreviewImageItem(imageProvider: CachedNetworkImageProvider(u)),
],
initialIndex: index,
);
}
Widget _gridItem(String url, int index, int total) {
final more = total > 9 && index == 8 ? total - 9 : 0;
return ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: SizedBox(
width: 99.w,
height: 99.h,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
errorWidget: (_, _, _) =>
const ColoredBox(color: Color(0xFFF0F2F5)),
),
if (more > 0)
ColoredBox(
color: const Color(0x66000000),
child: Center(
child: Text(
'+$more',
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
);
}
}
/// 点赞 / 评论栏(对标 LikeAndComment)
class LikeCommentBar extends StatelessWidget {
const LikeCommentBar({
super.key,
required this.post,
required this.onLike,
required this.onComment,
this.showComposer = true,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
/// 是否显示评论预览 + 输入框(列表页 true;详情页 false,由全量留言列表接管)
final bool showComposer;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MetaButton(
icon: Image.asset(
post.userLikeStatus
? 'assets/img/class/like-check-icon.png'
: 'assets/img/class/like-icon.png',
width: 13.w,
height: 13.w,
),
count: post.likeCount,
active: post.userLikeStatus,
onTap: onLike,
),
SizedBox(width: 8.w),
MetaButton(
icon: Image.asset(
'assets/img/class/comment-icon.png',
width: 13.w,
height: 13.w,
),
count: post.replyCount,
),
],
),
if (showComposer) ...[
if (post.commons.isNotEmpty) ...[
SizedBox(height: 12.h),
CommentPreview(post: post),
],
SizedBox(height: 12.h),
CommentInput(onSubmit: onComment),
],
],
);
}
}
class MetaButton extends StatelessWidget {
const MetaButton({
super.key,
required this.icon,
required this.count,
this.active = false,
this.onTap,
});
final Widget icon;
final int count;
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final color = active ? const Color(0xFF3D7DF5) : const Color(0xFFB4C2E0);
return GestureDetector(
onTap: onTap,
child: Container(
width: 51.w,
height: 22.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: const Color(0xFFB3C2E0).withValues(alpha: 0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
SizedBox(width: 4.w),
Text('$count', style: TextStyle(fontSize: 13, color: color)),
],
),
),
);
}
}
/// 评论预览框(前 3 条 + 共 N 条)
class CommentPreview extends StatelessWidget {
const CommentPreview({super.key, required this.post});
final UserPostDto post;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 19.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final c in post.commons.take(3))
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${c.userName}:',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF1D2B47),
),
),
TextSpan(
text: c.content,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF495A7A),
),
),
],
),
),
),
if (post.replyCount > 3)
Row(
children: [
Text(
'共${post.replyCount}条评论',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
const Icon(
Icons.chevron_right,
size: 14,
color: Color(0xFF687FAD),
),
],
),
],
),
);
}
}
/// 评论输入行
class CommentInput extends StatefulWidget {
const CommentInput({super.key, required this.onSubmit});
final ValueChanged<String> onSubmit;
@override
State<CommentInput> createState() => _CommentInputState();
}
class _CommentInputState extends State<CommentInput> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSubmit(text);
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
constraints: BoxConstraints(minHeight: 30.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE3ECFF)),
borderRadius: BorderRadius.circular(10.r),
),
// multiline:键盘右下角为「换行」,可换行写多条评论
child: TextField(
controller: _controller,
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: '写下你的评论',
hintStyle: TextStyle(fontSize: 12, color: Color(0xFFB4C2E0)),
),
onSubmitted: (_) => _send(),
),
),
),
SizedBox(width: 10.w),
GestureDetector(
onTap: _send,
child: Container(
width: 51.w,
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
),
borderRadius: BorderRadius.circular(6.r),
),
child: const Text(
'发送',
style: TextStyle(fontSize: 13, color: Colors.white),
),
),
),
],
);
}
}