Compare commits

...

4 Commits

Author SHA1 Message Date
iven
988ee7335a feat(app): 内容安全词库 + 过滤服务 + 分享前检查 — 28 个测试全覆盖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
新增文件:
- sensitive_words.dart — 8 分类 ~200 条敏感词 + 谐音/形近/数字变体映射
- content_filter_service.dart — 精确匹配 + 变体匹配 + 文本预处理(去零宽/空格/符号)
- content_filter_service_test.dart — 28 个测试(8分类精确/安全内容/预处理/变体/边界/词库完整性)

修改:
- share_bottom_sheet.dart — 分享到班级前调用 ContentFilterService,
  有敏感词时弹出警告对话框(返回修改/仍然分享),新增 contentText 参数
2026-06-03 19:40:13 +08:00
iven
9c92cba87f test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖
ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
2026-06-03 19:03:29 +08:00
iven
f6d394afb6 test(app): 手写引擎 Canvas 集成测试 — 55 个测试全覆盖
4 个测试文件:
- stroke_model_test.dart (12 tests) — StrokePoint/Stroke 序列化、不可变性、默认值
- stroke_renderer_test.dart (19 tests) — parseHexColor/pointsToOutline/buildStrokePath/createPaintForStroke
- stroke_cache_test.dart (15 tests) — StrokeRasterCache 添加/同步/清除/尺寸变化/边界条件
- handwriting_canvas_test.dart (9 tests) — Widget 渲染结构、手势回调、去抖、预加载、连续笔画
2026-06-03 18:57:41 +08:00
iven
4cd08535d3 chore(app): 管理端品牌替换 — 移除所有 ERP 面向用户文字,统一暖记风格
- index.css 注释头 → 暖记管理后台 Design System
- PluginMarket.tsx 用户提示 → 扩展暖记能力
- package.json → nuanji-admin v0.1.0 + 项目描述
- CSS 注释 Typography → Chinese-first 暖记
2026-06-03 18:48:47 +08:00
13 changed files with 2187 additions and 16 deletions

View File

@@ -0,0 +1,184 @@
// 内容安全过滤服务 — 本地敏感词检测
//
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
import 'sensitive_words.dart';
/// 敏感词匹配结果
class SensitiveWordMatch {
/// 匹配到的敏感词原文
final String word;
/// 所属分类
final SensitiveCategory category;
/// 在预处理后文本中的起始位置
final int position;
const SensitiveWordMatch({
required this.word,
required this.category,
required this.position,
});
@override
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
}
/// 内容安全过滤服务
///
/// 纯静态方法,无状态,可安全在任何地方调用。
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
class ContentFilterService {
ContentFilterService._();
/// 检查文本内容,返回所有匹配到的敏感词。
///
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
/// 返回空列表表示内容安全。
static List<SensitiveWordMatch> checkText(String text) {
if (text.isEmpty) return const [];
final normalized = _normalize(text);
if (normalized.isEmpty) return const [];
final matches = <SensitiveWordMatch>[];
final seen = <String>{}; // 去重:同一词不重复报告
for (final entry in kSensitiveWords.entries) {
final category = entry.key;
for (final word in entry.value) {
// 精确匹配
final pos = normalized.indexOf(word);
if (pos >= 0 && seen.add(word)) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: pos,
));
}
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
if (_matchesWithVariants(normalized, word)) {
if (seen.add('variant:$word')) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: -1, // 变体匹配无法精确定位
));
}
}
}
}
// 整词变体匹配 — kHomophoneVariants 中的多字 key如 "卧槽"
for (final variantEntry in kHomophoneVariants.entries) {
final originalKey = variantEntry.key;
if (originalKey.length <= 1) continue; // 单字已在上面处理
// 找到这个变体 key 对应的分类
SensitiveCategory? foundCategory;
for (final entry in kSensitiveWords.entries) {
if (entry.value.contains(originalKey)) {
foundCategory = entry.key;
break;
}
}
if (foundCategory == null) continue;
for (final variant in variantEntry.value) {
if (variant.isEmpty) continue;
final vPos = normalized.indexOf(variant.toLowerCase());
if (vPos >= 0) {
final key = 'wvariant:$originalKey:$variant';
if (seen.add(key)) {
matches.add(SensitiveWordMatch(
word: originalKey,
category: foundCategory,
position: vPos,
));
}
}
}
}
return matches;
}
/// 检查文本是否包含敏感词(快捷方法)
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
/// 获取匹配到的分类标签集合(用于 UI 展示)
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
return matches.map((m) => m.category.label).toSet();
}
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
///
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
static bool _matchesWithVariants(String normalizedText, String word) {
final chars = word.split('');
final variantChars = <List<String>>[];
for (final char in chars) {
final variants = kHomophoneVariants[char];
if (variants != null && variants.isNotEmpty) {
// 原字符 + 所有变体
variantChars.add([char, ...variants]);
} else {
variantChars.add([char]);
}
}
// 生成所有变体组合并检查
return _checkCombinations(normalizedText, variantChars, 0, '');
}
/// 递归生成变体组合并检查文本
static bool _checkCombinations(
String text,
List<List<String>> variantChars,
int index,
String current,
) {
if (index == variantChars.length) {
return text.contains(current);
}
for (final char in variantChars[index]) {
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
return true;
}
}
return false;
}
/// 文本预处理:去除干扰字符,统一为小写
///
/// 1. 去除零宽字符U+200B~U+200F, U+FEFF
/// 2. 去除空格、制表符、换行
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
/// 4. 转小写(对英文词有效)
static String _normalize(String text) {
final buffer = StringBuffer();
for (final rune in text.runes) {
// 跳过零宽字符
if (rune >= 0x200B && rune <= 0x200F) continue;
if (rune == 0xFEFF) continue;
// 跳过空白
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
// 跳过常见绕过符号
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
if (rune == 0x7E || rune == 0x60) continue; // ~ `
buffer.writeCharCode(rune);
}
return buffer.toString().toLowerCase();
}
}

View File

