Add word-book practice, iOS app shell, and fix embedded WebView blank screen.

Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+609
View File
@@ -0,0 +1,609 @@
{
"slug": "hujiao-primary-1",
"title": "沪版小学英语 · 一年级",
"description": "牛津上海版一年级词汇(上下册)",
"level": "primary",
"sort_order": 11,
"daily_target": 11,
"master_required_count": 3,
"weak_wrong_threshold": 2,
"learn_mode": "sequential",
"units": [
{
"unit": 1,
"title": "一年级上册",
"words": [
{
"lemma_en": "morning",
"zh": "早晨",
"phonetic": "[ˈmɔːnɪŋ]",
"sort_index": 1
},
{
"lemma_en": "afternoon",
"zh": "午后",
"phonetic": "[ˌɑːftəˈnuːn]",
"sort_index": 2
},
{
"lemma_en": "book",
"zh": "书籍",
"phonetic": "[bʊk]",
"sort_index": 3
},
{
"lemma_en": "ruler",
"zh": "尺",
"phonetic": "[ˈruːlə(r)]",
"sort_index": 4
},
{
"lemma_en": "pencil",
"zh": "铅笔",
"phonetic": "[ˈpens(ə)l]",
"sort_index": 5
},
{
"lemma_en": "rubber",
"zh": "橡胶",
"phonetic": "[ˈrʌbə(r)]",
"sort_index": 6
},
{
"lemma_en": "eye",
"zh": "眼睛",
"phonetic": "[aɪ]",
"sort_index": 7
},
{
"lemma_en": "mouth",
"zh": "口",
"phonetic": "[maʊθ]",
"sort_index": 8
},
{
"lemma_en": "face",
"zh": "脸",
"phonetic": "[feɪs]",
"sort_index": 9
},
{
"lemma_en": "nose",
"zh": "鼻子",
"phonetic": "[nəʊz]",
"sort_index": 10
},
{
"lemma_en": "ear",
"zh": "耳朵",
"phonetic": "[ɪə(r)]",
"sort_index": 11
},
{
"lemma_en": "dance",
"zh": "舞蹈",
"phonetic": "[dɑːns]",
"sort_index": 12
},
{
"lemma_en": "read",
"zh": "阅读",
"phonetic": "[riːd]",
"sort_index": 13
},
{
"lemma_en": "sing",
"zh": "演唱",
"phonetic": "[sɪŋ]",
"sort_index": 14
},
{
"lemma_en": "draw",
"zh": "平局",
"phonetic": "[drɔː]",
"sort_index": 15
},
{
"lemma_en": "grandfather",
"zh": "祖父",
"phonetic": "[ˈɡrænfɑːðə(r)]",
"sort_index": 16
},
{
"lemma_en": "grandmother",
"zh": "祖母",
"phonetic": "[ˈɡrænmʌðə(r)]",
"sort_index": 17
},
{
"lemma_en": "father",
"zh": "父亲",
"phonetic": "[ˈfɑːðə(r)]",
"sort_index": 18
},
{
"lemma_en": "mother",
"zh": "母亲",
"phonetic": "[ˈmʌðə(r)]",
"sort_index": 19
},
{
"lemma_en": "me",
"zh": "我(宾格)",
"phonetic": "[mi; miː]",
"sort_index": 20
},
{
"lemma_en": "fat",
"zh": "脂肪",
"phonetic": "[fæt]",
"sort_index": 21
},
{
"lemma_en": "thin",
"zh": "细小部分",
"phonetic": "[θɪn]",
"sort_index": 22
},
{
"lemma_en": "tall",
"zh": "高的",
"phonetic": "[tɔːl]",
"sort_index": 23
},
{
"lemma_en": "short",
"zh": "短",
"phonetic": "[ʃɔːt]",
"sort_index": 24
},
{
"lemma_en": "one",
"zh": "一个人",
"phonetic": "[wʌn]",
"sort_index": 25
},
{
"lemma_en": "two",
"zh": "二",
"phonetic": "[tuː]",
"sort_index": 26
},
{
"lemma_en": "three",
"zh": "三",
"phonetic": "[θriː]",
"sort_index": 27
},
{
"lemma_en": "four",
"zh": "四",
"phonetic": "[fɔː(r)]",
"sort_index": 28
},
{
"lemma_en": "five",
"zh": "五",
"phonetic": "[faɪv]",
"sort_index": 29
},
{
"lemma_en": "six",
"zh": "六",
"phonetic": "[sɪks]",
"sort_index": 30
},
{
"lemma_en": "apple",
"zh": "苹果",
"phonetic": "[ˈæp(ə)l]",
"sort_index": 31
},
{
"lemma_en": "pear",
"zh": "[园艺] 梨树",
"phonetic": "[peə(r)]",
"sort_index": 32
},
{
"lemma_en": "peach",
"zh": "桃子",
"phonetic": "[piːtʃ]",
"sort_index": 33
},
{
"lemma_en": "orange",
"zh": "橙色的",
"phonetic": "[ˈɒrɪndʒ]",
"sort_index": 34
},
{
"lemma_en": "hamburger",
"zh": "汉堡包",
"phonetic": "[ˈhæmbɜːɡə(r)]",
"sort_index": 35
},
{
"lemma_en": "pizza",
"zh": "比萨饼(一种涂有乳酪核番茄酱的意大利式有馅烘饼)",
"phonetic": "[ˈpiːtsə]",
"sort_index": 36
},
{
"lemma_en": "cake",
"zh": "蛋糕",
"phonetic": "[keɪk]",
"sort_index": 37
},
{
"lemma_en": "pie",
"zh": "馅饼",
"phonetic": "[paɪ]",
"sort_index": 38
},
{
"lemma_en": "chick",
"zh": "小鸡",
"phonetic": "[tʃɪk]",
"sort_index": 39
},
{
"lemma_en": "duck",
"zh": "鸭子",
"phonetic": "[dʌk]",
"sort_index": 40
},
{
"lemma_en": "cow",
"zh": "奶牛",
"phonetic": "[kaʊ]",
"sort_index": 41
},
{
"lemma_en": "pig",
"zh": "猪",
"phonetic": "[pɪɡ]",
"sort_index": 42
},
{
"lemma_en": "bear",
"zh": "熊",
"phonetic": "[beə(r)]",
"sort_index": 43
},
{
"lemma_en": "tiger",
"zh": "老虎",
"phonetic": "[ˈtaɪɡə(r)]",
"sort_index": 44
},
{
"lemma_en": "monkey",
"zh": "猴子",
"phonetic": "[ˈmʌŋki]",
"sort_index": 45
},
{
"lemma_en": "panda",
"zh": "熊猫",
"phonetic": "[ˈpændə]",
"sort_index": 46
},
{
"lemma_en": "red",
"zh": "红色",
"phonetic": "[red]",
"sort_index": 47
},
{
"lemma_en": "blue",
"zh": "蓝色的",
"phonetic": "[bluː]",
"sort_index": 48
},
{
"lemma_en": "yellow",
"zh": "黄色",
"phonetic": "[ˈjeləʊ]",
"sort_index": 49
},
{
"lemma_en": "green",
"zh": "绿色",
"phonetic": "[ɡriːn]",
"sort_index": 50
}
]
},
{
"unit": 2,
"title": "一年级下册",
"words": [
{
"lemma_en": "frog",
"zh": "青蛙",
"phonetic": "[frɒɡ]",
"sort_index": 51
},
{
"lemma_en": "rabbit",
"zh": "兔子",
"phonetic": "[ˈræbɪt]",
"sort_index": 52
},
{
"lemma_en": "bee",
"zh": "蜜蜂",
"phonetic": "[biː]",
"sort_index": 53
},
{
"lemma_en": "bird",
"zh": "鸟",
"phonetic": "[bɜːd]",
"sort_index": 54
},
{
"lemma_en": "sheep",
"zh": "羊",
"phonetic": "[ʃiːp]",
"sort_index": 55
},
{
"lemma_en": "hen",
"zh": "母鸡",
"phonetic": "[hen]",
"sort_index": 56
},
{
"lemma_en": "dog",
"zh": "狗",
"phonetic": "[dɒɡ]",
"sort_index": 57
},
{
"lemma_en": "cat",
"zh": "猫",
"phonetic": "[kæt]",
"sort_index": 58
},
{
"lemma_en": "rice",
"zh": "稻",
"phonetic": "[raɪs]",
"sort_index": 59
},
{
"lemma_en": "soup",
"zh": "汤",
"phonetic": "[suːp]",
"sort_index": 60
},
{
"lemma_en": "egg",
"zh": "蛋",
"phonetic": "[eɡ]",
"sort_index": 61
},
{
"lemma_en": "noodles",
"zh": "面条",
"phonetic": "[ˈnuːd(ə)lz]",
"sort_index": 62
},
{
"lemma_en": "ball",
"zh": "球",
"phonetic": "[bɔːl]",
"sort_index": 63
},
{
"lemma_en": "doll",
"zh": "洋娃娃",
"phonetic": "[dɒl]",
"sort_index": 64
},
{
"lemma_en": "bicycle",
"zh": "自行车",
"phonetic": "[ˈbaɪsɪk(ə)l]",
"sort_index": 65
},
{
"lemma_en": "kite",
"zh": "风筝",
"phonetic": "[kaɪt]",
"sort_index": 66
},
{
"lemma_en": "jelly",
"zh": "果冻",
"phonetic": "[ˈdʒeli]",
"sort_index": 67
},
{
"lemma_en": "ice cream",
"zh": "【食】冰淇淋",
"phonetic": "[ˈaɪs kriːm]",
"sort_index": 68
},
{
"lemma_en": "sweet",
"zh": "糖果",
"phonetic": "[swiːt]",
"sort_index": 69
},
{
"lemma_en": "biscuit",
"zh": "小点心",
"phonetic": "[ˈbɪskɪt]",
"sort_index": 70
},
{
"lemma_en": "cola",
"zh": "可乐",
"phonetic": "[ˈkəʊlə]",
"sort_index": 71
},
{
"lemma_en": "juice",
"zh": "(水果)汁",
"phonetic": "[dʒuːs]",
"sort_index": 72
},
{
"lemma_en": "milk",
"zh": "牛奶",
"phonetic": "[mɪlk]",
"sort_index": 73
},
{
"lemma_en": "water",
"zh": "水",
"phonetic": "[ˈwɔːtə(r)]",
"sort_index": 74
},
{
"lemma_en": "warm",
"zh": "取暖",
"phonetic": "[wɔːm]",
"sort_index": 75
},
{
"lemma_en": "hot",
"zh": "热的",
"phonetic": "[hɒt]",
"sort_index": 76
},
{
"lemma_en": "spring",
"zh": "春天",
"phonetic": "[sprɪŋ]",
"sort_index": 77
},
{
"lemma_en": "summer",
"zh": "夏季",
"phonetic": "[ˈsʌmə(r)]",
"sort_index": 78
},
{
"lemma_en": "sunny",
"zh": "阳光充足的",
"phonetic": "[ˈsʌni]",
"sort_index": 79
},
{
"lemma_en": "cloudy",
"zh": "多云的",
"phonetic": "[ˈklaʊdi]",
"sort_index": 80
},
{
"lemma_en": "rainy",
"zh": "下雨的",
"phonetic": "[ˈreɪni]",
"sort_index": 81
},
{
"lemma_en": "windy",
"zh": "多风的",
"phonetic": "[ˈwɪndi]",
"sort_index": 82
},
{
"lemma_en": "t-shirt",
"zh": "短袖圆领汗衫",
"phonetic": "[ˈtiː ʃɜːt]",
"sort_index": 83
},
{
"lemma_en": "dress",
"zh": "连衣裙",
"phonetic": "[dres]",
"sort_index": 84
},
{
"lemma_en": "shorts",
"zh": "短裤",
"phonetic": "[ʃɔːts]",
"sort_index": 85
},
{
"lemma_en": "blouse",
"zh": "宽松的上衣",
"phonetic": "[blaʊz]",
"sort_index": 86
},
{
"lemma_en": "ride",
"zh": "骑",
"phonetic": "[raɪd]",
"sort_index": 87
},
{
"lemma_en": "skip",
"zh": "跳跃",
"phonetic": "[skɪp]",
"sort_index": 88
},
{
"lemma_en": "play",
"zh": "游戏",
"phonetic": "[pleɪ]",
"sort_index": 89
},
{
"lemma_en": "fly",
"zh": "飞行",
"phonetic": "[flaɪ]",
"sort_index": 90
},
{
"lemma_en": "gift",
"zh": "礼物",
"phonetic": "[ɡɪft]",
"sort_index": 91
},
{
"lemma_en": "card",
"zh": "卡片",
"phonetic": "[kɑːd]",
"sort_index": 92
},
{
"lemma_en": "firecracker",
"zh": "鞭炮",
"phonetic": "[ˈfaɪəkrækə(r)]",
"sort_index": 93
},
{
"lemma_en": "firework",
"zh": "烟火",
"phonetic": "[ˈfaɪəwɜːk]",
"sort_index": 94
},
{
"lemma_en": "boy",
"zh": "男孩",
"phonetic": "[bɔɪ]",
"sort_index": 95
},
{
"lemma_en": "wolf",
"zh": "狼",
"phonetic": "[wʊlf]",
"sort_index": 96
},
{
"lemma_en": "farmer",
"zh": "农夫",
"phonetic": "[ˈfɑːmə(r)]",
"sort_index": 97
}
]
}
],
"word_count": 97,
"unit_count": 2
}
+591
View File
@@ -0,0 +1,591 @@
{
"slug": "hujiao-primary-2",
"title": "沪版小学英语 · 二年级",
"description": "牛津上海版二年级词汇(上下册)",
"level": "primary",
"sort_order": 12,
"daily_target": 12,
"master_required_count": 3,
"weak_wrong_threshold": 2,
"learn_mode": "sequential",
"units": [
{
"unit": 1,
"title": "二年级上册",
"words": [
{
"lemma_en": "morning",
"zh": "早晨",
"phonetic": "[ˈmɔːnɪŋ]",
"sort_index": 1
},
{
"lemma_en": "afternoon",
"zh": "午后",
"phonetic": "[ˌɑːftəˈnuːn]",
"sort_index": 2
},
{
"lemma_en": "evening",
"zh": "晚上",
"phonetic": "[ˈiːvnɪŋ]",
"sort_index": 3
},
{
"lemma_en": "night",
"zh": "夜晚",
"phonetic": "[naɪt]",
"sort_index": 4
},
{
"lemma_en": "boy",
"zh": "男孩",
"phonetic": "[bɔɪ]",
"sort_index": 5
},
{
"lemma_en": "girl",
"zh": "女孩",
"phonetic": "[ɡɜːl]",
"sort_index": 6
},
{
"lemma_en": "big",
"zh": "大的",
"phonetic": "[bɪɡ]",
"sort_index": 7
},
{
"lemma_en": "small",
"zh": "小件物品",
"phonetic": "[smɔːl]",
"sort_index": 8
},
{
"lemma_en": "seven",
"zh": "七个",
"phonetic": "[ˈsev(ə)n]",
"sort_index": 9
},
{
"lemma_en": "eight",
"zh": "八",
"phonetic": "[eɪt]",
"sort_index": 10
},
{
"lemma_en": "nine",
"zh": "九",
"phonetic": "[naɪn]",
"sort_index": 11
},
{
"lemma_en": "ten",
"zh": "十个",
"phonetic": "[ten]",
"sort_index": 12
},
{
"lemma_en": "run",
"zh": "奔跑",
"phonetic": "[rʌn]",
"sort_index": 13
},
{
"lemma_en": "write",
"zh": "写",
"phonetic": "[raɪt]",
"sort_index": 14
},
{
"lemma_en": "swim",
"zh": "游泳",
"phonetic": "[swɪm]",
"sort_index": 15
},
{
"lemma_en": "fly",
"zh": "飞行",
"phonetic": "[flaɪ]",
"sort_index": 16
},
{
"lemma_en": "young",
"zh": "年轻人",
"phonetic": "[jʌŋ]",
"sort_index": 17
},
{
"lemma_en": "old",
"zh": "陈旧的",
"phonetic": "[əʊld]",
"sort_index": 18
},
{
"lemma_en": "hair",
"zh": "头发",
"phonetic": "[heə(r)]",
"sort_index": 19
},
{
"lemma_en": "head",
"zh": "头",
"phonetic": "[hed]",
"sort_index": 20
},
{
"lemma_en": "face",
"zh": "脸",
"phonetic": "[feɪs]",
"sort_index": 21
},
{
"lemma_en": "slide",
"zh": "滑动",
"phonetic": "[slaɪd]",
"sort_index": 22
},
{
"lemma_en": "swing",
"zh": "摇摆",
"phonetic": "[swɪŋ]",
"sort_index": 23
},
{
"lemma_en": "seesaw",
"zh": "跷跷板",
"phonetic": "[ˈsiːsɔː]",
"sort_index": 24
},
{
"lemma_en": "bag",
"zh": "袋",
"phonetic": "[bæɡ]",
"sort_index": 25
},
{
"lemma_en": "box",
"zh": "箱",
"phonetic": "[bɒks]",
"sort_index": 26
},
{
"lemma_en": "desk",
"zh": "办公桌",
"phonetic": "[desk]",
"sort_index": 27
},
{
"lemma_en": "chair",
"zh": "椅子",
"phonetic": "[tʃeə(r)]",
"sort_index": 28
},
{
"lemma_en": "bowl",
"zh": "碗",
"phonetic": "[bəʊl]",
"sort_index": 29
},
{
"lemma_en": "plate",
"zh": "碟",
"phonetic": "[pleɪt]",
"sort_index": 30
},
{
"lemma_en": "spoon",
"zh": "匙",
"phonetic": "[spuːn]",
"sort_index": 31
},
{
"lemma_en": "chopsticks",
"zh": "筷子",
"phonetic": "[ˈtʃɒpstɪks]",
"sort_index": 32
},
{
"lemma_en": "sun",
"zh": "太阳",
"phonetic": "[sʌn]",
"sort_index": 33
},
{
"lemma_en": "moon",
"zh": "月亮",
"phonetic": "[muːn]",
"sort_index": 34
},
{
"lemma_en": "star",
"zh": "星",
"phonetic": "[stɑː(r)]",
"sort_index": 35
},
{
"lemma_en": "fox",
"zh": "狐狸",
"phonetic": "[fɒks]",
"sort_index": 36
},
{
"lemma_en": "hippo",
"zh": "河马",
"phonetic": "[ˈhɪpəʊ]",
"sort_index": 37
},
{
"lemma_en": "meat",
"zh": "肉",
"phonetic": "[miːt]",
"sort_index": 38
},
{
"lemma_en": "grass",
"zh": "草",
"phonetic": "[ɡrɑːs]",
"sort_index": 39
},
{
"lemma_en": "flower",
"zh": "花",
"phonetic": "[ˈflaʊə(r)]",
"sort_index": 40
},
{
"lemma_en": "tree",
"zh": "树",
"phonetic": "[triː]",
"sort_index": 41
}
]
},
{
"unit": 2,
"title": "二年级下册",
"words": [
{
"lemma_en": "white",
"zh": "白色",
"phonetic": "[waɪt]",
"sort_index": 42
},
{
"lemma_en": "purple",
"zh": "紫色",
"phonetic": "[ˈpɜːp(ə)l]",
"sort_index": 43
},
{
"lemma_en": "pink",
"zh": "粉红色",
"phonetic": "[pɪŋk]",
"sort_index": 44
},
{
"lemma_en": "orange",
"zh": "橙色的",
"phonetic": "[ˈɒrɪndʒ]",
"sort_index": 45
},
{
"lemma_en": "brown",
"zh": "褐色",
"phonetic": "[braʊn]",
"sort_index": 46
},
{
"lemma_en": "black",
"zh": "黑色",
"phonetic": "[blæk]",
"sort_index": 47
},
{
"lemma_en": "watch",
"zh": "手表",
"phonetic": "[wɒtʃ]",
"sort_index": 48
},
{
"lemma_en": "soft",
"zh": "柔性",
"phonetic": "[sɒft]",
"sort_index": 49
},
{
"lemma_en": "hard",
"zh": "努力的",
"phonetic": "[hɑːd]",
"sort_index": 50
},
{
"lemma_en": "rough",
"zh": "艰苦",
"phonetic": "[rʌf]",
"sort_index": 51
},
{
"lemma_en": "smooth",
"zh": "平滑部分",
"phonetic": "[smuːð]",
"sort_index": 52
},
{
"lemma_en": "train",
"zh": "火车",
"phonetic": "[treɪn]",
"sort_index": 53
},
{
"lemma_en": "bus",
"zh": "公共汽车",
"phonetic": "[bʌs]",
"sort_index": 54
},
{
"lemma_en": "car",
"zh": "汽车",
"phonetic": "[kɑː(r)]",
"sort_index": 55
},
{
"lemma_en": "van",
"zh": "先锋",
"phonetic": "[væn]",
"sort_index": 56
},
{
"lemma_en": "bicycle",
"zh": "自行车",
"phonetic": "[ˈbaɪsɪk(ə)l]",
"sort_index": 57
},
{
"lemma_en": "ship",
"zh": "船",
"phonetic": "[ʃɪp]",
"sort_index": 58
},
{
"lemma_en": "skate",
"zh": "溜冰",
"phonetic": "[skeɪt]",
"sort_index": 59
},
{
"lemma_en": "hop",
"zh": "搭乘",
"phonetic": "[hɒp]",
"sort_index": 60
},
{
"lemma_en": "skip",
"zh": "跳跃",
"phonetic": "[skɪp]",
"sort_index": 61
},
{
"lemma_en": "ride a bicycle",
"zh": "骑自行车",
"phonetic": null,
"sort_index": 62
},
{
"lemma_en": "salad",
"zh": "色拉",
"phonetic": "[ˈsæləd]",
"sort_index": 63
},
{
"lemma_en": "carrot",
"zh": "胡萝卜",
"phonetic": "[ˈkærət]",
"sort_index": 64
},
{
"lemma_en": "fish",
"zh": "鱼",
"phonetic": "[fɪʃ]",
"sort_index": 65
},
{
"lemma_en": "chicken",
"zh": "鸡肉",
"phonetic": "[ˈtʃɪkɪn]",
"sort_index": 66
},
{
"lemma_en": "banana",
"zh": "香蕉",
"phonetic": "[bəˈnɑːnə]",
"sort_index": 67
},
{
"lemma_en": "giraffe",
"zh": "长颈鹿",
"phonetic": "[dʒəˈrɑːf]",
"sort_index": 68
},
{
"lemma_en": "snake",
"zh": "蛇",
"phonetic": "[sneɪk]",
"sort_index": 69
},
{
"lemma_en": "elephant",
"zh": "象",
"phonetic": "[ˈelɪfənt]",
"sort_index": 70
},
{
"lemma_en": "zebra",
"zh": "[脊椎] 斑马",
"phonetic": "[ˈzebrə]",
"sort_index": 71
},
{
"lemma_en": "autumn",
"zh": "秋天",
"phonetic": "[ˈɔːtəm]",
"sort_index": 72
},
{
"lemma_en": "winter",
"zh": "冬季",
"phonetic": "[ˈwɪntə(r)]",
"sort_index": 73
},
{
"lemma_en": "cool",
"zh": "凉爽",
"phonetic": "[kuːl]",
"sort_index": 74
},
{
"lemma_en": "cold",
"zh": "寒冷",
"phonetic": "[kəʊld]",
"sort_index": 75
},
{
"lemma_en": "stop",
"zh": "停止",
"phonetic": "[stɒp]",
"sort_index": 76
},
{
"lemma_en": "go",
"zh": "去",
"phonetic": "[ɡəʊ]",
"sort_index": 77
},
{
"lemma_en": "wait",
"zh": "等待",
"phonetic": "[weɪt]",
"sort_index": 78
},
{
"lemma_en": "light",
"zh": "光",
"phonetic": "[laɪt]",
"sort_index": 79
},
{
"lemma_en": "trousers",
"zh": "裤子",
"phonetic": "[ˈtraʊzəz]",
"sort_index": 80
},
{
"lemma_en": "sweater",
"zh": "毛线衣",
"phonetic": "[ˈswetə(r)]",
"sort_index": 81
},
{
"lemma_en": "shirt",
"zh": "衬衫",
"phonetic": "[ʃɜːt]",
"sort_index": 82
},
{
"lemma_en": "coat",
"zh": "外套",
"phonetic": "[kəʊt]",
"sort_index": 83
},
{
"lemma_en": "play football",
"zh": "踢足球",
"phonetic": null,
"sort_index": 84
},
{
"lemma_en": "play basketball",
"zh": "打篮球",
"phonetic": null,
"sort_index": 85
},
{
"lemma_en": "play ping-pong",
"zh": "打乒乓球",
"phonetic": null,
"sort_index": 86
},
{
"lemma_en": "play cards",
"zh": "打纸牌",
"phonetic": null,
"sort_index": 87
},
{
"lemma_en": "letter",
"zh": "信",
"phonetic": "[ˈletə(r)]",
"sort_index": 88
},
{
"lemma_en": "balloon",
"zh": "气球",
"phonetic": "[bəˈluːn]",
"sort_index": 89
},
{
"lemma_en": "carnation",
"zh": "[园艺] 康乃馨",
"phonetic": "[kɑːˈneɪʃn]",
"sort_index": 90
},
{
"lemma_en": "hungry",
"zh": "饥饿的",
"phonetic": "[ˈhʌŋɡri]",
"sort_index": 91
},
{
"lemma_en": "thirsty",
"zh": "口渴的",
"phonetic": "[ˈθɜːsti]",
"sort_index": 92
},
{
"lemma_en": "bread",
"zh": "面包",
"phonetic": "[bred]",
"sort_index": 93
},
{
"lemma_en": "milk",
"zh": "牛奶",
"phonetic": "[mɪlk]",
"sort_index": 94
}
]
}
],
"word_count": 94,
"unit_count": 2
}
+915
View File
@@ -0,0 +1,915 @@
{
"slug": "hujiao-primary-3",
"title": "沪版小学英语 · 三年级",
"description": "牛津上海版三年级词汇(上下册)",
"level": "primary",
"sort_order": 13,
"daily_target": 13,
"master_required_count": 3,
"weak_wrong_threshold": 2,
"learn_mode": "sequential",
"units": [
{
"unit": 1,
"title": "三年级上册",
"words": [
{
"lemma_en": "miss",
"zh": "女士",
"phonetic": null,
"sort_index": 1
},
{
"lemma_en": "mr",
"zh": "先生(Mister)",
"phonetic": "[ˈmɪstə(r)]",
"sort_index": 2
},
{
"lemma_en": "mrs",
"zh": "太太",
"phonetic": "[ˈmɪsɪz]",
"sort_index": 3
},
{
"lemma_en": "stand up",
"zh": "站起来",
"phonetic": "[ˈstænd ʌp]",
"sort_index": 4
},
{
"lemma_en": "sit down",
"zh": "坐下",
"phonetic": "[ˈsɪt daʊn]",
"sort_index": 5
},
{
"lemma_en": "open",
"zh": "公开的",
"phonetic": "[ˈəʊpən]",
"sort_index": 6
},
{
"lemma_en": "close",
"zh": "结束",
"phonetic": "[kləʊz]",
"sort_index": 7
},
{
"lemma_en": "door",
"zh": "门",
"phonetic": "[dɔː(r)]",
"sort_index": 8
},
{
"lemma_en": "name",
"zh": "名称",
"phonetic": "[neɪm]",
"sort_index": 9
},
{
"lemma_en": "book",
"zh": "书籍",
"phonetic": "[bʊk]",
"sort_index": 10
},
{
"lemma_en": "write",
"zh": "写",
"phonetic": "[raɪt]",
"sort_index": 11
},
{
"lemma_en": "one",
"zh": "一个人",
"phonetic": "[wʌn]",
"sort_index": 12
},
{
"lemma_en": "two",
"zh": "二",
"phonetic": "[tuː]",
"sort_index": 13
},
{
"lemma_en": "three",
"zh": "三",
"phonetic": "[θriː]",
"sort_index": 14
},
{
"lemma_en": "four",
"zh": "四",
"phonetic": "[fɔː(r)]",
"sort_index": 15
},
{
"lemma_en": "five",
"zh": "五",
"phonetic": "[faɪv]",
"sort_index": 16
},
{
"lemma_en": "six",
"zh": "六",
"phonetic": "[sɪks]",
"sort_index": 17
},
{
"lemma_en": "seven",
"zh": "七个",
"phonetic": "[ˈsev(ə)n]",
"sort_index": 18
},
{
"lemma_en": "eight",
"zh": "八",
"phonetic": "[eɪt]",
"sort_index": 19
},
{
"lemma_en": "nine",
"zh": "九",
"phonetic": "[naɪn]",
"sort_index": 20
},
{
"lemma_en": "ten",
"zh": "十个",
"phonetic": "[ten]",
"sort_index": 21
},
{
"lemma_en": "today",
"zh": "今天",
"phonetic": "[təˈdeɪ]",
"sort_index": 22
},
{
"lemma_en": "fat",
"zh": "脂肪",
"phonetic": "[fæt]",
"sort_index": 23
},
{
"lemma_en": "thin",
"zh": "细小部分",
"phonetic": "[θɪn]",
"sort_index": 24
},
{
"lemma_en": "tall",
"zh": "高的",
"phonetic": "[tɔːl]",
"sort_index": 25
},
{
"lemma_en": "short",
"zh": "短",
"phonetic": "[ʃɔːt]",
"sort_index": 26
},
{
"lemma_en": "boy",
"zh": "男孩",
"phonetic": "[bɔɪ]",
"sort_index": 27
},
{
"lemma_en": "girl",
"zh": "女孩",
"phonetic": "[ɡɜːl]",
"sort_index": 28
},
{
"lemma_en": "friend",
"zh": "朋友",
"phonetic": "[frend]",
"sort_index": 29
},
{
"lemma_en": "grandfather",
"zh": "祖父",
"phonetic": "[ˈɡrænfɑːðə(r)]",
"sort_index": 30
},
{
"lemma_en": "grandmother",
"zh": "祖母",
"phonetic": "[ˈɡrænmʌðə(r)]",
"sort_index": 31
},
{
"lemma_en": "father",
"zh": "父亲",
"phonetic": "[ˈfɑːðə(r)]",
"sort_index": 32
},
{
"lemma_en": "mother",
"zh": "母亲",
"phonetic": "[ˈmʌðə(r)]",
"sort_index": 33
},
{
"lemma_en": "brother",
"zh": "兄弟",
"phonetic": "[ˈbrʌðə(r)]",
"sort_index": 34
},
{
"lemma_en": "sister",
"zh": "姐妹",
"phonetic": "[ˈsɪstə(r)]",
"sort_index": 35
},
{
"lemma_en": "me",
"zh": "我(宾格)",
"phonetic": "[mi; miː]",
"sort_index": 36
},
{
"lemma_en": "eye",
"zh": "眼睛",
"phonetic": "[aɪ]",
"sort_index": 37
},
{
"lemma_en": "nose",
"zh": "鼻子",
"phonetic": "[nəʊz]",
"sort_index": 38
},
{
"lemma_en": "hair",
"zh": "头发",
"phonetic": "[heə(r)]",
"sort_index": 39
},
{
"lemma_en": "ear",
"zh": "耳朵",
"phonetic": "[ɪə(r)]",
"sort_index": 40
},
{
"lemma_en": "mouth",
"zh": "口",
"phonetic": "[maʊθ]",
"sort_index": 41
},
{
"lemma_en": "small",
"zh": "小件物品",
"phonetic": "[smɔːl]",
"sort_index": 42
},
{
"lemma_en": "long",
"zh": "长时间",
"phonetic": "[lɒŋ]",
"sort_index": 43
},
{
"lemma_en": "big",
"zh": "大的",
"phonetic": "[bɪɡ]",
"sort_index": 44
},
{
"lemma_en": "school",
"zh": "学校",
"phonetic": "[skuːl]",
"sort_index": 45
},
{
"lemma_en": "library",
"zh": "图书馆",
"phonetic": "[ˈlaɪbrəri; ˈlaɪbri]",
"sort_index": 46
},
{
"lemma_en": "toilet",
"zh": "厕所",
"phonetic": "[ˈtɔɪlət]",
"sort_index": 47
},
{
"lemma_en": "hall",
"zh": "过道",
"phonetic": "[hɔːl]",
"sort_index": 48
},
{
"lemma_en": "playground",
"zh": "运动场",
"phonetic": "[ˈpleɪɡraʊnd]",
"sort_index": 49
},
{
"lemma_en": "classroom",
"zh": "教室",
"phonetic": "[ˈklɑːsruːm]",
"sort_index": 50
},
{
"lemma_en": "apple",
"zh": "苹果",
"phonetic": "[ˈæp(ə)l]",
"sort_index": 51
},
{
"lemma_en": "orange",
"zh": "橙色的",
"phonetic": "[ˈɒrɪndʒ]",
"sort_index": 52
},
{
"lemma_en": "banana",
"zh": "香蕉",
"phonetic": "[bəˈnɑːnə]",
"sort_index": 53
},
{
"lemma_en": "peach",
"zh": "桃子",
"phonetic": "[piːtʃ]",
"sort_index": 54
},
{
"lemma_en": "boat",
"zh": "小船",
"phonetic": "[bəʊt]",
"sort_index": 55
},
{
"lemma_en": "balloon",
"zh": "气球",
"phonetic": "[bəˈluːn]",
"sort_index": 56
},
{
"lemma_en": "flower",
"zh": "花",
"phonetic": "[ˈflaʊə(r)]",
"sort_index": 57
},
{
"lemma_en": "kite",
"zh": "风筝",
"phonetic": "[kaɪt]",
"sort_index": 58
},
{
"lemma_en": "red",
"zh": "红色",
"phonetic": "[red]",
"sort_index": 59
},
{
"lemma_en": "yellow",
"zh": "黄色",
"phonetic": "[ˈjeləʊ]",
"sort_index": 60
},
{
"lemma_en": "ladybird",
"zh": "瓢虫",
"phonetic": "[ˈleɪdibɜːd]",
"sort_index": 61
},
{
"lemma_en": "bee",
"zh": "蜜蜂",
"phonetic": "[biː]",
"sort_index": 62
},
{
"lemma_en": "butterfly",
"zh": "蝴蝶",
"phonetic": "[ˈbʌtəflaɪ]",
"sort_index": 63
},
{
"lemma_en": "ant",
"zh": "蚂蚁",
"phonetic": "[ænt]",
"sort_index": 64
},
{
"lemma_en": "fly",
"zh": "飞行",
"phonetic": "[flaɪ]",
"sort_index": 65
},
{
"lemma_en": "black",
"zh": "黑色",
"phonetic": "[blæk]",
"sort_index": 66
},
{
"lemma_en": "chick",
"zh": "小鸡",
"phonetic": "[tʃɪk]",
"sort_index": 67
},
{
"lemma_en": "hen",
"zh": "母鸡",
"phonetic": "[hen]",
"sort_index": 68
},
{
"lemma_en": "duck",
"zh": "鸭子",
"phonetic": "[dʌk]",
"sort_index": 69
},
{
"lemma_en": "pig",
"zh": "猪",
"phonetic": "[pɪɡ]",
"sort_index": 70
},
{
"lemma_en": "dog",
"zh": "狗",
"phonetic": "[dɒɡ]",
"sort_index": 71
},
{
"lemma_en": "cat",
"zh": "猫",
"phonetic": "[kæt]",
"sort_index": 72
},
{
"lemma_en": "leaf",
"zh": "叶子",
"phonetic": "[liːf]",
"sort_index": 73
},
{
"lemma_en": "leaves",
"zh": "树叶",
"phonetic": "[liːvz]",
"sort_index": 74
},
{
"lemma_en": "branch",
"zh": "树枝",
"phonetic": "[brɑːntʃ]",
"sort_index": 75
},
{
"lemma_en": "trunk",
"zh": "树干",
"phonetic": "[trʌŋk]",
"sort_index": 76
},
{
"lemma_en": "root",
"zh": "根",
"phonetic": "[ruːt]",
"sort_index": 77
},
{
"lemma_en": "plant",
"zh": "工厂",
"phonetic": "[plɑːnt]",
"sort_index": 78
},
{
"lemma_en": "green",
"zh": "绿色",
"phonetic": "[ɡriːn]",
"sort_index": 79
},
{
"lemma_en": "pink",
"zh": "粉红色",
"phonetic": "[pɪŋk]",
"sort_index": 80
},
{
"lemma_en": "beautiful",
"zh": "美丽的",
"phonetic": "[ˈbjuːtɪf(ə)l]",
"sort_index": 81
}
]
},
{
"unit": 2,
"title": "三年级下册",
"words": [
{
"lemma_en": "aeroplane",
"zh": "飞机(等于airplane)",
"phonetic": "[ˈeərəpleɪn]",
"sort_index": 82
},
{
"lemma_en": "bus",
"zh": "公共汽车",
"phonetic": "[bʌs]",
"sort_index": 83
},
{
"lemma_en": "ship",
"zh": "船",
"phonetic": "[ʃɪp]",
"sort_index": 84
},
{
"lemma_en": "car",
"zh": "汽车",
"phonetic": "[kɑː(r)]",
"sort_index": 85
},
{
"lemma_en": "pineapple",
"zh": "[园艺] 菠萝",
"phonetic": "[ˈpaɪnæp(ə)l]",
"sort_index": 86
},
{
"lemma_en": "cake",
"zh": "蛋糕",
"phonetic": "[keɪk]",
"sort_index": 87
},
{
"lemma_en": "bread",
"zh": "面包",
"phonetic": "[bred]",
"sort_index": 88
},
{
"lemma_en": "glass",
"zh": "玻璃",
"phonetic": "[ɡlɑːs]",
"sort_index": 89
},
{
"lemma_en": "bag",
"zh": "袋",
"phonetic": "[bæɡ]",
"sort_index": 90
},
{
"lemma_en": "sweet",
"zh": "糖果",
"phonetic": "[swiːt]",
"sort_index": 91
},
{
"lemma_en": "lemon",
"zh": "柠檬色的",
"phonetic": "[ˈlemən]",
"sort_index": 92
},
{
"lemma_en": "sour",
"zh": "酸味",
"phonetic": "[ˈsaʊə(r)]",
"sort_index": 93
},
{
"lemma_en": "salt",
"zh": "盐",
"phonetic": "[sɔːlt]",
"sort_index": 94
},
{
"lemma_en": "salty",
"zh": "咸的",
"phonetic": "[ˈsɔːlti]",
"sort_index": 95
},
{
"lemma_en": "coffee",
"zh": "咖啡",
"phonetic": "[ˈkɒfi]",
"sort_index": 96
},
{
"lemma_en": "bitter",
"zh": "苦味",
"phonetic": "[ˈbɪtə(r)]",
"sort_index": 97
},
{
"lemma_en": "tiger",
"zh": "老虎",
"phonetic": "[ˈtaɪɡə(r)]",
"sort_index": 98
},
{
"lemma_en": "lion",
"zh": "狮子",
"phonetic": "[ˈlaɪən]",
"sort_index": 99
},
{
"lemma_en": "panda",
"zh": "熊猫",
"phonetic": "[ˈpændə]",
"sort_index": 100
},
{
"lemma_en": "monkey",
"zh": "猴子",
"phonetic": "[ˈmʌŋki]",
"sort_index": 101
},
{
"lemma_en": "clever",
"zh": "聪明的",
"phonetic": "[ˈklevə(r)]",
"sort_index": 102
},
{
"lemma_en": "strong",
"zh": "坚强的",
"phonetic": "[strɒŋ]",
"sort_index": 103
},
{
"lemma_en": "toy train",
"zh": "玩具火车",
"phonetic": null,
"sort_index": 104
},
{
"lemma_en": "doll",
"zh": "洋娃娃",
"phonetic": "[dɒl]",
"sort_index": 105
},
{
"lemma_en": "skateboard",
"zh": "溜冰板",
"phonetic": "[ˈskeɪtbɔːd]",
"sort_index": 106
},
{
"lemma_en": "robot",
"zh": "机器人",
"phonetic": "[ˈrəʊbɒt]",
"sort_index": 107
},
{
"lemma_en": "lovely",
"zh": "可爱的",
"phonetic": "[ˈlʌvli]",
"sort_index": 108
},
{
"lemma_en": "hat",
"zh": "帽子",
"phonetic": "[hæt]",
"sort_index": 109
},
{
"lemma_en": "scarf",
"zh": "围巾",
"phonetic": "[skɑːf]",
"sort_index": 110
},
{
"lemma_en": "jacket",
"zh": "羽绒滑雪衫",
"phonetic": "[ˈdʒækɪt]",
"sort_index": 111
},
{
"lemma_en": "a pair of gloves",
"zh": "一副手套",
"phonetic": null,
"sort_index": 112
},
{
"lemma_en": "a pair of socks",
"zh": "无",
"phonetic": null,
"sort_index": 113
},
{
"lemma_en": "a pair of shoes",
"zh": "一双鞋",
"phonetic": null,
"sort_index": 114
},
{
"lemma_en": "funny",
"zh": "滑稽人物",
"phonetic": "[ˈfʌni]",
"sort_index": 115
},
{
"lemma_en": "his",
"zh": "他的",
"phonetic": "[hɪz]",
"sort_index": 116
},
{
"lemma_en": "circle",
"zh": "循环",
"phonetic": "[ˈsɜːk(ə)l]",
"sort_index": 117
},
{
"lemma_en": "square",
"zh": "平方",
"phonetic": "[skweə(r)]",
"sort_index": 118
},
{
"lemma_en": "triangle",
"zh": "三角(形)",
"phonetic": "[ˈtraɪæŋɡl]",
"sort_index": 119
},
{
"lemma_en": "star",
"zh": "星",
"phonetic": "[stɑː(r)]",
"sort_index": 120
},
{
"lemma_en": "rectangle",
"zh": "矩形",
"phonetic": "[ˈrektæŋɡl]",
"sort_index": 121
},
{
"lemma_en": "house",
"zh": "住宅",
"phonetic": "[haʊs]",
"sort_index": 122
},
{
"lemma_en": "sky",
"zh": "天空",
"phonetic": "[skaɪ]",
"sort_index": 123
},
{
"lemma_en": "sea",
"zh": "海",
"phonetic": "[siː]",
"sort_index": 124
},
{
"lemma_en": "mountain",
"zh": "山",
"phonetic": "[ˈmaʊntən]",
"sort_index": 125
},
{
"lemma_en": "river",
"zh": "河",
"phonetic": "[ˈrɪvə(r)]",
"sort_index": 126
},
{
"lemma_en": "plant a tree",
"zh": "无",
"phonetic": null,
"sort_index": 127
},
{
"lemma_en": "have a picnic",
"zh": "举行野餐",
"phonetic": null,
"sort_index": 128
},
{
"lemma_en": "ice-skate",
"zh": "溜冰",
"phonetic": "[ˈaɪs skeɪt]",
"sort_index": 129
},
{
"lemma_en": "ski",
"zh": "滑雪橇",
"phonetic": "[skiː]",
"sort_index": 130
},
{
"lemma_en": "body",
"zh": "身体",
"phonetic": "[ˈbɒdi]",
"sort_index": 131
},
{
"lemma_en": "head",
"zh": "头",
"phonetic": "[hed]",
"sort_index": 132
},
{
"lemma_en": "shoulder",
"zh": "肩",
"phonetic": "[ˈʃəʊldə(r)]",
"sort_index": 133
},
{
"lemma_en": "arm",
"zh": "手臂",
"phonetic": "[ɑːm]",
"sort_index": 134
},
{
"lemma_en": "finger",
"zh": "手指",
"phonetic": "[ˈfɪŋɡə(r)]",
"sort_index": 135
},
{
"lemma_en": "hand",
"zh": "手",
"phonetic": "[hænd]",
"sort_index": 136
},
{
"lemma_en": "knee",
"zh": "膝盖",
"phonetic": "[niː]",
"sort_index": 137
},
{
"lemma_en": "leg",
"zh": "腿",
"phonetic": "[leɡ]",
"sort_index": 138
},
{
"lemma_en": "foot",
"zh": "脚",
"phonetic": "[fʊt]",
"sort_index": 139
},
{
"lemma_en": "feet",
"zh": "脚(foot的复数形式)",
"phonetic": "[fiːt]",
"sort_index": 140
},
{
"lemma_en": "park",
"zh": "公园",
"phonetic": "[pɑːk]",
"sort_index": 141
},
{
"lemma_en": "cinema",
"zh": "电影",
"phonetic": "[ˈsɪnəmə]",
"sort_index": 142
},
{
"lemma_en": "zoo",
"zh": "动物园",
"phonetic": "[zuː]",
"sort_index": 143
},
{
"lemma_en": "our",
"zh": "我们的",
"phonetic": "[ɑː(r)]",
"sort_index": 144
},
{
"lemma_en": "class",
"zh": "阶级",
"phonetic": "[klɑːs]",
"sort_index": 145
},
{
"lemma_en": "party",
"zh": "政党",
"phonetic": "[ˈpɑːti]",
"sort_index": 146
},
{
"lemma_en": "take a photograph",
"zh": "摄影",
"phonetic": null,
"sort_index": 147
},
{
"lemma_en": "parent",
"zh": "父亲(或母亲)",
"phonetic": "[ˈpeərənt]",
"sort_index": 148
}
]
}
],
"word_count": 148,
"unit_count": 2
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
[
{
"slug": "hujiao-primary-1",
"title": "沪版小学英语 · 一年级",
"word_count": 97
},
{
"slug": "hujiao-primary-2",
"title": "沪版小学英语 · 二年级",
"word_count": 94
},
{
"slug": "hujiao-primary-3",
"title": "沪版小学英语 · 三年级",
"word_count": 148
},
{
"slug": "hujiao-primary-4",
"title": "沪版小学英语 · 四年级",
"word_count": 217
},
{
"slug": "hujiao-primary-5",
"title": "沪版小学英语 · 五年级",
"word_count": 204
},
{
"slug": "hujiao-junior-6",
"title": "沪版初中英语 · 六年级",
"word_count": 410
},
{
"slug": "hujiao-junior-7",
"title": "沪版初中英语 · 七年级",
"word_count": 422
},
{
"slug": "hujiao-junior-8",
"title": "沪版初中英语 · 八年级",
"word_count": 482
},
{
"slug": "hujiao-junior-9",
"title": "沪版初中英语 · 九年级",
"word_count": 386
},
{
"slug": "hujiao-senior-1",
"title": "沪版高中英语 · 高一",
"word_count": 402
},
{
"slug": "hujiao-senior-2",
"title": "沪版高中英语 · 高二",
"word_count": 564
},
{
"slug": "hujiao-senior-3",
"title": "沪版高中英语 · 高三",
"word_count": 423
},
{
"slug": "business-daily",
"title": "日常商务英语",
"word_count": 3272
}
]
+34
View File
@@ -32,6 +32,40 @@ def run_migrations() -> None:
)
)
if insp.has_table("user_settings"):
cols = {c["name"] for c in insp.get_columns("user_settings")}
if "active_book_id" not in cols:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN active_book_id INTEGER"))
if not insp.has_table("user_book_settings"):
conn.execute(
text(
"""
CREATE TABLE user_book_settings (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
user_id INTEGER NOT NULL,
book_id INTEGER NOT NULL,
daily_target INTEGER NOT NULL DEFAULT 12,
master_required_count INTEGER NOT NULL DEFAULT 3,
weak_wrong_threshold INTEGER NOT NULL DEFAULT 2,
CONSTRAINT uq_user_book_settings UNIQUE (user_id, book_id),
FOREIGN KEY(user_id) REFERENCES users (id),
FOREIGN KEY(book_id) REFERENCES word_books (id)
)
"""
)
)
if insp.has_table("words"):
cols = {c["name"] for c in insp.get_columns("words")}
if "book_id" not in cols:
conn.execute(
text("ALTER TABLE words ADD COLUMN book_id INTEGER NOT NULL DEFAULT 0")
)
conn.execute(text("CREATE INDEX ix_words_book_id ON words (book_id)"))
if "book_entry_id" not in cols:
conn.execute(text("ALTER TABLE words ADD COLUMN book_entry_id INTEGER"))
if insp.has_table("words") and insp.has_table("quiz_records"):
conn.execute(
text(
+2
View File
@@ -5,6 +5,7 @@ from database import Base, engine
from db_migrate import run_migrations
from routers import (
auth_router,
book_router,
coach_router,
quiz_router,
settings_router,
@@ -27,6 +28,7 @@ app.add_middleware(
app.include_router(auth_router.router)
app.include_router(translate_router.router)
app.include_router(book_router.router)
app.include_router(word_router.router)
app.include_router(quiz_router.router)
app.include_router(settings_router.router)
+59 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy import Column, ForeignKey, Integer, String, Text, UniqueConstraint
from database import Base
@@ -12,11 +12,54 @@ class User(Base):
created_at = Column(String(32), nullable=False)
class WordBook(Base):
"""全局词书目录(部署时导入)。"""
__tablename__ = "word_books"
id = Column(Integer, primary_key=True, autoincrement=True)
slug = Column(String(64), unique=True, nullable=False, index=True)
title = Column(String(128), nullable=False)
description = Column(Text, nullable=True)
level = Column(String(32), nullable=False)
word_count = Column(Integer, nullable=False, default=0)
unit_count = Column(Integer, nullable=False, default=0)
sort_order = Column(Integer, nullable=False, default=0)
daily_target = Column(Integer, nullable=False, default=15)
master_required_count = Column(Integer, nullable=False, default=4)
weak_wrong_threshold = Column(Integer, nullable=False, default=2)
learn_mode = Column(String(32), nullable=False, default="sequential")
is_published = Column(Integer, nullable=False, default=1)
class WordBookEntry(Base):
__tablename__ = "word_book_entries"
__table_args__ = (UniqueConstraint("book_id", "lemma_en", name="uq_book_lemma"),)
id = Column(Integer, primary_key=True, autoincrement=True)
book_id = Column(Integer, ForeignKey("word_books.id"), nullable=False, index=True)
unit = Column(Integer, nullable=False, default=1)
unit_title = Column(String(128), nullable=True)
sort_index = Column(Integer, nullable=False, index=True)
lemma_en = Column(String(128), nullable=False)
zh = Column(String(256), nullable=False)
phonetic = Column(String(128), nullable=True)
example_en = Column(Text, nullable=True)
example_cn = Column(Text, nullable=True)
class Word(Base):
__tablename__ = "words"
__table_args__ = (
UniqueConstraint(
"user_id", "book_id", "source_text", "target_text", name="uq_user_book_word"
),
)
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
book_id = Column(Integer, nullable=False, default=0, index=True)
book_entry_id = Column(Integer, ForeignKey("word_book_entries.id"), nullable=True)
source_text = Column(String(500), nullable=False)
target_text = Column(String(500), nullable=False)
source_lang = Column(String(8), nullable=False)
@@ -58,6 +101,21 @@ class UserSettings(Base):
daily_target = Column(Integer, nullable=False, default=20)
master_required_count = Column(Integer, nullable=False, default=3)
weak_wrong_threshold = Column(Integer, nullable=False, default=3)
active_book_id = Column(Integer, ForeignKey("word_books.id"), nullable=True)
class UserBookSettings(Base):
"""用户对某本词书的练习参数(每本书独立保存)。"""
__tablename__ = "user_book_settings"
__table_args__ = (UniqueConstraint("user_id", "book_id", name="uq_user_book_settings"),)
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
book_id = Column(Integer, ForeignKey("word_books.id"), nullable=False, index=True)
daily_target = Column(Integer, nullable=False, default=12)
master_required_count = Column(Integer, nullable=False, default=3)
weak_wrong_threshold = Column(Integer, nullable=False, default=2)
class DictionaryEntry(Base):
+1
View File
@@ -8,3 +8,4 @@ pydantic-settings==2.6.1
python-multipart==0.0.17
pymysql==1.1.1
numpy==2.0.2
openpyxl==3.1.5
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User, WordBookEntry
from schemas import (
ActiveBookOut,
BookPracticeSettingsOut,
BookPracticeSettingsUpdate,
SetActiveBookRequest,
WordBookDetailOut,
WordBookOut,
WordBookProgressOut,
WordBookUnitOut,
)
from services.book_service import book_service
router = APIRouter(prefix="/api/books", tags=["books"])
def _active_out(db: Session, user: User, book) -> ActiveBookOut:
progress = book_service.book_progress(db, user, book.id)
settings = book_service.practice_settings_dict(db, user, book)
return ActiveBookOut(
book=WordBookOut.model_validate(book),
progress=WordBookProgressOut(**progress),
settings=BookPracticeSettingsOut(**settings),
)
@router.get("", response_model=list[WordBookOut])
def list_books(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return book_service.list_books(db)
@router.get("/settings", response_model=BookPracticeSettingsOut)
def get_book_practice_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先在词书设置中选择词书")
data = book_service.practice_settings_dict(db, current_user, book)
return BookPracticeSettingsOut(**data)
@router.patch("/settings", response_model=ActiveBookOut)
def update_book_practice_settings(
data: BookPracticeSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if data.book_id:
book = book_service.activate_book(db, current_user, data.book_id)
elif not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先选择词书")
payload = data.model_dump(exclude_unset=True, exclude={"book_id"})
if payload:
book_service.update_practice_settings(db, current_user, book.id, payload)
return _active_out(db, current_user, book)
@router.get("/active", response_model=ActiveBookOut)
def get_active_book(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_active_book(db, current_user)
if not book:
return ActiveBookOut(book=None, progress=None, settings=None)
return _active_out(db, current_user, book)
@router.post("/active", response_model=ActiveBookOut)
def set_active_book(
data: SetActiveBookRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.activate_book(db, current_user, data.book_id)
return _active_out(db, current_user, book)
@router.get("/{book_id}", response_model=WordBookDetailOut)
def get_book_detail(
book_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
book = book_service.get_book(db, book_id)
rows = (
db.query(
WordBookEntry.unit,
WordBookEntry.unit_title,
func.count(WordBookEntry.id),
)
.filter(WordBookEntry.book_id == book.id)
.group_by(WordBookEntry.unit, WordBookEntry.unit_title)
.order_by(WordBookEntry.unit.asc())
.all()
)
units = [WordBookUnitOut(unit=r[0], title=r[1], word_count=r[2]) for r in rows]
return WordBookDetailOut(
**WordBookOut.model_validate(book).model_dump(),
units=units,
)
+5 -2
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from typing import Literal
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from auth import get_current_user
@@ -12,10 +14,11 @@ router = APIRouter(prefix="/api/coach", tags=["coach"])
@router.get("/session", response_model=CoachSessionResponse)
def coach_session(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_coach_service.start_session(db, current_user)
return memory_coach_service.start_session(db, current_user, track=track)
@router.post("/turn", response_model=CoachTurnResponse)
+9 -4
View File
@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends
from typing import Literal, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from auth import get_current_user
@@ -17,19 +19,21 @@ router = APIRouter(prefix="/api/quiz", tags=["quiz"])
@router.get("/daily", response_model=DailyQuizResponse)
def daily_quiz(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_daily_quiz(db, current_user)
result = quiz_service.get_daily_quiz(db, current_user, track=track)
return DailyQuizResponse(**result)
@router.get("/spell", response_model=DailyQuizResponse)
def spell_quiz(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_spell_quiz(db, current_user)
result = quiz_service.get_spell_quiz(db, current_user, track=track)
return DailyQuizResponse(**result)
@@ -53,7 +57,8 @@ def submit_answer(
@router.get("/stats", response_model=QuizStatsResponse)
def quiz_stats(
track: Literal["accumulation", "book"] = Query("accumulation"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return quiz_service.get_stats(db, current_user)
return quiz_service.get_stats(db, current_user, track=track)
+4 -2
View File
@@ -34,18 +34,20 @@ def create_word(
@router.get("", response_model=list[WordOut])
def list_words(
status: Optional[str] = None,
book_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return word_service.list_words(db, current_user, status)
return word_service.list_words(db, current_user, status, book_id=book_id)
@router.get("/memory-viz", response_model=MemoryVisualizationResponse)
def memory_visualization(
book_id: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_visual_service.get_visualization(db, current_user)
return memory_visual_service.get_visualization(db, current_user, book_id=book_id)
@router.get("/{word_id}/memory", response_model=WordMemoryDetailResponse)
+69
View File
@@ -47,6 +47,7 @@ class TranslateResponse(BaseModel):
phonetic: Optional[str] = None
example_en: Optional[str] = None
example_cn: Optional[str] = None
found: bool = True
# Words
@@ -67,6 +68,8 @@ class WordUpdate(BaseModel):
class WordOut(BaseModel):
id: int
user_id: int
book_id: int = 0
book_entry_id: Optional[int] = None
source_text: str
target_text: str
source_lang: str
@@ -195,6 +198,8 @@ class QuizQuestion(BaseModel):
class DailyQuizResponse(BaseModel):
questions: list[QuizQuestion]
total: int
track: str = "accumulation"
book_id: Optional[int] = None
class QuizAnswerRequest(BaseModel):
@@ -223,6 +228,70 @@ class QuizStatsResponse(BaseModel):
daily_target: int
today_completed: int
streak_days: int
track: str = "accumulation"
book_id: Optional[int] = None
# Word books
class WordBookOut(BaseModel):
id: int
slug: str
title: str
description: Optional[str] = None
level: str
word_count: int
unit_count: int
sort_order: int
daily_target: int
master_required_count: int
weak_wrong_threshold: int
learn_mode: str
class Config:
from_attributes = True
class WordBookUnitOut(BaseModel):
unit: int
title: Optional[str] = None
word_count: int
class WordBookDetailOut(WordBookOut):
units: list[WordBookUnitOut] = []
class WordBookProgressOut(BaseModel):
book_id: int
total: int
introduced: int
mastered: int
learning: int
locked: int
class BookPracticeSettingsOut(BaseModel):
book_id: int
daily_target: int
master_required_count: int
weak_wrong_threshold: int
class ActiveBookOut(BaseModel):
book: Optional[WordBookOut] = None
progress: Optional[WordBookProgressOut] = None
settings: Optional[BookPracticeSettingsOut] = None
class SetActiveBookRequest(BaseModel):
book_id: int = Field(ge=1)
class BookPracticeSettingsUpdate(BaseModel):
book_id: Optional[int] = Field(None, ge=1)
daily_target: Optional[int] = Field(None, ge=5, le=50)
master_required_count: Optional[int] = Field(None, ge=1, le=10)
weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=10)
# Settings
+249
View File
@@ -0,0 +1,249 @@
"""从 GitHub (lilinji/English) 下载沪版牛津与商务英语 xlsx,生成词书 JSON。
用法:
python -m scripts.fetch_hujiao_books
python -m scripts.fetch_hujiao_books --import # 生成后直接写入数据库
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
OUT_DIR = BACKEND_DIR / "data" / "books"
GITHUB_RAW = "https://raw.githubusercontent.com/lilinji/English/main"
OXFORD_DIR = (
"1.全国各大教材版本中小学同步/牛津版"
)
BUSINESS_DIR = "8.商务英语"
PRIMARY_GRADES = [
(1, 11, "一年级", ["牛津上海版一年级上册.xlsx", "牛津上海版一年级下册.xlsx"]),
(2, 12, "二年级", ["牛津上海版二年级上册.xlsx", "牛津上海版二年级下册.xlsx"]),
(3, 13, "三年级", ["牛津上海版三年级上册.xlsx", "牛津上海版三年级下册.xlsx"]),
(4, 14, "四年级", ["牛津上海版四年级上册.xlsx", "牛津上海版四年级下册.xlsx"]),
(5, 15, "五年级", ["牛津上海版五年级上册.xlsx", "牛津上海版五年级下册.xlsx"]),
]
JUNIOR_GRADES = [
(6, 21, "六年级", ["牛津上海版六年级上册.xlsx", "牛津上海版六年级下册.xlsx"]),
(7, 22, "七年级", ["牛津上海版七年级上册.xlsx", "牛津上海版七年级下册.xlsx"]),
(8, 23, "八年级", ["牛津上海版八年级上册.xlsx", "牛津上海版八年级下册.xlsx"]),
(9, 24, "九年级", ["牛津上海版九年级上册.xlsx", "牛津上海版九年级下册.xlsx"]),
]
SENIOR_GRADES = [
(1, 31, "高一", ["牛津上海版高一上.xlsx", "牛津上海版高一下.xlsx"]),
(2, 32, "高二", ["牛津上海版高二上.xlsx", "牛津上海版高二下.xlsx"]),
(3, 33, "高三", ["牛津上海版高三上.xlsx", "牛津上海版高三下.xlsx"]),
]
BOOK_SPECS = [
*[
{
"slug": f"hujiao-primary-{grade}",
"title": f"沪版小学英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "primary",
"sort_order": order,
"daily_target": 10 + grade,
"master_required_count": 3,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in PRIMARY_GRADES
],
*[
{
"slug": f"hujiao-junior-{grade}",
"title": f"沪版初中英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "junior",
"sort_order": order,
"daily_target": 11 + grade - 6,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in JUNIOR_GRADES
],
*[
{
"slug": f"hujiao-senior-{grade}",
"title": f"沪版高中英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "senior",
"sort_order": order,
"daily_target": 15 + grade,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in SENIOR_GRADES
],
{
"slug": "business-daily",
"title": "日常商务英语",
"description": "BEC 初/中级核心商务词汇",
"level": "business",
"sort_order": 40,
"daily_target": 15,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": [
"BEC初级词汇精选.xlsx",
"BEC中级词汇精选.xlsx",
],
"base_dir": BUSINESS_DIR,
},
]
def download_file(rel_path: str, cache_dir: Path) -> Path:
cache_dir.mkdir(parents=True, exist_ok=True)
local = cache_dir / Path(rel_path).name
if local.exists() and local.stat().st_size > 0:
return local
url = f"{GITHUB_RAW}/{urllib.parse.quote(rel_path)}"
print(f" 下载 {Path(rel_path).name} ...")
urllib.request.urlretrieve(url, local)
return local
def parse_xlsx(path: Path) -> list[dict]:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
header = [str(c).strip() if c else "" for c in rows[0]]
col_word = next((i for i, h in enumerate(header) if h in ("单词", "word", "英文")), 0)
col_zh = next((i for i, h in enumerate(header) if h in ("释义", "中文", "意思")), -1)
col_phonetic = next((i for i, h in enumerate(header) if "英音" in h or h == "音标"), -1)
out: list[dict] = []
seen: set[str] = set()
for row in rows[1:]:
if not row or not row[col_word]:
continue
lemma = str(row[col_word]).strip().lower()
if not lemma or lemma in seen:
continue
seen.add(lemma)
zh_raw = str(row[col_zh]).strip() if col_zh >= 0 and row[col_zh] else lemma
zh = _simplify_zh(zh_raw)
phonetic = None
if col_phonetic >= 0 and row[col_phonetic]:
phonetic = str(row[col_phonetic]).strip()[:128] or None
out.append({"lemma_en": lemma[:128], "zh": zh[:256], "phonetic": phonetic})
wb.close()
return out
def _simplify_zh(raw: str) -> str:
text = raw.strip()
first = text.split("\n")[0].strip()
first = re.sub(r"^[a-z]+\.\s*", "", first, flags=re.I)
for sep in (";", ";", ",", ","):
if sep in first:
first = first.split(sep)[0].strip()
return first or raw[:64]
def unit_title_from_filename(name: str) -> str:
base = name.replace(".xlsx", "").replace("牛津上海版", "").replace("BEC", "BEC ")
return base.strip()
def build_book(spec: dict, cache_dir: Path) -> dict:
base_dir = spec.get("base_dir", OXFORD_DIR)
units = []
global_seen: set[str] = set()
sort_index = 0
for unit_no, fname in enumerate(spec["files"], start=1):
rel = f"{base_dir}/{fname}"
path = download_file(rel, cache_dir)
words = parse_xlsx(path)
unit_words = []
for w in words:
key = w["lemma_en"]
if key in global_seen:
continue
global_seen.add(key)
sort_index += 1
unit_words.append({**w, "sort_index": sort_index})
if unit_words:
units.append(
{
"unit": unit_no,
"title": unit_title_from_filename(fname),
"words": unit_words,
}
)
return {
"slug": spec["slug"],
"title": spec["title"],
"description": spec["description"],
"level": spec["level"],
"sort_order": spec["sort_order"],
"daily_target": spec["daily_target"],
"master_required_count": spec["master_required_count"],
"weak_wrong_threshold": spec["weak_wrong_threshold"],
"learn_mode": "sequential",
"units": units,
}
def main() -> None:
parser = argparse.ArgumentParser(description="下载沪版词书并生成 JSON")
parser.add_argument("--import-db", action="store_true", help="生成后导入数据库")
args = parser.parse_args()
try:
import openpyxl # noqa: F401
except ImportError:
print("请先安装: pip install openpyxl", file=sys.stderr)
sys.exit(1)
OUT_DIR.mkdir(parents=True, exist_ok=True)
cache_dir = BACKEND_DIR / "data" / "cache" / "word_books"
books = []
for spec in BOOK_SPECS:
print(f"处理 {spec['title']} ...")
book = build_book(spec, cache_dir)
word_count = sum(len(u["words"]) for u in book["units"])
book["word_count"] = word_count
book["unit_count"] = len(book["units"])
out_path = OUT_DIR / f"{spec['slug']}.json"
out_path.write_text(json.dumps(book, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" -> {out_path.name} ({word_count} 词, {book['unit_count']} 单元)")
books.append(book)
manifest = OUT_DIR / "manifest.json"
manifest.write_text(
json.dumps([{"slug": b["slug"], "title": b["title"], "word_count": b["word_count"]} for b in books],
ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"已生成 {len(books)} 本词书 -> {OUT_DIR}")
if args.import_db:
from scripts.import_word_books import import_all
import_all(OUT_DIR)
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
"""将 backend/data/books/*.json 导入 word_books / word_book_entries 表。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from database import SessionLocal # noqa: E402
from models import WordBook, WordBookEntry # noqa: E402
from services.book_service import DEPRECATED_SLUGS # noqa: E402
BOOKS_DIR = BACKEND_DIR / "data" / "books"
def upsert_book(session, data: dict) -> WordBook:
book = session.query(WordBook).filter(WordBook.slug == data["slug"]).first()
if not book:
book = WordBook(slug=data["slug"])
session.add(book)
book.title = data["title"]
book.description = data.get("description")
book.level = data.get("level", "general")
book.word_count = data.get("word_count", 0)
book.unit_count = data.get("unit_count", 0)
book.sort_order = data.get("sort_order", 0)
book.daily_target = data.get("daily_target", 15)
book.master_required_count = data.get("master_required_count", 4)
book.weak_wrong_threshold = data.get("weak_wrong_threshold", 2)
book.learn_mode = data.get("learn_mode", "sequential")
book.is_published = 1
session.flush()
return book
def import_book_file(session, path: Path) -> tuple[str, int]:
data = json.loads(path.read_text(encoding="utf-8"))
book = upsert_book(session, data)
session.query(WordBookEntry).filter(WordBookEntry.book_id == book.id).delete(
synchronize_session=False
)
count = 0
for unit in data.get("units", []):
unit_no = unit.get("unit", 1)
unit_title = unit.get("title")
for w in unit.get("words", []):
entry = WordBookEntry(
book_id=book.id,
unit=unit_no,
unit_title=unit_title,
sort_index=w["sort_index"],
lemma_en=w["lemma_en"].strip().lower()[:128],
zh=w["zh"].strip()[:256],
phonetic=(w.get("phonetic") or "")[:128] or None,
example_en=w.get("example_en"),
example_cn=w.get("example_cn"),
)
session.add(entry)
count += 1
book.word_count = count
book.unit_count = len(data.get("units", []))
session.commit()
return book.slug, count
def import_all(books_dir: Path = BOOKS_DIR) -> None:
files = sorted(books_dir.glob("*.json"))
files = [
f
for f in files
if f.name != "manifest.json"
and f.stem not in DEPRECATED_SLUGS
]
if not files:
print(f"未找到词书 JSON: {books_dir}")
return
session = SessionLocal()
try:
for path in files:
slug, count = import_book_file(session, path)
print(f" 导入 {slug}: {count} 词条")
for slug in DEPRECATED_SLUGS:
book = session.query(WordBook).filter(WordBook.slug == slug).first()
if book and book.is_published:
book.is_published = 0
session.commit()
print(f" 下架旧词书 {slug}")
finally:
session.close()
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="导入词书 JSON 到数据库")
parser.add_argument("--dir", type=Path, default=BOOKS_DIR)
args = parser.parse_args()
import_all(args.dir)
if __name__ == "__main__":
main()
+240
View File
@@ -0,0 +1,240 @@
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models import User, UserBookSettings, UserSettings, Word, WordBook, WordBookEntry
from services.word_service import utc_now_iso
ACCUMULATION_BOOK_ID = 0
DEPRECATED_SLUGS = frozenset({"hujiao-primary", "hujiao-junior", "hujiao-senior"})
class BookService:
def list_books(self, db: Session) -> list[WordBook]:
return (
db.query(WordBook)
.filter(WordBook.is_published == 1)
.order_by(WordBook.sort_order.asc(), WordBook.id.asc())
.all()
)
def get_book(self, db: Session, book_id: int) -> WordBook:
book = db.query(WordBook).filter(WordBook.id == book_id, WordBook.is_published == 1).first()
if not book:
raise HTTPException(status_code=404, detail="词书不存在")
return book
def get_user_settings_row(
self, db: Session, user: User, book: WordBook
) -> UserBookSettings:
row = (
db.query(UserBookSettings)
.filter(UserBookSettings.user_id == user.id, UserBookSettings.book_id == book.id)
.first()
)
if row:
return row
row = UserBookSettings(
user_id=user.id,
book_id=book.id,
daily_target=book.daily_target,
master_required_count=book.master_required_count,
weak_wrong_threshold=book.weak_wrong_threshold,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def get_active_book_id(self, db: Session, user: User) -> Optional[int]:
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings or not settings.active_book_id:
return None
book = (
db.query(WordBook)
.filter(WordBook.id == settings.active_book_id, WordBook.is_published == 1)
.first()
)
if not book:
return None
return book.id
def get_active_book(self, db: Session, user: User) -> Optional[WordBook]:
book_id = self.get_active_book_id(db, user)
if not book_id:
return None
return self.get_book(db, book_id)
def practice_settings_dict(self, db: Session, user: User, book: WordBook) -> dict:
row = self.get_user_settings_row(db, user, book)
return {
"book_id": book.id,
"daily_target": row.daily_target,
"master_required_count": row.master_required_count,
"weak_wrong_threshold": row.weak_wrong_threshold,
"learn_mode": book.learn_mode,
}
def update_practice_settings(
self, db: Session, user: User, book_id: int, data: dict
) -> dict:
book = self.get_book(db, book_id)
row = self.get_user_settings_row(db, user, book)
if data.get("daily_target") is not None:
row.daily_target = data["daily_target"]
if data.get("master_required_count") is not None:
row.master_required_count = data["master_required_count"]
if data.get("weak_wrong_threshold") is not None:
row.weak_wrong_threshold = data["weak_wrong_threshold"]
db.commit()
db.refresh(row)
return self.practice_settings_dict(db, user, book)
def book_progress(self, db: Session, user: User, book_id: int) -> dict:
book = self.get_book(db, book_id)
total = book.word_count or db.query(WordBookEntry).filter(
WordBookEntry.book_id == book_id
).count()
all_user = db.query(Word).filter(Word.user_id == user.id, Word.book_id == book_id).count()
locked = db.query(Word).filter(
Word.user_id == user.id, Word.book_id == book_id, Word.status == "locked"
).count()
introduced = all_user - locked
active_words = (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book_id, Word.status != "locked")
.all()
)
mastered = sum(1 for w in active_words if w.status == "mastered")
learning = sum(1 for w in active_words if w.status in ("learning", "weak", "new"))
if all_user == 0:
locked = total
introduced = 0
return {
"book_id": book_id,
"total": total,
"introduced": introduced,
"mastered": mastered,
"learning": learning,
"locked": locked,
}
def _daily_unlock_limit(self, db: Session, user: User, book: WordBook) -> int:
return self.get_user_settings_row(db, user, book).daily_target
def ensure_user_book_words(self, db: Session, user: User, book: WordBook) -> int:
existing_entry_ids = {
w.book_entry_id
for w in db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book.id)
.all()
if w.book_entry_id
}
entries = (
db.query(WordBookEntry)
.filter(WordBookEntry.book_id == book.id)
.order_by(WordBookEntry.sort_index.asc())
.all()
)
now = utc_now_iso()
created = 0
unlock_limit = self._daily_unlock_limit(db, user, book)
for entry in entries:
if entry.id in existing_entry_ids:
continue
status = "new" if created < unlock_limit else "locked"
word = Word(
user_id=user.id,
book_id=book.id,
book_entry_id=entry.id,
source_text=entry.zh,
target_text=entry.lemma_en,
source_lang="zh",
target_lang="en",
phonetic=entry.phonetic,
example_en=entry.example_en,
example_cn=entry.example_cn,
status=status,
correct_count=0,
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=now,
)
db.add(word)
created += 1
if created:
db.commit()
elif not existing_entry_ids and entries:
self._unlock_initial_batch(db, user, book)
return created
def _unlock_initial_batch(self, db: Session, user: User, book: WordBook) -> None:
limit = self._daily_unlock_limit(db, user, book)
locked = (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book.id, Word.status == "locked")
.order_by(Word.id.asc())
.limit(limit)
.all()
)
for w in locked:
w.status = "new"
if locked:
db.commit()
def activate_book(self, db: Session, user: User, book_id: int) -> WordBook:
book = self.get_book(db, book_id)
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings:
settings = UserSettings(user_id=user.id)
db.add(settings)
db.flush()
settings.active_book_id = book.id
self.get_user_settings_row(db, user, book)
self.ensure_user_book_words(db, user, book)
db.commit()
db.refresh(settings)
return book
def unlock_more_new_words(self, db: Session, user: User, book: WordBook, limit: int) -> int:
locked = (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book.id, Word.status == "locked")
.order_by(Word.id.asc())
.limit(limit)
.all()
)
for w in locked:
w.status = "new"
if locked:
db.commit()
return len(locked)
def book_settings(
self, db: Session, user: User, book: Optional[WordBook], user_settings: UserSettings
) -> dict:
if book:
return self.practice_settings_dict(db, user, book)
return {
"daily_target": user_settings.daily_target,
"master_required_count": user_settings.master_required_count,
"weak_wrong_threshold": user_settings.weak_wrong_threshold,
"learn_mode": "adaptive",
}
def deprecate_old_books(self, db: Session) -> None:
for slug in DEPRECATED_SLUGS:
book = db.query(WordBook).filter(WordBook.slug == slug).first()
if book and book.is_published:
book.is_published = 0
db.commit()
book_service = BookService()
+16 -4
View File
@@ -216,13 +216,25 @@ class MemoryCoachService:
t.revealed = True
return tokens
def start_session(self, db: Session, user: User) -> CoachSessionResponse:
settings = quiz_service.get_settings(db, user)
words = quiz_service.select_daily_words(db, user, settings.daily_target)
def start_session(self, db: Session, user: User, track: str = "accumulation") -> CoachSessionResponse:
from services.book_service import book_service
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
return CoachSessionResponse(words=[], total=0)
book_service.ensure_user_book_words(db, user, book)
practice = book_service.practice_settings_dict(db, user, book)
words = quiz_service.select_book_words(db, user, book, practice["daily_target"])
book_id = book.id
else:
settings = quiz_service.get_settings(db, user)
words = quiz_service.select_accumulation_words(db, user, settings.daily_target)
book_id = 0
if not words:
return CoachSessionResponse(words=[], total=0)
summaries = memory_visual_service.get_visualization(db, user)["words"]
summaries = memory_visual_service.get_visualization(db, user, book_id=book_id)["words"]
risk_map = {w["id"]: w.get("retention_now", 0) for w in summaries}
items = [
+14 -5
View File
@@ -57,14 +57,23 @@ def mastery_at_time(word: Word, records: list[QuizRecord], at: datetime) -> floa
class MemoryVisualService:
def get_visualization(self, db: Session, user: User, horizon_days: int = 30) -> dict:
words = db.query(Word).filter(Word.user_id == user.id).all()
records = (
db.query(QuizRecord)
def get_visualization(
self, db: Session, user: User, horizon_days: int = 30, book_id: int = 0
) -> dict:
words = (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book_id, Word.status != "locked")
.all()
)
word_ids = {w.id for w in words}
records = [
r
for r in db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.asc())
.all()
)
if r.word_id in word_ids
]
records_by_word: dict[int, list[QuizRecord]] = defaultdict(list)
for r in records:
records_by_word[r.word_id].append(r)
+119 -40
View File
@@ -5,8 +5,9 @@ from typing import Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word
from models import QuizRecord, User, UserSettings, Word, WordBook
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.book_service import ACCUMULATION_BOOK_ID, book_service
from services.dictionary_service import dictionary_service
from services.word_service import calc_mastery_score, utc_now_iso
@@ -40,10 +41,31 @@ class QuizService:
db.refresh(settings)
return settings
def select_daily_words(self, db: Session, user: User, limit: int) -> list[Word]:
today = today_str()
all_words = db.query(Word).filter(Word.user_id == user.id).all()
def _track_words(self, db: Session, user: User, book_id: int) -> list[Word]:
return (
db.query(Word)
.filter(Word.user_id == user.id, Word.book_id == book_id, Word.status != "locked")
.all()
)
def select_accumulation_words(self, db: Session, user: User, limit: int) -> list[Word]:
return self._select_adaptive_words(
self._track_words(db, user, ACCUMULATION_BOOK_ID), limit
)
def select_book_words(self, db: Session, user: User, book: WordBook, limit: int) -> list[Word]:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
if len(pool) < limit and book.learn_mode == "sequential":
need = limit - len(pool)
unlocked = book_service.unlock_more_new_words(db, user, book, need)
if unlocked:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
return pool[:limit]
def _select_adaptive_words(self, all_words: list[Word], limit: int) -> list[Word]:
today = today_str()
weak = [w for w in all_words if w.status == "weak"]
learning = [w for w in all_words if w.status == "learning"]
new = [w for w in all_words if w.status == "new"]
@@ -65,6 +87,20 @@ class QuizService:
return pool[:limit]
return pool[:limit]
def select_daily_words(
self, db: Session, user: User, limit: int, track: str = "accumulation"
) -> list[Word]:
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
return []
book_service.ensure_user_book_words(db, user, book)
settings = self.get_settings(db, user)
practice = book_service.practice_settings_dict(db, user, book)
return self.select_book_words(db, user, book, practice["daily_target"])
settings = self.get_settings(db, user)
return self.select_accumulation_words(db, user, settings.daily_target)
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
@@ -130,19 +166,42 @@ class QuizService:
correct_answer=en,
)
def get_spell_quiz(self, db: Session, user: User) -> dict:
def _resolve_track(self, db: Session, user: User, track: str) -> tuple[int, int]:
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请先在首页选择词书")
book_service.ensure_user_book_words(db, user, book)
practice = book_service.practice_settings_dict(db, user, book)
return book.id, practice["daily_target"]
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
return ACCUMULATION_BOOK_ID, settings.daily_target
def get_spell_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
book_id, limit = self._resolve_track(db, user, track)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
else:
words = self.select_accumulation_words(db, user, limit)
questions = [self.build_spell_question(w) for w in words]
return {
"questions": questions,
"total": len(questions),
"track": track,
"book_id": book_id if track == "book" else None,
}
def get_daily_quiz(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
all_words = db.query(Word).filter(Word.user_id == user.id).all()
def get_daily_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
book_id, limit = self._resolve_track(db, user, track)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
else:
words = self.select_accumulation_words(db, user, limit)
all_words = self._track_words(db, user, book_id)
if len(all_words) < 4:
questions = []
@@ -154,6 +213,8 @@ class QuizService:
return {
"questions": questions,
"total": len(questions),
"track": track,
"book_id": book_id if track == "book" else None,
}
def submit_answer(
@@ -172,6 +233,10 @@ class QuizService:
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
book = None
if word.book_id > 0:
book = db.query(WordBook).filter(WordBook.id == word.book_id).first()
rule = book_service.book_settings(db, user, book, settings)
if question_type in ("spell", "memory_coach"):
is_correct = (
user_answer.strip().lower() == correct_answer.strip().lower()
@@ -187,7 +252,7 @@ class QuizService:
word.consecutive_correct_count += 1
if word.status == "new":
word.status = "learning"
if word.consecutive_correct_count >= settings.master_required_count:
if word.consecutive_correct_count >= rule["master_required_count"]:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
@@ -195,7 +260,7 @@ class QuizService:
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= settings.weak_wrong_threshold:
if word.wrong_count >= rule["weak_wrong_threshold"]:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
@@ -226,35 +291,45 @@ class QuizService:
"word": WordOut.model_validate(word),
}
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
def get_stats(self, db: Session, user: User, track: str = "accumulation") -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
book_id = ACCUMULATION_BOOK_ID
daily_target = settings.daily_target
if track == "book":
active = book_service.get_active_book(db, user)
if not active:
return QuizStatsResponse(
total_words=0,
new_count=0,
learning_count=0,
mastered_count=0,
weak_count=0,
today_quiz_count=0,
today_correct_count=0,
today_accuracy=0.0,
daily_target=15,
today_completed=0,
streak_days=0,
track=track,
)
book_id = active.id
daily_target = book_service.practice_settings_dict(db, user, active)["daily_target"]
total = db.query(Word).filter(Word.user_id == user.id).count()
new_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "new").count()
learning_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "learning").count()
)
mastered_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "mastered").count()
)
weak_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "weak").count()
word_q = db.query(Word).filter(Word.user_id == user.id, Word.book_id == book_id)
active_words = word_q.filter(Word.status != "locked")
total = active_words.count()
new_count = active_words.filter(Word.status == "new").count()
learning_count = active_words.filter(Word.status == "learning").count()
mastered_count = active_words.filter(Word.status == "mastered").count()
weak_count = active_words.filter(Word.status == "weak").count()
today_records = (
db.query(QuizRecord)
.filter(
QuizRecord.user_id == user.id,
func.date(QuizRecord.created_at) == today,
)
.all()
)
# SQLite date compare fallback: filter by prefix
if not today_records:
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today)
]
track_word_ids = {w.id for w in word_q.all()}
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today) and r.word_id in track_word_ids
]
today_quiz_count = len(today_records)
today_correct = sum(1 for r in today_records if r.is_correct == 1)
@@ -262,7 +337,7 @@ class QuizService:
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
)
streak = self._calc_streak(db, user)
streak = self._calc_streak(db, user, track_word_ids)
return QuizStatsResponse(
total_words=total,
@@ -273,18 +348,22 @@ class QuizService:
today_quiz_count=today_quiz_count,
today_correct_count=today_correct,
today_accuracy=accuracy,
daily_target=settings.daily_target,
daily_target=daily_target,
today_completed=today_quiz_count,
streak_days=streak,
track=track,
book_id=book_id if track == "book" else None,
)
def _calc_streak(self, db: Session, user: User) -> int:
def _calc_streak(self, db: Session, user: User, word_ids: Optional[set[int]] = None) -> int:
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.desc())
.all()
)
if word_ids is not None:
records = [r for r in records if r.word_id in word_ids]
days_with_quiz = set()
for r in records:
days_with_quiz.add(r.created_at[:10])
+8 -6
View File
@@ -34,7 +34,7 @@ class TranslationService:
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, text, "en", "zh")
return self._placeholder(text, "en", "zh")
def _zh_to_en(self, db: Session, text: str) -> dict:
entry = dictionary_service.lookup_zh(db, text)
@@ -48,7 +48,7 @@ class TranslationService:
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, f"[{text}]", "zh", "en")
return self._placeholder(text, "zh", "en")
def _build_response(
self,
@@ -68,17 +68,19 @@ class TranslationService:
"phonetic": phonetic,
"example_en": example_en,
"example_cn": example_cn,
"found": True,
}
def _placeholder(self, source: str, target: str, source_lang: str, target_lang: str) -> dict:
def _placeholder(self, source: str, source_lang: str, target_lang: str) -> dict:
return {
"source_text": source,
"target_text": target,
"target_text": "",
"source_lang": source_lang,
"target_lang": target_lang,
"phonetic": None,
"example_en": f"Example with {target}." if target_lang == "en" else None,
"example_cn": f"包含「{source}」的例句。" if source_lang == "zh" else None,
"example_en": None,
"example_cn": None,
"found": False,
}
+17 -1
View File
@@ -6,6 +6,8 @@ from sqlalchemy.orm import Session
from models import QuizRecord, Word, User
ACCUMULATION_BOOK_ID = 0
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -20,10 +22,12 @@ def calc_mastery_score(correct: int, wrong: int) -> int:
class WordService:
def create_word(self, db: Session, user: User, data: dict) -> Word:
book_id = data.get("book_id") or ACCUMULATION_BOOK_ID
existing = (
db.query(Word)
.filter(
Word.user_id == user.id,
Word.book_id == book_id,
Word.source_text == data["source_text"],
Word.target_text == data["target_text"],
)
@@ -34,6 +38,8 @@ class WordService:
word = Word(
user_id=user.id,
book_id=book_id,
book_entry_id=data.get("book_entry_id"),
source_text=data["source_text"],
target_text=data["target_text"],
source_lang=data["source_lang"],
@@ -54,8 +60,18 @@ class WordService:
db.refresh(word)
return word
def list_words(self, db: Session, user: User, status: Optional[str] = None) -> list[Word]:
def list_words(
self,
db: Session,
user: User,
status: Optional[str] = None,
book_id: Optional[int] = None,
) -> list[Word]:
q = db.query(Word).filter(Word.user_id == user.id)
if book_id is None:
q = q.filter(Word.book_id == ACCUMULATION_BOOK_ID)
elif book_id > 0:
q = q.filter(Word.book_id == book_id)
if status:
q = q.filter(Word.status == status)
return q.order_by(Word.created_at.desc()).all()