整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -1,10 +1,13 @@
/// 单元内容包(assets/courses)的离线校验,规则见 Doc/COURSE-PACK-JSON.md
/// 与《学习引擎规格》5.4–5.6。只依赖 dart:core,供命令行工具和测试共用。
/// 与《学习引擎规格》5.4–5.6。供命令行工具和测试共用;match 的判定直接用
/// App 的 answer_rules.dart,校验结果和运行时判分一致。
library;
import 'dart:convert';
import 'dart:io';
import 'package:kouyu_english/core/courses/answer_rules.dart' show matchesRule;
const levelOrder = ['A0', 'A1', 'A2', 'B1', 'B2'];
int levelRank(String level) {
@@ -241,21 +244,16 @@ String normalizeForMatch(String text) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}
bool containsTerm(String normalizedText, String term) {
final normalizedTerm = normalizeForMatch(term);
if (normalizedTerm.isEmpty) return false;
/// 规范化后的 [normalizedText] 是否以完整词语的形式含有 [phrase],用于
/// 查找选项在原文里是否出现;match 规则的判定用 answer_rules.dart。
bool containsPhrase(String normalizedText, String phrase) {
final normalizedPhrase = normalizeForMatch(phrase);
if (normalizedPhrase.isEmpty) return false;
return RegExp(
'(^| )${RegExp.escape(normalizedTerm)}(\$| )',
'(^| )${RegExp.escape(normalizedPhrase)}(\$| )',
).hasMatch(normalizedText);
}
bool satisfiesMatch(String text, List<List<String>> groups) {
final normalized = normalizeForMatch(text);
return groups.every(
(group) => group.any((term) => containsTerm(normalized, term)),
);
}
// ------------------------------------------------------------ 词表
const functionPos = {
@@ -451,6 +449,9 @@ class CourseValidator {
for (final level in (map['levels'] as List).cast<Map<String, dynamic>>()) {
final levelId = level['id'] as String;
// A0 is the frozen baseline: its words are passed in as `a0Texts`, and
// it has no word pool or unit contract of the later levels.
if (levelId == 'A0') continue;
final functionWords = <String>{...lexicon.functionWordsUpTo(levelId)};
var ready = true;
for (final entry in (level['units'] as List).cast<Map<String, dynamic>>()) {
@@ -723,7 +724,7 @@ class _UnitContext {
_addWords(unitTaught, en);
_addWords(unitCovered, en);
final match = _match(item, id);
if (en.isNotEmpty && match.isNotEmpty && !satisfiesMatch(en, match)) {
if (en.isNotEmpty && match.isNotEmpty && !matchesRule(en, match)) {
error(id, 'en 本身不满足 match');
}
final example = item['example'];
@@ -732,7 +733,7 @@ class _UnitContext {
} else {
final exampleEn = _string(example, 'en', '$id.example');
_string(example, 'zh', '$id.example');
if (match.isNotEmpty && !satisfiesMatch(exampleEn, match)) {
if (match.isNotEmpty && !matchesRule(exampleEn, match)) {
error(id, 'example.en 不满足 match');
}
}
@@ -907,9 +908,9 @@ class _UnitContext {
}
if (sourceText != null) {
final text = normalizeForMatch(sourceText);
final present = normalized.where((option) => containsTerm(text, option)).length;
final present = normalized.where((option) => containsPhrase(text, option)).length;
if (present < 2) error(where, '英文选项至少两个要在原文里出现');
if (!containsTerm(text, normalized.first)) error(where, '正确选项没有在原文中出现');
if (!containsPhrase(text, normalized.first)) error(where, '正确选项没有在原文中出现');
}
}
}
@@ -969,7 +970,7 @@ class _UnitContext {
_checkText('$id.writing.example', example);
for (final ref in _itemRefs(writing, '$id.writing')) {
final item = core[ref];
if (item != null && !satisfiesMatch(example, _match(item, ref))) {
if (item != null && !matchesRule(example, _match(item, ref))) {
error('$id.writing', '示范没有用到 $ref');
}
}
@@ -989,7 +990,7 @@ class _UnitContext {
for (final itemId in itemIds) {
final item = core[itemId];
if (item == null) continue;
if (practice.any((text) => satisfiesMatch(text, _match(item, itemId)))) {
if (practice.any((text) => matchesRule(text, _match(item, itemId)))) {
usage[itemId]?.add('practice');
}
}
@@ -1029,7 +1030,7 @@ class _UnitContext {
for (final ref in _itemRefs(turn, where)) {
final item = core[ref];
if (item == null) continue;
if (satisfiesMatch(model, _match(item, ref))) {
if (matchesRule(model, _match(item, ref))) {
usage[ref]?.add('scene');
} else {
error(where, 'model 没有用到 $ref');