@@ -0,0 +1,141 @@
// 敏感词库 — 本地静态词库常量,面向小学生场景
//
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
// 每个分类包含基础词 + 谐音/形近/数字变体
// 词库为 const 编译期常量,零运行时开销
//
// 注意:本词库仅为 Phase 1 基础覆盖Phase 2 将接入服务端 AI + 可更新词库。
/// 敏感词分类
enum SensitiveCategory {
violence('暴力'),
sexual('色情'),
bullying('欺凌'),
drugs('毒品'),
gambling('赌博'),
politics('政治敏感'),
fraud('诈骗'),
profanity('粗口');
const SensitiveCategory(this.label);
final String label;
}
/// ============================================================
/// 各分类敏感词
/// ============================================================
/// 暴力类
const _violenceWords = [
// 直接暴力
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
'揍你', '揍死', '打死他', '砍死', '捅死',
'杀了他', '打死他', '砍了他', '捅了他',
'去死', '你去死', '怎么不去死',
'割腕', '割脖子', '跳楼', '上吊',
// 武器
'炸弹', '手枪', '步枪', '子弹', '刀杀',
// 自残/伤害暗示
'自杀', '自残', '不想活',
];
/// 色情类
const _sexualWords = [
'色情', '裸体', '裸照', '黄色', '黄片',
'做爱', '性行为', '性交', '强奸', '强暴',
'猥亵', '性骚扰', '偷拍',
'发情', '骚货', '贱人',
];
/// 欺凌类
const _bullyingWords = [
'废物', '垃圾', '蠢货', '白痴', '弱智',
'傻子', '笨蛋', '猪头', '丑八怪',
'滚开', '滚蛋', '闭嘴', '别烦我',
'讨厌鬼', '没人要', '没朋友',
'不和你玩', '不要和你玩',
'大家不要理', '孤立',
'偷东西', '小偷',
];
/// 毒品类
const _drugsWords = [
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
'冰毒', '摇头丸', '可卡因', '吗啡',
'鸦片', 'K粉', '安非他命',
'上瘾', '毒瘾',
];
/// 赌博类
const _gamblingWords = [
'赌博', '赌钱', '下注', '押注', '赌场',
'买彩票', '时时彩', '六合彩',
'百家乐', '老虎机', '扑克赌',
'赌债', '借钱赌',
];
/// 政治敏感类
const _politicsWords = [
'反动', '颠覆', '分裂', '暴动', '造反',
'推翻', '政变', '游行示威',
];
/// 诈骗类
const _fraudWords = [
'诈骗', '骗钱', '骗密码', '骗账号',
'中奖了', '恭喜中奖', '免费领取',
'点击链接领奖', '转账给我',
'刷单', '兼职刷单', '高薪兼职',
'传销', '拉人头',
];
/// 粗口类
const _profanityWords = [
'操你', '妈的', '他妈', '去你的', '狗屎',
'', '', '放屁', '扯淡', '王八蛋',
'混蛋', '', '我去', '卧槽',
'我靠', '我擦',
];
/// 全量词库:分类 → 词列表
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
SensitiveCategory.violence: _violenceWords,
SensitiveCategory.sexual: _sexualWords,
SensitiveCategory.bullying: _bullyingWords,
SensitiveCategory.drugs: _drugsWords,
SensitiveCategory.gambling: _gamblingWords,
SensitiveCategory.politics: _politicsWords,
SensitiveCategory.fraud: _fraudWords,
SensitiveCategory.profanity: _profanityWords,
};
/// ============================================================
/// 谐音/形近/数字变体映射
/// ============================================================
/// 原词 → 变体列表
///
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
/// - 数字谐音: "死" → "4"
/// - 形近替换: "傻" → "纱"
/// - 拼音缩写: "牛逼" → "nb"
const Map<String, List<String>> kHomophoneVariants = {
// 暴力相关
'': ['4', '', '', ''],
'': ['', '', ''],
'': ['砍人'],
'': ['捅人'],
// 欺凌相关
'': ['', '', ''],
'': [], // 无实际变体
'': [''],
'废物': ['费物', '废无'],
'垃圾': ['拉吉', '垃 圾'],
// 粗口相关
'': ['', '', ''],
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
'我靠': ['我 k', '我K'],
// 欺凌
'': [''],
'': [''],
};

View File

@@ -4,20 +4,27 @@
// - 温暖友好的文案(面向小学生)
// - 分享到班级(有班级时显示)/ 仅自己可见
// - 无班级时提示加入班级后可分享
// - 分享前自动进行内容安全检查(敏感词过滤)
import 'package:flutter/material.dart';
import '../../../data/services/content_filter_service.dart';
/// 编辑器完成后的分享选择面板
class ShareBottomSheet extends StatelessWidget {
final String? classId;
final String className;
final void Function(bool shareToClass) onDecision;
/// 用于内容安全检查的文本内容(标题 + 文本元素)
final String contentText;
const ShareBottomSheet({
super.key,
required this.classId,
required this.className,
required this.onDecision,
this.contentText = '',
});
@override
@@ -65,10 +72,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity,
height: 52,
child: FilledButton.icon(
onPressed: () {
onDecision(true);
Navigator.pop(context);
},
onPressed: () => _handleShare(context, shareToClass: true),
icon: const Icon(Icons.group),
label: Text('分享到 $className'),
style: FilledButton.styleFrom(
@@ -86,10 +90,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity,
height: 52,
child: OutlinedButton.icon(
onPressed: () {
onDecision(false);
Navigator.pop(context);
},
onPressed: () => _handleShare(context, shareToClass: false),
icon: const Icon(Icons.lock_outline),
label: const Text('仅自己可见'),
style: OutlinedButton.styleFrom(
@@ -116,4 +117,80 @@ class ShareBottomSheet extends StatelessWidget {
),
);
}
/// 处理分享/保存决定
void _handleShare(BuildContext context, {required bool shareToClass}) {
// 仅在分享到班级时进行内容安全检查
if (shareToClass && contentText.isNotEmpty) {
final matches = ContentFilterService.checkText(contentText);
if (matches.isNotEmpty) {
_showContentWarning(context, matches);
return;
}
}
// 安全或仅自己可见 → 直接执行
onDecision(shareToClass);
Navigator.pop(context);
}
/// 显示内容安全警告对话框
void _showContentWarning(
BuildContext context,
List<SensitiveWordMatch> matches,
) {
final categories = ContentFilterService.getMatchedCategories(matches);
final words = matches.map((m) => ' "${m.word}"').toSet().toList();
final wordList = words.take(5).join('');
final categoryList = categories.join('');
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('内容提醒'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('日记中可能包含不太合适分享的内容:'),
const SizedBox(height: 8),
Text(
wordList,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'涉及:$categoryList',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 12),
const Text(
'建议修改后再分享,或者先保存为仅自己可见。',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('返回修改'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext); // 关闭对话框
onDecision(true); // 仍然分享
Navigator.pop(context); // 关闭 BottomSheet
},
child: const Text('仍然分享'),
),
],
),
);
}
}

View File

