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:
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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user