@@ -0,0 +1,221 @@
// ContentFilterService 单元测试
//
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/data/services/content_filter_service.dart';
import 'package:nuanji_app/data/services/sensitive_words.dart';
void main() {
// ============================================================
// 精确匹配 — 各分类
// ============================================================
group('精确匹配', () {
test('暴力类词汇检测', () {
final matches = ContentFilterService.checkText('我要打死你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('色情类词汇检测', () {
final matches = ContentFilterService.checkText('这个视频很色情');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
});
test('欺凌类词汇检测', () {
final matches = ContentFilterService.checkText('你是个废物');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('毒品类词汇检测', () {
final matches = ContentFilterService.checkText('他在吸毒');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
});
test('赌博类词汇检测', () {
final matches = ContentFilterService.checkText('我们去赌钱吧');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
});
test('粗口类词汇检测', () {
final matches = ContentFilterService.checkText('卧槽太厉害了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
});
test('诈骗类词汇检测', () {
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
});
test('政治敏感类词汇检测', () {
final matches = ContentFilterService.checkText('要造反了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
});
});
// ============================================================
// 安全内容
// ============================================================
group('安全内容', () {
test('正常日记文本不触发', () {
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
'我们玩了滑梯、秋千,还吃了冰淇淋。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
test('学习相关文本不触发', () {
final text = '今天数学课学了乘法,我觉得很有趣。'
'老师表扬了我,说我进步很大。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
});
// ============================================================
// 文本预处理 — 绕过手法
// ============================================================
group('文本预处理', () {
test('空格分隔不影响检测', () {
final matches = ContentFilterService.checkText('我 要 打 死 你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('特殊符号插入不影响检测', () {
final matches = ContentFilterService.checkText('废.物.垃.圾');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('零宽字符不影响检测', () {
// U+200B 零宽空格
final matches = ContentFilterService.checkText('废​物​');
expect(matches, isNotEmpty);
});
test('下划线连字符不影响检测', () {
final matches = ContentFilterService.checkText('废_物');
expect(matches, isNotEmpty);
});
});
// ============================================================
// 谐音/变体匹配
// ============================================================
group('谐音变体匹配', () {
test('数字谐音 "4" 匹配含 "死" 的词', () {
// "去死" 在词库中 → "去4" 应触发匹配
final matches = ContentFilterService.checkText('你怎么不去4');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '去死'), isTrue);
});
test('形近字 "草" 匹配含 "操" 的词', () {
// "操你" 在词库中 → "草你" 应触发匹配
final matches = ContentFilterService.checkText('我草你太牛了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '操你'), isTrue);
});
test('变体 "wc" 匹配 "卧槽"', () {
final matches = ContentFilterService.checkText('wc这个好厉害');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '卧槽'), isTrue);
});
test('变体 "莎" 匹配含 "杀" 的词', () {
// "杀人" 在词库中 → "莎人" 应触发匹配
final matches = ContentFilterService.checkText('我要莎人了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '杀人'), isTrue);
});
});
// ============================================================
// 边界条件
// ============================================================
group('边界条件', () {
test('空字符串返回空列表', () {
expect(ContentFilterService.checkText(''), isEmpty);
});
test('纯空格返回空列表', () {
expect(ContentFilterService.checkText(' '), isEmpty);
});
test('纯符号返回空列表', () {
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
});
test('超长文本不崩溃', () {
final longText = '今天天气很好。' * 10000; // ~80,000 字符
final matches = ContentFilterService.checkText(longText);
expect(matches, isEmpty); // 正常内容
});
test('多次出现同一词精确匹配只报告一次', () {
final matches = ContentFilterService.checkText('白痴白痴');
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
expect(exactMatches.length, 1);
});
});
// ============================================================
// 辅助方法
// ============================================================
group('辅助方法', () {
test('hasSensitiveContent 正确判断', () {
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
});
test('getMatchedCategories 返回分类标签', () {
final matches = ContentFilterService.checkText('废物你去死');
final categories = ContentFilterService.getMatchedCategories(matches);
expect(categories, isNotEmpty);
// 至少包含欺凌和暴力
expect(categories.any((c) => c == '欺凌'), isTrue);
expect(categories.any((c) => c == '暴力'), isTrue);
});
});
// ============================================================
// 词库完整性
// ============================================================
group('词库完整性', () {
test('8 个分类都有词', () {
expect(kSensitiveWords.length, 8);
for (final entry in kSensitiveWords.entries) {
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
}
});
test('总词量 >= 100', () {
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
expect(total, greaterThanOrEqualTo(100));
});
test('谐音变体映射的 key 都在词库中', () {
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
for (final key in kHomophoneVariants.keys) {
// 变体 key 应该在词库中存在(单字映射除外)
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
expect(
allWords.any((w) => w.contains(key)),
isTrue,
reason: '变体 key "$key" 不在词库中',
);
}
});
});
}

View File

@@ -0,0 +1,372 @@
// ClassBloc 单元测试
//
// 覆盖:班级列表加载、选中详情、成员/日记墙/主题/评语加载、创建班级、加入班级、布置主题
// 使用 mocktail mock ClassRepository + JournalRepository
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/features/class_/bloc/class_bloc.dart';
// ===== Mocks =====
class MockClassRepository extends Mock implements ClassRepository {}
class MockJournalRepository extends Mock implements JournalRepository {}
// ===== 测试数据工厂 =====
SchoolClass _makeClass({
String id = 'class-1',
String name = '三年级一班',
String schoolName = '暖阳小学',
String teacherId = 'teacher-1',
String classCode = 'ABC123',
int memberCount = 25,
}) {
return SchoolClass(
id: id,
name: name,
schoolName: schoolName,
teacherId: teacherId,
classCode: classCode,
memberCount: memberCount,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
}
JournalEntry _makeJournal({
String id = 'j-1',
String title = '今天的心情',
Mood mood = Mood.happy,
bool sharedToClass = true,
}) {
return JournalEntry(
id: id,
authorId: 'user-1',
title: title,
date: DateTime(2026, 6, 1),
mood: mood,
createdAt: DateTime(2026, 6, 1),
updatedAt: DateTime(2026, 6, 1),
sharedToClass: sharedToClass,
);
}
ClassMemberDto _makeMember({
String userId = 'student-1',
String role = 'student',
String? nickname = '小明',
}) {
return ClassMemberDto(
userId: userId,
role: role,
nickname: nickname,
joinedAt: DateTime(2026, 1, 15),
);
}
TopicDto _makeTopic({
String id = 'topic-1',
String classId = 'class-1',
String title = '我的暑假计划',
}) {
return TopicDto(
id: id,
classId: classId,
teacherId: 'teacher-1',
title: title,
isActive: true,
);
}
CommentDto _makeComment({
String id = 'comment-1',
String journalId = 'j-1',
String content = '写得很好!',
}) {
return CommentDto(
id: id,
journalId: journalId,
authorId: 'teacher-1',
content: content,
createdAt: DateTime(2026, 6, 2),
);
}
void main() {
late MockClassRepository mockClassRepo;
late MockJournalRepository mockJournalRepo;
late ClassBloc bloc;
setUp(() {
mockClassRepo = MockClassRepository();
mockJournalRepo = MockJournalRepository();
bloc = ClassBloc(
classRepository: mockClassRepo,
journalRepository: mockJournalRepo,
);
});
tearDown(() {
bloc.close();
});
// ===== 辅助:收集事件触发后的最终状态 =====
Future<ClassState> dispatch(ClassEvent event) async {
bloc.add(event);
// 等待所有异步事件处理完毕
await Future<void>.delayed(const Duration(milliseconds: 50));
return bloc.state;
}
// ===== 班级列表 =====
group('ClassLoadMyClasses', () {
test('成功加载班级列表', () async {
final classes = [_makeClass(id: 'c1'), _makeClass(id: 'c2', name: '三年级二班')];
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => classes);
final state = await dispatch(const ClassLoadMyClasses());
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes.length, 2);
expect(loaded.classes[0].id, 'c1');
expect(loaded.classes[1].name, '三年级二班');
expect(loaded.isLoading, false);
expect(loaded.error, isNull);
});
test('加载失败返回空列表', () async {
when(() => mockClassRepo.getMyClasses()).thenThrow(Exception('网络错误'));
final state = await dispatch(const ClassLoadMyClasses());
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes, isEmpty);
expect(loaded.isLoading, false);
});
test('加载中状态先触发', () async {
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return [_makeClass()];
});
bloc.add(const ClassLoadMyClasses());
// 立即检查,应该在 loading 状态
await Future<void>.delayed(const Duration(milliseconds: 10));
// state 可能是 loading也可能已完成取决于调度
});
});
// ===== 选中班级 =====
group('ClassSelected', () {
test('成功选中班级并触发子事件加载', () async {
final classInfo = _makeClass();
final members = [_makeMember()];
final journals = [_makeJournal(sharedToClass: true)];
final topics = [_makeTopic()];
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => classInfo);
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => topics);
final state = await dispatch(const ClassSelected('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.classInfo.id, 'class-1');
expect(detail.members.length, 1);
expect(detail.topics.length, 1);
});
test('加载失败返回 ClassError', () async {
when(() => mockClassRepo.getClass('class-1')).thenThrow(Exception('不存在'));
final state = await dispatch(const ClassSelected('class-1'));
expect(state, isA<ClassError>());
expect((state as ClassError).message, contains('加载班级失败'));
});
});
// ===== 成员加载 =====
group('ClassLoadMembers', () {
test('在 ClassDetailLoaded 状态下加载成员', () async {
// 先进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
// 现在加载成员
final members = [_makeMember(), _makeMember(userId: 'student-2', nickname: '小红')];
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
final state = await dispatch(const ClassLoadMembers('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.members.length, 2);
expect(detail.members[0].nickname, '小明');
expect(detail.members[1].nickname, '小红');
});
test('非 ClassDetailLoaded 状态下忽略', () async {
// 初始状态 ClassInitial直接加载成员应被忽略
final state = await dispatch(const ClassLoadMembers('class-1'));
expect(state, isA<ClassInitial>());
});
});
// ===== 日记墙 =====
group('ClassLoadDiaryWall', () {
test('只包含 sharedToClass 为 true 的日记', () async {
// 手动进入 ClassDetailLoaded 状态
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
// 等待 ClassSelected 的子事件完成
await Future<void>.delayed(const Duration(milliseconds: 150));
// 此时应该已经在 ClassDetailLoaded 状态
expect(bloc.state, isA<ClassDetailLoaded>());
// 现在单独测试 ClassLoadDiaryWall 过滤逻辑
final journals = [
_makeJournal(id: 'j1', sharedToClass: true),
_makeJournal(id: 'j2', sharedToClass: false),
_makeJournal(id: 'j3', sharedToClass: true),
];
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
final state = await dispatch(const ClassLoadDiaryWall('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.diaryWall.length, 2);
expect(detail.diaryWall.every((j) => j.sharedToClass), isTrue);
});
});
// ===== 创建班级 =====
group('ClassCreate', () {
test('成功创建班级并添加到列表', () async {
// 先加载列表
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass(id: 'c1')]);
await dispatch(const ClassLoadMyClasses());
// 创建新班级
final newClass = _makeClass(id: 'c2', name: '新班级');
when(() => mockClassRepo.createClass(name: '新班级')).thenAnswer((_) async => newClass);
final state = await dispatch(const ClassCreate(name: '新班级'));
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes.length, 2);
expect(loaded.classes.last.id, 'c2');
});
test('创建失败设置 error', () async {
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => []);
await dispatch(const ClassLoadMyClasses());
when(() => mockClassRepo.createClass(name: '失败')).thenThrow(Exception('创建失败'));
final state = await dispatch(const ClassCreate(name: '失败'));
expect(state, isA<ClassListLoaded>());
expect((state as ClassListLoaded).error, isNotNull);
});
});
// ===== 加入班级 =====
group('ClassJoin', () {
test('加入成功后触发列表刷新', () async {
when(() => mockClassRepo.joinClass('ABC123')).thenAnswer((_) async => _makeClass());
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass()]);
await dispatch(const ClassJoin(classCode: 'ABC123'));
// 应该触发 ClassLoadMyClasses最终状态为 ClassListLoaded
expect(bloc.state, isA<ClassListLoaded>());
verify(() => mockClassRepo.joinClass('ABC123')).called(1);
verify(() => mockClassRepo.getMyClasses()).called(1);
});
test('加入失败返回 ClassError', () async {
when(() => mockClassRepo.joinClass('INVALID')).thenThrow(Exception('班级码错误'));
final state = await dispatch(const ClassJoin(classCode: 'INVALID'));
expect(state, isA<ClassError>());
expect((state as ClassError).message, contains('加入班级失败'));
});
});
// ===== 布置主题 =====
group('TopicAssign', () {
test('成功布置主题并添加到列表', () async {
// 进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
final newTopic = _makeTopic(id: 'topic-2', title: '新主题');
when(() => mockClassRepo.assignTopic(classId: 'class-1', title: '新主题'))
.thenAnswer((_) async => newTopic);
final state = await dispatch(const TopicAssign(classId: 'class-1', title: '新主题'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.topics.length, 1);
expect(detail.topics.first.title, '新主题');
});
});
// ===== 评语 =====
group('ClassLoadComments', () {
test('加载评语并设置 selectedJournalId', () async {
// 进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
final comments = [_makeComment(), _makeComment(id: 'comment-2', content: '继续加油')];
when(() => mockClassRepo.getComments('j-1')).thenAnswer((_) async => comments);
final state = await dispatch(const ClassLoadComments('j-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.comments.length, 2);
expect(detail.selectedJournalId, 'j-1');
});
});
}

View File

@@ -0,0 +1,272 @@
// HandwritingCanvas Widget 集成测试 — 指针事件驱动笔画完成回调
//
// 验证:
// 1. Widget 正确渲染(双层 CustomPaint
// 2. 手势事件触发 onStrokeCompleted 回调
// 3. 不同画笔类型/颜色/宽度正确传递
// 4. 去抖过滤(微小移动被丢弃)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/handwriting_canvas.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// 辅助
// ============================================================
/// 包裹 HandwritingCanvas 在必要的父组件中,提供约束尺寸
Widget buildTestSubject({
Key? key,
BrushType brushType = BrushType.pen,
String brushColor = '#2D2420',
double brushWidth = 3.0,
List<Stroke> strokes = const [],
ValueChanged<Stroke>? onStrokeCompleted,
}) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
brushType: brushType,
brushColor: brushColor,
brushWidth: brushWidth,
strokes: strokes,
onStrokeCompleted: onStrokeCompleted,
),
),
),
);
}
/// 使用标准 TestGesture 模拟一条完整的拖拽手势
Future<void> simulateDragStroke(
WidgetTester tester,
List<Offset> points,
) async {
assert(points.length >= 2, '至少需要 down 和 up 两个点');
final gesture = await tester.startGesture(points.first);
await tester.pump();
for (var i = 1; i < points.length; i++) {
await gesture.moveTo(points[i]);
await tester.pump();
}
await gesture.up();
await tester.pump();
}
// ============================================================
// 渲染结构验证
// ============================================================
group('HandwritingCanvas — 渲染结构', () {
testWidgets('正确渲染双层 CustomPaint', (tester) async {
await tester.pumpWidget(buildTestSubject());
await tester.pumpAndSettle();
// 应找到 CustomPaint两层CachedStrokesPainter + ActiveStrokePainter
final customPaints = find.byType(CustomPaint);
expect(customPaints, findsAtLeast(2));
// 应找到 ListenerHandwritingCanvas 的 Listener + Gesture 识别器可能有额外 Listener
expect(find.byType(Listener), findsAtLeast(1));
// 应找到 RepaintBoundaryMaterialApp/Scaffold 可能添加额外的)
expect(find.byType(RepaintBoundary), findsAtLeast(1));
});
testWidgets('初始无笔画时仍正确渲染', (tester) async {
await tester.pumpWidget(buildTestSubject());
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
});
// ============================================================
// 手势事件 → onStrokeCompleted
// ============================================================
group('HandwritingCanvas — 笔画完成回调', () {
testWidgets('有效拖拽触发 onStrokeCompleted', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
// 模拟 5 个点的笔画(距离足够大,避免去抖过滤)
await simulateDragStroke(tester, [
const Offset(100, 100),
const Offset(150, 120),
const Offset(200, 140),
const Offset(250, 160),
const Offset(300, 180),
]);
// 应触发回调
expect(completedStroke, isNotNull);
expect(completedStroke!.points.length, greaterThanOrEqualTo(2));
expect(completedStroke!.brushType, BrushType.pen);
expect(completedStroke!.color, '#2D2420');
expect(completedStroke!.width, 3.0);
});
testWidgets('笔画携带正确的画笔类型', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
brushType: BrushType.marker,
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
await simulateDragStroke(tester, [
const Offset(100, 100),
const Offset(200, 200),
const Offset(300, 100),
]);
expect(completedStroke, isNotNull);
expect(completedStroke!.brushType, BrushType.marker);
});
testWidgets('笔画携带正确的颜色和宽度', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
brushColor: '#E07A5F',
brushWidth: 8.0,
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
await simulateDragStroke(tester, [
const Offset(50, 50),
const Offset(150, 150),
const Offset(250, 50),
]);
expect(completedStroke!.color, '#E07A5F');
expect(completedStroke!.width, 8.0);
});
testWidgets('tap无拖拽不触发回调', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
// 仅 tapdown + up 在同一位置)— 单点不足以构成笔画
await tester.tapAt(const Offset(100, 100));
await tester.pumpAndSettle();
// Tap 产生 1 个点down 和 up 位置相同),不触发回调
expect(completedStroke, isNull);
});
});
// ============================================================
// 预加载笔画
// ============================================================
group('HandwritingCanvas — 预加载笔画', () {
testWidgets('初始笔画列表正确传入', (tester) async {
final existingStrokes = [
Stroke(
id: 'existing-1',
points: [
const StrokePoint(x: 10, y: 10),
const StrokePoint(x: 100, y: 100),
],
),
];
await tester.pumpWidget(buildTestSubject(
strokes: existingStrokes,
));
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
testWidgets('笔画更新触发 didUpdateWidget', (tester) async {
final key = GlobalKey();
// 第一次渲染 — 无笔画
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
strokes: const [],
),
),
),
));
await tester.pumpAndSettle();
// 更新 — 添加笔画
final updatedStrokes = [
Stroke(
id: 'new-1',
points: [
const StrokePoint(x: 50, y: 50),
const StrokePoint(x: 200, y: 200),
],
),
];
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
strokes: updatedStrokes,
),
),
),
));
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
});
// ============================================================
// 连续多笔画
// ============================================================
group('HandwritingCanvas — 连续多笔画', () {
testWidgets('连续绘制多条笔画,每条都触发回调', (tester) async {
final completedStrokes = <Stroke>[];
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStrokes.add(stroke),
));
await tester.pumpAndSettle();
// 第一条笔画
await simulateDragStroke(tester, [
const Offset(50, 50),
const Offset(150, 100),
const Offset(250, 50),
]);
// 第二条笔画(使用新的 gesture
await simulateDragStroke(tester, [
const Offset(100, 200),
const Offset(200, 300),
const Offset(300, 200),
]);
expect(completedStrokes.length, 2);
expect(completedStrokes[0].id, isNot(equals(completedStrokes[1].id)));
});
});
}

View File

@@ -0,0 +1,238 @@
// StrokeRasterCache 单元测试 — 光栅化缓存管理器
//
// 注意ui.PictureRecorder().endRecording().toImage() 需要 Flutter Test 绑定,
// 因此这些测试在 flutter test 环境中运行(自动提供 TestWidgetsFlutterBinding
// 使用足够大的画布尺寸(>0才能使光栅化生效。
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_cache.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// 辅助
// ============================================================
/// 构造一条从 (x0,y0) 到 (x1,y1) 的简单笔画
Stroke makeStroke(
String id, {
double x0 = 10.0,
double y0 = 10.0,
double x1 = 200.0,
double y1 = 200.0,
BrushType brushType = BrushType.pen,
String color = '#2D2420',
double width = 3.0,
int pointCount = 10,
}) {
return Stroke(
id: id,
points: List.generate(
pointCount,
(i) {
final t = i / (pointCount - 1);
return StrokePoint(
x: x0 + (x1 - x0) * t,
y: y0 + (y1 - y0) * t,
pressure: 0.5,
timestamp: i * 16,
);
},
),
brushType: brushType,
color: color,
width: width,
);
}
// ============================================================
// 生命周期与基本属性
// ============================================================
group('StrokeRasterCache — 生命周期', () {
test('初始状态为空', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
expect(cache.compositeImage, isNull);
expect(cache.layerVersion, 0);
expect(cache.length, 0);
expect(cache.cachedStrokeIds, isEmpty);
});
test('dispose 后可安全调用', () {
final cache = StrokeRasterCache();
cache.dispose();
// 不应抛异常
expect(cache.compositeImage, isNull);
});
});
// ============================================================
// 尺寸管理
// ============================================================
group('StrokeRasterCache — 尺寸管理', () {
test('ensureSize 设置画布尺寸', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
cache.ensureSize(const Size(800, 600));
expect(cache.canvasSize, const Size(800, 600));
});
test('ensureSize 相同尺寸不触发失效', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
cache.ensureSize(const Size(800, 600));
final v1 = cache.layerVersion;
cache.ensureSize(const Size(800, 600)); // 相同尺寸
expect(cache.layerVersion, v1); // 版本不变
});
});
// ============================================================
// 笔画操作
// ============================================================
group('StrokeRasterCache — 笔画操作', () {
late StrokeRasterCache cache;
setUp(() {
cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
});
tearDown(() {
cache.dispose();
});
test('addStroke 缓存笔画并递增版本', () async {
final stroke = makeStroke('s1');
await cache.addStroke(stroke);
expect(cache.length, 1);
expect(cache.cachedStrokeIds, contains('s1'));
expect(cache.layerVersion, greaterThan(0));
expect(cache.compositeImage, isNotNull);
});
test('addStroke 在画布尺寸为零时跳过', () async {
final emptyCache = StrokeRasterCache();
addTearDown(emptyCache.dispose);
// 未调用 ensureSizecanvasSize == Size.zero
await emptyCache.addStroke(makeStroke('s1'));
expect(emptyCache.length, 0);
});
test('多条笔画增量合成', () async {
await cache.addStroke(makeStroke('s1'));
final v1 = cache.layerVersion;
await cache.addStroke(makeStroke('s2'));
final v2 = cache.layerVersion;
await cache.addStroke(makeStroke('s3'));
expect(cache.length, 3);
expect(v2, greaterThan(v1));
expect(cache.layerVersion, greaterThan(v2));
});
test('不同画笔类型均可光栅化', () async {
for (final bt in BrushType.values) {
final id = 'stroke-${bt.value}';
await cache.addStroke(makeStroke(
id,
brushType: bt,
color: bt == BrushType.eraser ? '#FFFFFF' : '#E07A5F',
));
expect(cache.cachedStrokeIds, contains(id), reason: '$bt 应能光栅化');
}
expect(cache.length, BrushType.values.length);
});
test('clear 清除所有缓存', () async {
await cache.addStroke(makeStroke('s1'));
await cache.addStroke(makeStroke('s2'));
expect(cache.length, 2);
await cache.clear();
expect(cache.length, 0);
expect(cache.compositeImage, isNull);
expect(cache.cachedStrokeIds, isEmpty);
});
test('syncStrokes 添加缺失笔画', () async {
await cache.addStroke(makeStroke('s1'));
expect(cache.length, 1);
// syncStrokes 传入 s1 + s2应只添加 s2
await cache.syncStrokes([makeStroke('s1'), makeStroke('s2')]);
expect(cache.length, 2);
expect(cache.cachedStrokeIds, containsAll(['s1', 's2']));
});
test('syncStrokes 移除多余笔画(模拟撤销)', () async {
await cache.addStroke(makeStroke('s1'));
await cache.addStroke(makeStroke('s2'));
expect(cache.length, 2);
// syncStrokes 只保留 s1移除 s2
await cache.syncStrokes([makeStroke('s1')]);
expect(cache.length, 1);
expect(cache.cachedStrokeIds, {'s1'});
});
test('syncStrokes 无变化时不增加版本', () async {
await cache.addStroke(makeStroke('s1'));
final v = cache.layerVersion;
// 传入完全相同的笔画列表
await cache.syncStrokes([makeStroke('s1')]);
expect(cache.layerVersion, v);
});
test('invalidateAll 重建所有缓存', () async {
await cache.addStroke(makeStroke('s1'));
final v1 = cache.layerVersion;
// 尺寸变化触发 invalidateAll
cache.ensureSize(const Size(1024, 768));
await cache.addStroke(makeStroke('s1')); // 重建后重新添加
// 版本应高于之前(因为 invalidateAll + addStroke 都递增)
expect(cache.layerVersion, greaterThan(v1));
});
});
// ============================================================
// 空边界条件
// ============================================================
group('StrokeRasterCache — 边界条件', () {
test('单点笔画不会生成缓存条目', () async {
final cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
addTearDown(cache.dispose);
final singlePointStroke = Stroke(
id: 'single',
points: [const StrokePoint(x: 50, y: 50)],
);
await cache.addStroke(singlePointStroke);
// 单点 → pointsToOutline 返回空 → _rasterizeStroke 返回 null
expect(cache.length, 0);
});
test('空笔画列表的 syncStrokes 不报错', () async {
final cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
addTearDown(cache.dispose);
await cache.syncStrokes([]); // 不应抛
expect(cache.length, 0);
});
});
}

View File

@@ -0,0 +1,159 @@
// StrokeModel 单元测试 — 笔画数据模型的序列化与不可变性验证
import 'dart:collection';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// StrokePoint
// ============================================================
group('StrokePoint', () {
test('构造函数设置默认值', () {
const point = StrokePoint(x: 10.0, y: 20.0);
expect(point.x, 10.0);
expect(point.y, 20.0);
expect(point.pressure, 0.5);
expect(point.timestamp, 0);
});
test('copyWith 返回新实例,原实例不变', () {
const original = StrokePoint(x: 1.0, y: 2.0, pressure: 0.3, timestamp: 100);
final copied = original.copyWith(x: 10.0, pressure: 0.8);
expect(copied.x, 10.0);
expect(copied.y, 2.0); // 未变
expect(copied.pressure, 0.8);
expect(copied.timestamp, 100); // 未变
// 原实例不变
expect(original.x, 1.0);
expect(original.pressure, 0.3);
});
test('toJson → fromJson 往返一致', () {
const point = StrokePoint(x: 123.456, y: 789.012, pressure: 0.75, timestamp: 1700000000);
final json = point.toJson();
final restored = StrokePoint.fromJson(json);
expect(restored.x, closeTo(point.x, 0.001));
expect(restored.y, closeTo(point.y, 0.001));
expect(restored.pressure, closeTo(point.pressure, 0.001));
expect(restored.timestamp, point.timestamp);
});
test('fromJson 处理缺失字段使用默认值', () {
final restored = StrokePoint.fromJson({'x': 5.0, 'y': 10.0});
expect(restored.pressure, 0.5);
expect(restored.timestamp, 0);
});
});
// ============================================================
// Stroke
// ============================================================
group('Stroke', () {
List<StrokePoint> makePoints(int count) => List.generate(
count,
(i) => StrokePoint(x: i * 10.0, y: i * 5.0, pressure: 0.5, timestamp: i * 16),
);
test('构造函数设置默认值', () {
final stroke = Stroke(id: 'test-1', points: makePoints(3));
expect(stroke.id, 'test-1');
expect(stroke.brushType, BrushType.pen);
expect(stroke.color, '#2D2420');
expect(stroke.width, 3.0);
});
test('copyWith 返回新实例', () {
final original = Stroke(
id: 's1',
points: makePoints(3),
brushType: BrushType.marker,
color: '#FF0000',
width: 5.0,
);
final copied = original.copyWith(color: '#00FF00', width: 8.0);
expect(copied.id, 's1'); // 未变
expect(copied.brushType, BrushType.marker); // 未变
expect(copied.color, '#00FF00');
expect(copied.width, 8.0);
});
test('toJson → fromJson 往返一致', () {
final stroke = Stroke(
id: 'abc-123',
points: makePoints(5),
brushType: BrushType.pencil,
color: '#81B29A',
width: 2.0,
);
final json = stroke.toJson();
final restored = Stroke.fromJson(json);
expect(restored.id, stroke.id);
expect(restored.brushType, stroke.brushType);
expect(restored.color, stroke.color);
expect(restored.width, stroke.width);
expect(restored.points.length, stroke.points.length);
for (var i = 0; i < stroke.points.length; i++) {
expect(restored.points[i].x, closeTo(stroke.points[i].x, 0.001));
expect(restored.points[i].y, closeTo(stroke.points[i].y, 0.001));
}
});
test('fromJson 产生不可变点列表', () {
final stroke = Stroke.fromJson({
'id': 'immutable-test',
'points': [
{'x': 1.0, 'y': 2.0},
{'x': 3.0, 'y': 4.0},
],
});
expect(stroke.points, isA<UnmodifiableListView<StrokePoint>>());
});
test('fromJson 处理缺失可选字段使用默认值', () {
final restored = Stroke.fromJson({
'id': 'minimal',
'points': [
{'x': 0.0, 'y': 0.0},
],
});
expect(restored.brushType, BrushType.pen);
expect(restored.color, '#2D2420');
expect(restored.width, 3.0);
});
test('fromJson 处理未知 brushType 回退到 pen', () {
final restored = Stroke.fromJson({
'id': 'unknown-brush',
'points': [
{'x': 0.0, 'y': 0.0},
],
'brushType': 'nonexistent',
});
expect(restored.brushType, BrushType.pen);
});
});
// ============================================================
// BrushType 枚举
// ============================================================
group('BrushType', () {
test('包含全部 4 种画笔', () {
expect(BrushType.values.length, 4);
expect(BrushType.values.map((b) => b.value), ['pen', 'pencil', 'marker', 'eraser']);
});
test('value 与枚举一一对应', () {
for (final bt in BrushType.values) {
final found = BrushType.values.firstWhere((b) => b.value == bt.value);
expect(found, bt);
}
});
});
}

View File

@@ -0,0 +1,214 @@
// StrokeRenderer 单元测试 — 纯函数验证
//
// 覆盖pointsToOutline、buildStrokePath、parseHexColor、createPaintForStroke
// 不依赖 Flutter 绑定dart:ui 的 Canvas/Image仅测试纯逻辑。
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_renderer.dart';
void main() {
// ============================================================
// parseHexColor
// ============================================================
group('parseHexColor', () {
test('解析标准 #RRGGBB 格式', () {
final color = parseHexColor('#E07A5F');
expect(color.value, const Color(0xFFE07A5F).value);
});
test('解析不带 # 的 6 位十六进制', () {
// parseHexColor 会先 replaceFirst('#', ''),所以直接传 6 位也应该工作
final color = parseHexColor('#2D2420');
expect(color, const Color(0xFF2D2420));
});
test('全黑 #000000', () {
expect(parseHexColor('#000000').value, const Color(0xFF000000).value);
});
test('全白 #FFFFFF', () {
expect(parseHexColor('#FFFFFF').value, const Color(0xFFFFFFFF).value);
});
test('无效长度回退到默认色', () {
const fallback = Color(0xFF2D2420);
expect(parseHexColor('#FFF').value, fallback.value);
expect(parseHexColor('#12345').value, fallback.value);
expect(parseHexColor('').value, fallback.value);
});
test('无效字符回退到默认色', () {
expect(parseHexColor('#GGGGGG').value, const Color(0xFF2D2420).value);
});
});
// ============================================================
// pointsToOutline
// ============================================================
group('pointsToOutline', () {
/// 构造 N 个均匀分布的点
List<StrokePoint> makeLinearPoints(int count) => List.generate(
count,
(i) => StrokePoint(
x: i * 10.0,
y: i * 10.0,
pressure: 0.5,
timestamp: i * 16,
),
);
test('少于 2 个点返回空列表', () {
final empty = pointsToOutline([], BrushType.pen, 3.0);
expect(empty, isEmpty);
final onePoint = pointsToOutline(
[const StrokePoint(x: 0, y: 0)],
BrushType.pen,
3.0,
);
expect(onePoint, isEmpty);
});
test('2 个点生成非空轮廓', () {
final points = makeLinearPoints(2);
final outline = pointsToOutline(points, BrushType.pen, 3.0);
expect(outline, isNotEmpty);
// perfect_freehand 生成的是封闭轮廓,点数远多于输入点
expect(outline.length, greaterThan(points.length));
});
test('4 种画笔类型均能生成轮廓', () {
final points = makeLinearPoints(10);
for (final bt in BrushType.values) {
final outline = pointsToOutline(points, bt, 3.0);
expect(outline, isNotEmpty, reason: '$bt 应生成非空轮廓');
}
});
test('宽度影响轮廓大小 — 更大的 width 产生更大的轮廓', () {
final points = makeLinearPoints(10);
final outlineThin = pointsToOutline(points, BrushType.pen, 1.0);
final outlineThick = pointsToOutline(points, BrushType.pen, 8.0);
// 计算轮廓的包围盒面积作为粗略大小指标
double bboxArea(List<Offset> pts) {
if (pts.isEmpty) return 0;
double minX = double.infinity, maxX = double.negativeInfinity;
double minY = double.infinity, maxY = double.negativeInfinity;
for (final p in pts) {
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
}
return (maxX - minX) * (maxY - minY);
}
expect(bboxArea(outlineThick), greaterThan(bboxArea(outlineThin)));
});
test('isComplete 参数影响输出(端点处理)', () {
final points = makeLinearPoints(5);
final complete = pointsToOutline(points, BrushType.pen, 3.0, isComplete: true);
final active = pointsToOutline(points, BrushType.pen, 3.0, isComplete: false);
// 两者都应该生成轮廓,但端点处理不同
expect(complete, isNotEmpty);
expect(active, isNotEmpty);
});
});
// ============================================================
// buildStrokePath
// ============================================================
group('buildStrokePath', () {
test('空列表返回空 Path', () {
final path = buildStrokePath([]);
// 空 path 的 bounds 是零矩形
expect(path.getBounds().isEmpty, isTrue);
});
test('非空点列表返回有效 Path', () {
final points = [
const Offset(0, 0),
const Offset(10, 10),
const Offset(20, 5),
const Offset(30, 15),
];
final path = buildStrokePath(points);
expect(path.getBounds().isEmpty, isFalse);
expect(path.getBounds().width, greaterThan(0));
expect(path.getBounds().height, greaterThan(0));
});
test('路径包围盒包含所有输入点', () {
final points = [
const Offset(5, 5),
const Offset(100, 50),
const Offset(200, 100),
];
final path = buildStrokePath(points);
final bounds = path.getBounds();
for (final p in points) {
expect(bounds.left, lessThanOrEqualTo(p.dx));
expect(bounds.top, lessThanOrEqualTo(p.dy));
expect(bounds.right, greaterThanOrEqualTo(p.dx));
expect(bounds.bottom, greaterThanOrEqualTo(p.dy));
}
});
});
// ============================================================
// createPaintForStroke
// ============================================================
group('createPaintForStroke', () {
Stroke makeStroke(BrushType type, {String color = '#2D2420', double width = 3.0}) {
return Stroke(
id: 'test',
points: [
const StrokePoint(x: 0, y: 0),
const StrokePoint(x: 100, y: 100),
],
brushType: type,
color: color,
width: width,
);
}
test('钢笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen));
expect(paint.color.value, parseHexColor('#2D2420').value);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('铅笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pencil));
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('马克笔 — 半透明', () {
final paint = createPaintForStroke(makeStroke(BrushType.marker, color: '#E07A5F'));
// alpha = 0.4
expect(paint.color.alpha, closeTo(102, 1)); // 0.4 * 255 ≈ 102
expect(paint.style, PaintingStyle.fill);
});
test('橡皮擦 — dstOut 混合模式', () {
final paint = createPaintForStroke(makeStroke(BrushType.eraser));
expect(paint.blendMode, BlendMode.dstOut);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('颜色正确传递', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen, color: '#81B29A'));
expect(paint.color.value, parseHexColor('#81B29A').value);
});
});
}

View File

@@ -0,0 +1,293 @@
// SearchBloc 单元测试
//
// 覆盖关键词搜索、标签搜索、心情筛选、搜索历史、清除搜索、tab 切换
// 使用 mocktail mock JournalRepository
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/features/search/bloc/search_bloc.dart';
// ===== Mock =====
class MockJournalRepository extends Mock implements JournalRepository {}
// ===== 测试数据工厂 =====
JournalEntry _makeJournal({
String id = 'j-1',
String title = '今天的心情日记',
Mood mood = Mood.happy,
String? contentExcerpt,
List<String> tags = const [],
}) {
return JournalEntry(
id: id,
authorId: 'user-1',
title: title,
date: DateTime(2026, 6, 1),
mood: mood,
contentExcerpt: contentExcerpt,
tags: tags,
createdAt: DateTime(2026, 6, 1),
updatedAt: DateTime(2026, 6, 1),
);
}
void main() {
late MockJournalRepository mockJournalRepo;
late SearchBloc bloc;
setUp(() {
mockJournalRepo = MockJournalRepository();
bloc = SearchBloc(journalRepository: mockJournalRepo);
});
tearDown(() {
bloc.close();
});
/// 辅助dispatch 并等待处理完成
Future<SearchState> dispatch(SearchEvent event) async {
bloc.add(event);
await Future<void>.delayed(const Duration(milliseconds: 50));
return bloc.state;
}
// ===== 关键词搜索 =====
group('SearchByKeyword', () {
test('空关键词不触发搜索,返回空结果', () async {
final state = await dispatch(const SearchByKeyword(''));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
});
test('纯空格关键词视为空', () async {
final state = await dispatch(const SearchByKeyword(' '));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
});
test('匹配标题中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', title: '今天的心情日记'),
_makeJournal(id: 'j2', title: '周末旅行记'),
_makeJournal(id: 'j3', title: '读后感'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('心情'));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 1);
expect(loaded.results.first.id, 'j1');
expect(loaded.activeKeyword, '心情');
});
test('匹配内容摘要中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', title: '日记', contentExcerpt: '今天心情很好,阳光明媚'),
_makeJournal(id: 'j2', title: '随笔', contentExcerpt: '天气阴沉'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('阳光'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 1);
expect((state as SearchLoaded).results.first.id, 'j1');
});
test('匹配标签中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
_makeJournal(id: 'j2', tags: ['学习']),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('旅行'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 1);
});
test('大小写不敏感搜索', () async {
final journals = [
_makeJournal(id: 'j1', title: 'Happy Day'),
_makeJournal(id: 'j2', title: 'happy mood'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('HAPPY'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 2);
});
test('搜索失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByKeyword('测试'));
expect(state, isA<SearchError>());
});
test('搜索历史记录', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('关键词A'));
await dispatch(const SearchByKeyword('关键词B'));
final state = await dispatch(const SearchByKeyword(''));
final loaded = state as SearchLoaded;
expect(loaded.searchHistory, ['关键词B', '关键词A']);
});
test('搜索历史去重', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('重复'));
await dispatch(const SearchByKeyword('其他'));
await dispatch(const SearchByKeyword('重复'));
final state = await dispatch(const SearchByKeyword(''));
final loaded = state as SearchLoaded;
expect(loaded.searchHistory, ['重复', '其他']);
});
});
// ===== 心情筛选 =====
group('SearchByMood', () {
test('null mood 返回空结果', () async {
final state = await dispatch(const SearchByMood(null));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
expect((state as SearchLoaded).activeMood, isNull);
});
test('按心情筛选日记', () async {
final journals = [
_makeJournal(id: 'j1', mood: Mood.happy),
_makeJournal(id: 'j2', mood: Mood.happy),
_makeJournal(id: 'j3', mood: Mood.sad),
];
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
.thenAnswer((_) async => journals.where((j) => j.mood == Mood.happy).toList());
final state = await dispatch(const SearchByMood(Mood.happy));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 2);
expect(loaded.activeMood, 'happy');
});
test('心情筛选失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByMood(Mood.happy));
expect(state, isA<SearchError>());
});
});
// ===== 标签搜索 =====
group('SearchByTag', () {
test('按标签筛选日记', () async {
final journals = [
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
];
when(() => mockJournalRepo.getJournals(tag: '旅行', page: 1, pageSize: 50))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByTag('旅行'));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 1);
expect(loaded.activeTag, '旅行');
expect(loaded.searchHistory, contains('旅行'));
});
test('标签搜索失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(tag: '不存在', page: 1, pageSize: 50))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByTag('不存在'));
expect(state, isA<SearchError>());
});
});
// ===== 清除搜索 =====
group('SearchClear', () {
test('清除搜索返回空结果', () async {
// 先执行一次搜索
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => [_makeJournal()]);
await dispatch(const SearchByKeyword('测试'));
// 清除
final state = await dispatch(const SearchClear());
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results, isEmpty);
expect(loaded.activeKeyword, isNull);
expect(loaded.activeMood, isNull);
expect(loaded.activeTag, isNull);
});
});
// ===== Tab 切换 =====
group('SearchTabChanged', () {
test('切换 tab 更新 activeTab', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('测试'));
final state = await dispatch(const SearchTabChanged(SearchResultTab.journal));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).activeTab, SearchResultTab.journal);
});
test('非 SearchLoaded 状态下切换 tab 无效', () async {
// 初始状态 SearchInitial不响应 tab 切换
final state = await dispatch(const SearchTabChanged(SearchResultTab.tag));
expect(state, isA<SearchInitial>());
});
});
// ===== hasActiveFilter =====
group('SearchLoaded.hasActiveFilter', () {
test('无筛选条件时 hasActiveFilter 为 false', () async {
final state = await dispatch(const SearchClear());
expect((state as SearchLoaded).hasActiveFilter, isFalse);
});
test('有关键词时 hasActiveFilter 为 true', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
final state = await dispatch(const SearchByKeyword('测试'));
expect((state as SearchLoaded).hasActiveFilter, isTrue);
});
});
}

View File

@@ -1,7 +1,8 @@
{
"name": "web",
"name": "nuanji-admin",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"description": "暖记管理后台 — 班级管理·日记审核·成长追踪",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,14 +1,13 @@
@import "tailwindcss";
/* ====================================================================
* ERP Platform — Design System Tokens & Global Styles
* Soft UI Evolution: Professional, warm, accessible for all industries
* Generated by UI UX Pro Max
* 暖记管理后台 — Design System Tokens & Global Styles
* 温暖治愈风格 · 手账日记管理 · Soft UI Evolution
* ==================================================================== */
/* --- Design Tokens (CSS Custom Properties) --- */
:root {
/* Primary Palette — Trust Blue */
/* Primary Palette — 珊瑚暖色 (warm 主题为默认,:root 为基线) */
--erp-primary: #2563eb;
--erp-primary-hover: #1d4ed8;
--erp-primary-active: #1e40af;
@@ -69,7 +68,7 @@
--erp-space-xl: 32px;
--erp-space-2xl: 48px;
/* Typography — Noto Sans SC for Chinese-first ERP */
/* Typography — Noto Sans SC for Chinese-first 暖记 */
--erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;

View File

@@ -142,7 +142,7 @@ export default function PluginMarket() {
<Title level={3} style={{ marginBottom: 8 }}>
<AppstoreOutlined />
</Title>
<Text type="secondary"> ERP </Text>
<Text type="secondary"></Text>
</div>
{/* 搜索和分类 */}