learning-page.component.html 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. <section class="learning-page">
  2. @if (loading()) {
  3. <div class="empty-state"><p>正在加载学习内容...</p></div>
  4. } @else if (error()) {
  5. <div class="empty-state error">
  6. <div>
  7. <lucide-icon [img]="icons.CircleHelp" [size]="32" />
  8. <p>{{ error() }}</p>
  9. <button type="button" class="secondary-button" (click)="load()">重新加载</button>
  10. </div>
  11. </div>
  12. } @else if (isNodePage) {
  13. <div class="node-intro">
  14. <lucide-icon [img]="icons.Library" [size]="27" />
  15. <div>
  16. <h2>{{ isCatalogPage ? '选择词库教材' : canStartPointReading ? '选择学习单元' : '学习单元' }}</h2>
  17. <p>{{ isCatalogPage ? '先选择学习阶段,再选择对应教材' : canStartPointReading ? '可多选单元,下一步精筛本次新词' : '点击单元查看老师分配的单词列表' }}</p>
  18. </div>
  19. </div>
  20. @if (isCatalogPage) {
  21. <nav class="catalog-tabs" aria-label="学习阶段">
  22. @for (category of catalogCategories; track category.pid; let index = $index) {
  23. <button type="button" [class.active]="catalogCategoryIndex() === index" (click)="selectCatalogCategory(index)">{{ category.label }}</button>
  24. }
  25. </nav>
  26. } @else if (canStartPointReading) {
  27. <div class="unit-selection-summary">
  28. <span>已选 <strong>{{ selectedNodes().size }}</strong> 个单元</span>
  29. <small>单击单元可选择或取消</small>
  30. </div>
  31. }
  32. <div class="node-list">
  33. @for (node of nodes(); track $index) {
  34. <button type="button" [class.unit-node]="isUnitListPage" [class.readonly]="isUnitListPage && !canStartPointReading" [class.selected]="isUnitListPage && selectedNodes().has(nodeId(node))" (click)="openNode(node)">
  35. @if (isUnitListPage && canStartPointReading) {
  36. <span class="node-check">@if (selectedNodes().has(nodeId(node))) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
  37. }
  38. <span class="node-copy">
  39. <strong>{{ nodeTitle(node) }}</strong>
  40. @if (nodeProgress(node)) { <small>{{ nodeProgress(node) }}</small> }
  41. @else if (nodeCount(node)) { <small>{{ nodeCount(node) }} 个词</small> }
  42. </span>
  43. @if (isUnitListPage) {
  44. <span class="unit-progress-ring" [class.complete]="nodeProgressPercent(node) === 100" [style.--progress]="nodeProgressPercent(node) + '%'" [attr.aria-label]="nodeTitle(node) + '学习进度' + nodeProgressPercent(node) + '%'"><strong>{{ nodeProgressPercent(node) }}%</strong></span>
  45. }
  46. @if (isCatalogPage || (isUnitListPage && !canStartPointReading)) { <lucide-icon [img]="icons.ChevronRight" [size]="19" /> }
  47. </button>
  48. } @empty {
  49. <div class="empty-state"><p>{{ isCatalogPage ? '该阶段暂无可用词库' : '该教材暂无可选单元' }}</p></div>
  50. }
  51. </div>
  52. @if (isUnitListPage && canStartPointReading) {
  53. <div class="sticky-actions unit-actions">
  54. <span>已选 {{ selectedNodes().size }} 个单元</span>
  55. <button type="button" class="primary-button" (click)="startNodeSelection()"><lucide-icon [img]="icons.Play" [size]="18" />选词提升</button>
  56. </div>
  57. }
  58. } @else if (isReadOnlyWordView) {
  59. <section class="readonly-word-card" aria-labelledby="readonly-word-title">
  60. <header class="readonly-word-header">
  61. <div><strong id="readonly-word-title">单词列表</strong><span>第 {{ wordPreviewRangeStart() }}–{{ wordPreviewRangeEnd() }} 条 · 共 {{ wordPreviewTotal() }} 个单词</span></div>
  62. <em>{{ isHistoryWordView && canStartPointReading ? '历史记录' : '学生只读' }}</em>
  63. </header>
  64. <div class="readonly-word-list" [class.is-updating]="wordPreviewLoading()" [attr.aria-busy]="wordPreviewLoading()" aria-live="polite">
  65. @for (word of words(); track word.id) {
  66. <article>
  67. <div><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</div>
  68. <p>{{ word.meaning || '暂无释义' }}</p>
  69. </article>
  70. } @empty {
  71. <div class="empty-state"><p>{{ isHistoryWordView ? '该次学习记录暂无可显示的单词' : '该单元暂无单词' }}</p></div>
  72. }
  73. </div>
  74. @if (wordPreviewPageCount() > 1) {
  75. <nav class="word-preview-pagination" aria-label="单词列表分页">
  76. <button type="button" class="page-direction" [disabled]="wordPreviewPage() === 1 || wordPreviewLoading()" (click)="openWordPreviewPage(wordPreviewPage() - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button>
  77. <div class="page-numbers">
  78. @for (page of wordPreviewPages(); track page) {
  79. <button type="button" [class.active]="page === wordPreviewPage()" [attr.aria-current]="page === wordPreviewPage() ? 'page' : null" [attr.aria-label]="'第 ' + page + ' 页'" [disabled]="wordPreviewLoading()" (click)="openWordPreviewPage(page)">{{ page }}</button>
  80. }
  81. </div>
  82. <button type="button" class="page-direction" [disabled]="wordPreviewPage() === wordPreviewPageCount() || wordPreviewLoading()" (click)="openWordPreviewPage(wordPreviewPage() + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
  83. <span>{{ wordPreviewPage() }} / {{ wordPreviewPageCount() }} 页</span>
  84. </nav>
  85. }
  86. <p class="readonly-word-note">{{ isHistoryWordView ? '这里展示本次学习记录中的单词,不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作' }}</p>
  87. </section>
  88. } @else if (isSelectionPage) {
  89. <header class="selection-header" [class.refine-selection-header]="isRefineSelectionPage">
  90. <div>
  91. @if (isRefineSelectionPage) {
  92. <strong>{{ isReadOnlySelectionPage ? '词库学习进度' : '本次待学 ' + selected().size + ' / ' + selectionLimit }}</strong>
  93. <span>已学完 {{ completedSelectionWords().length }} · 还没学 {{ pendingSelectionWords().length }}</span>
  94. } @else {
  95. <strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong>
  96. <span>选择本次要学习的单词</span>
  97. }
  98. </div>
  99. <div class="header-actions">
  100. @if (!isRefineSelectionPage) { <button type="button" class="quiet-button" (click)="toggleAllWords()">{{ selected().size ? '取消全选' : '全选' }}</button> }
  101. @if (!isReadOnlySelectionPage) {
  102. <button type="button" class="primary-button" [disabled]="submitting()" (click)="startSelected()">
  103. <lucide-icon [img]="icons.Play" [size]="17" />{{ isRefineSelectionPage ? selectionActionLabel() : '开始学习' }}
  104. </button>
  105. }
  106. </div>
  107. </header>
  108. @if (isRefineSelectionPage) {
  109. <div class="word-selection-panel">
  110. <nav class="word-selection-tabs" role="tablist" aria-label="单词学习状态">
  111. <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'pending'" [attr.aria-selected]="activeSelectionBucket() === 'pending'" (click)="activeSelectionBucket.set('pending')">
  112. <span>还没学</span><b>{{ pendingSelectionWords().length }}</b>
  113. </button>
  114. <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="activeSelectionBucket.set('completed')">
  115. <span>已学完</span><b>{{ completedSelectionWords().length }}</b>
  116. </button>
  117. </nav>
  118. @if (activeSelectionBucket() === 'pending') {
  119. <section class="word-selection-list pending-column" role="tabpanel" aria-label="还没学的单词">
  120. <p class="word-column-help">{{ isReadOnlySelectionPage ? '这些单词尚未完成学习,可点击单词查看释义和读音。' : '先回忆中文,核对后选择“认识”或“加入学习”。' }}</p>
  121. <div class="word-select-list">
  122. @for (word of pendingSelectionWords(); track word.id) {
  123. <article class="word-refine-item" [class.selected]="selected().has(word.id)">
  124. <div class="word-refine-main">
  125. @if (isReadOnlySelectionPage) {
  126. <span class="word-pending-mark" aria-hidden="true"><lucide-icon [img]="icons.CircleHelp" [size]="16" /></span>
  127. } @else {
  128. <button type="button" class="word-select-check" [attr.aria-label]="(selected().has(word.id) ? '移出学习:' : '加入学习:') + word.word" [attr.aria-pressed]="selected().has(word.id)" (click)="toggleWord(word)">
  129. @if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }
  130. </button>
  131. }
  132. <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
  133. <strong>{{ word.word }}</strong>
  134. <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : '先说出中文意思,再点击核对' }}</small>
  135. </button>
  136. <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
  137. </div>
  138. @if (selectionWordRevealed(word) && !isReadOnlySelectionPage) {
  139. <div class="word-assessment">
  140. <span>和你刚才说的一样吗?</span>
  141. <button type="button" class="known-button" [class.active]="selectionAssessment()[word.id] === 'known'" (click)="markWordKnown(word)"><lucide-icon [img]="icons.Check" [size]="15" />认识</button>
  142. <button type="button" class="unknown-button" [class.active]="selectionAssessment()[word.id] === 'unknown'" (click)="markWordUnknown(word)"><lucide-icon [img]="icons.CircleHelp" [size]="15" />不认识,加入学习</button>
  143. </div>
  144. }
  145. </article>
  146. } @empty {
  147. <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>这个词库已经全部学完</span></div>
  148. }
  149. </div>
  150. </section>
  151. } @else {
  152. <section class="word-selection-list completed-column" role="tabpanel" aria-label="已学完的单词">
  153. <p class="word-column-help">包含真正完成学习,以及本次判断为“认识”的词。</p>
  154. <div class="word-select-list">
  155. @for (word of completedSelectionWords(); track word.id) {
  156. <article class="word-refine-item completed-word">
  157. <div class="word-refine-main">
  158. <span class="word-complete-check" aria-hidden="true"><lucide-icon [img]="icons.Check" [size]="16" /></span>
  159. <button type="button" class="word-reveal" [attr.aria-expanded]="selectionWordRevealed(word)" (click)="revealSelectionWord(word)">
  160. <strong>{{ word.word }}</strong>
  161. <small [class.revealed]="selectionWordRevealed(word)">{{ selectionWordRevealed(word) ? (word.meaning || '暂无释义') : selectionCompletionLabel(word) + ' · 点击查看释义' }}</small>
  162. </button>
  163. <span class="audio" aria-hidden="true"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
  164. </div>
  165. @if (!wordHistoricallyLearned(word)) {
  166. <div class="completed-word-action">
  167. <span>本次已标记为认识</span>
  168. <button type="button" (click)="markWordUnknown(word)">改为还没学</button>
  169. </div>
  170. }
  171. </article>
  172. } @empty {
  173. <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>还没有学完的词</span></div>
  174. }
  175. </div>
  176. </section>
  177. }
  178. </div>
  179. } @else {
  180. <div class="word-select-list">
  181. @for (word of words(); track word.id) {
  182. <button type="button" [class.selected]="selected().has(word.id)" (click)="toggleWord(word)">
  183. <span class="check">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }</span>
  184. <span><strong>{{ word.word }}</strong><small>{{ word.meaning || '暂无释义' }}</small></span>
  185. <span class="audio" (click)="$event.stopPropagation(); play(word)"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
  186. </button>
  187. } @empty {
  188. <div class="empty-state"><p>暂无可选词汇</p></div>
  189. }
  190. </div>
  191. }
  192. @if (meta.path === 'pages/member/starshop') {
  193. <div class="sticky-actions single">
  194. <button type="button" class="danger-button" (click)="deleteSelectedNewWords()"><lucide-icon [img]="icons.Trash2" [size]="18" />删除所选</button>
  195. </div>
  196. }
  197. } @else if (isStudyListPage) {
  198. <div class="stage-banner">
  199. <div>
  200. <span>第 {{ studyStep() }} / 3 轮</span>
  201. <strong>{{ studyStep() === 1 ? '顺序学习' : studyStep() === 2 ? '乱序学习' : '中文挑战' }}</strong>
  202. </div>
  203. <button type="button" title="重新乱序" aria-label="重新乱序" (click)="shuffleStudyWords()"><lucide-icon [img]="icons.RefreshCw" [size]="19" /></button>
  204. </div>
  205. <div class="repeat-list">
  206. @for (word of words(); track word.id; let index = $index) {
  207. <button type="button" (click)="repeatWord(word, index)">
  208. <span>
  209. <strong>{{ studyStep() === 3 ? word.meaning : word.word }}</strong>
  210. @if ((repetitions()[word.id] || 0) >= 2) { <small>{{ studyStep() === 3 ? word.word : word.meaning }}</small> }
  211. </span>
  212. <span class="repeat-dots" aria-label="跟读次数">
  213. @for (dot of [1, 2, 3]; track dot) { <i [class.done]="(repetitions()[word.id] || 0) >= dot"></i> }
  214. </span>
  215. </button>
  216. }
  217. </div>
  218. <div class="sticky-actions">
  219. <button type="button" class="secondary-button" (click)="shuffleStudyWords()"><lucide-icon [img]="icons.RefreshCw" [size]="18" />再次乱序</button>
  220. <button type="button" class="primary-button" (click)="skipStudyList()">跳到下一关<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button>
  221. </div>
  222. } @else if (isMatchingPage) {
  223. @if (completed()) {
  224. <ng-container *ngTemplateOutlet="stageComplete; context: { title: '配对完成', text: '听音识意已经准备好了' }" />
  225. } @else {
  226. <div class="game-heading"><span>单词对对碰</span><strong>剩余 {{ matchLeft().length }} 组</strong></div>
  227. <div class="match-grid">
  228. <div>
  229. @for (word of matchLeft(); track word.id) {
  230. <button type="button" [class.active]="leftChoice() === word.id" (click)="chooseMatch('left', word)"><lucide-icon [img]="icons.Volume2" [size]="17" />{{ word.word }}</button>
  231. }
  232. </div>
  233. <div>
  234. @for (word of matchRight(); track word.id) {
  235. <button type="button" [class.active]="rightChoice() === word.id" (click)="chooseMatch('right', word)">{{ word.meaning || '暂无释义' }}</button>
  236. }
  237. </div>
  238. </div>
  239. }
  240. } @else if (isListeningPage) {
  241. @if (completed()) {
  242. <ng-container *ngTemplateOutlet="stageComplete; context: { title: '听音识意完成', text: '下一关是组合拼写' }" />
  243. } @else {
  244. @if (current(); as word) {
  245. <div class="study-progress"><div><span [style.width.%]="progress()"></span></div><strong>{{ currentIndex() + 1 }} / {{ words().length }}</strong></div>
  246. <div class="listen-panel">
  247. <button type="button" class="listen-button" aria-label="播放单词" (click)="play(word)"><lucide-icon [img]="icons.Headphones" [size]="34" /></button>
  248. <p>点击喇叭,选择正确释义</p>
  249. </div>
  250. <div class="option-list">
  251. @for (option of choiceOptions(); track option.id) {
  252. <button type="button" (click)="chooseMeaning(option)">{{ option.meaning || option.word }}</button>
  253. }
  254. </div>
  255. }
  256. }
  257. } @else if (isSpellingPage) {
  258. @if (completed()) {
  259. <ng-container *ngTemplateOutlet="stageComplete; context: { title: '拼写完成', text: '本轮单词已全部完成' }" />
  260. } @else {
  261. @if (current(); as word) {
  262. <div class="study-progress"><div><span [style.width.%]="progress()"></span></div><strong>{{ currentIndex() + 1 }} / {{ words().length }}</strong></div>
  263. <article class="spelling-card">
  264. <button type="button" class="audio-main" aria-label="播放读音" title="播放读音" (click)="play(word)"><lucide-icon [img]="icons.Headphones" [size]="25" /></button>
  265. <p>{{ word.meaning || '请根据读音拼写单词' }}</p>
  266. <div class="assembled" [class.correct]="spellingCorrect()">{{ assembled() || ' ' }}</div>
  267. <div class="letter-grid">
  268. @for (letter of letters(); track $index) {
  269. <button type="button" [disabled]="letter.used" (click)="spellLetter($index)">{{ letter.value }}</button>
  270. }
  271. </div>
  272. </article>
  273. <div class="sticky-actions" [class.teacher-spelling-actions]="meta.path === 'pages/stu/pl_stu5'">
  274. <button type="button" class="secondary-button" (click)="resetSpelling()"><lucide-icon [img]="icons.RotateCcw" [size]="18" />重拼</button>
  275. @if (meta.path === 'pages/stu/pl_stu5') { <button type="button" class="secondary-button" (click)="skipSpellingWord()">{{ currentIndex() === words().length - 1 ? '跳过并完成' : '跳过此词' }}</button> }
  276. <button type="button" class="primary-button" [disabled]="!spellingCorrect()" (click)="nextSpellingWord()">{{ currentIndex() === words().length - 1 ? '完成本关' : '下一个' }}<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button>
  277. </div>
  278. }
  279. }
  280. } @else if (isReviewPage) {
  281. <div class="review-summary">
  282. <div><strong>{{ words().length }}</strong><span>学习词数</span></div>
  283. <div><strong>{{ reviewCount(1) }}</strong><span>已掌握</span></div>
  284. <div><strong>{{ reviewCount(2) }}</strong><span>待加强</span></div>
  285. </div>
  286. <div class="review-list">
  287. @for (word of words(); track word.id) {
  288. <article>
  289. <button type="button" class="word-audio" (click)="play(word)"><span><strong>{{ word.word }}</strong><small>{{ word.meaning }}</small></span><lucide-icon [img]="icons.Volume2" [size]="18" /></button>
  290. <div class="review-buttons">
  291. <button type="button" [class.active]="reviewValue(word) === 1" (click)="markReview(word, 1)"><lucide-icon [img]="icons.Check" [size]="18" />已掌握</button>
  292. <button type="button" [class.active]="reviewValue(word) === 2" (click)="markReview(word, 2)"><lucide-icon [img]="icons.X" [size]="18" />待加强</button>
  293. </div>
  294. </article>
  295. }
  296. </div>
  297. <div class="sticky-actions">
  298. <button type="button" class="secondary-button" (click)="markAllKnown()">{{ reviewCount(1) === words().length ? '取消全选' : '全部掌握' }}</button>
  299. <button type="button" class="primary-button" [disabled]="submitting()" (click)="submitReview()">{{ meta.path === 'pages/stu/pl_end' ? '结束课程' : '提交检测' }}</button>
  300. </div>
  301. } @else if (isCalendarPage) {
  302. <section class="memory-overview" aria-label="抗遗忘计划概览">
  303. <lucide-icon class="memory-watermark" [img]="icons.BrainCircuit" [size]="156" aria-hidden="true" />
  304. <header>
  305. <div class="memory-copy">
  306. <span class="memory-kicker"><lucide-icon [img]="icons.Activity" [size]="14" />{{ canStartPointReading ? '关联学员抗遗忘任务' : '21 天智能复习引擎' }}</span>
  307. <strong>{{ canStartPointReading ? '带学生及时复习' : '记忆巩固进行中' }}</strong>
  308. <small>{{ canStartPointReading ? '查看学生姓名、复习日期和完成情况,并从任务中直接开始带练' : '在遗忘临界点及时复习,让记忆一次次回升' }}</small>
  309. </div>
  310. <div class="memory-score" role="progressbar" aria-label="总体完成度" aria-valuemin="0" aria-valuemax="100" [attr.aria-valuenow]="memoryProgress()"><b>{{ memoryProgress() }}%</b><small>总体完成</small></div>
  311. </header>
  312. <figure class="memory-curve" aria-labelledby="forgetting-curve-title">
  313. <figcaption><span id="forgetting-curve-title">21 天抗遗忘原理</span><strong><i></i>临界点复习</strong></figcaption>
  314. <div class="memory-curve-chart">
  315. <svg viewBox="0 0 320 128" role="img" aria-label="自然遗忘曲线逐渐下降,长期记忆曲线逐渐上升;在五个交点及时复习后,遗忘速度越来越慢">
  316. <line class="memory-chart-axis" x1="26" y1="10" x2="26" y2="98" />
  317. <line class="memory-chart-axis" x1="26" y1="98" x2="304" y2="98" />
  318. <path class="memory-forgetting-line" [attr.d]="antiForgetChart.forgettingPath" />
  319. <path class="memory-learning-line" [attr.d]="antiForgetChart.learningPath" />
  320. @for (event of antiForgetChart.reviewEvents; track event.day) {
  321. <path class="memory-review-decay" [attr.d]="event.path" />
  322. <line class="memory-review-lift" [style.--pulse-delay]="(((event.day - 1) * 0.45) + 's')" [attr.x1]="event.x" [attr.y1]="event.y" [attr.x2]="event.x" y2="12" />
  323. <circle class="memory-review-ripple" [style.--pulse-delay]="(((event.day - 1) * 0.45) + 's')" [attr.cx]="event.x" [attr.cy]="event.y" r="4" />
  324. <circle class="memory-review-dot" [attr.cx]="event.x" [attr.cy]="event.y" r="3" />
  325. <circle class="memory-reset-dot" [attr.cx]="event.x" cy="12" r="2.5" />
  326. <text class="memory-review-label" [attr.x]="event.x + 4" [attr.y]="event.y - 4">复习{{ $index + 1 }}</text>
  327. }
  328. <path class="memory-review-decay" [attr.d]="antiForgetChart.continuationPath" />
  329. <text class="memory-line-label forgetting" x="238" y="88">自然遗忘</text>
  330. <text class="memory-line-label learning" x="226" y="27">长期记忆</text>
  331. @for (point of antiForgetChart.days; track point.day) {
  332. <g class="memory-day-point" [style.--pulse-delay]="(($index * 0.45) + 's')">
  333. <circle class="memory-day-ripple" [attr.cx]="point.x" cy="106" r="2.8" />
  334. <circle class="memory-day-dot" [attr.cx]="point.x" cy="106" r="1.6" />
  335. </g>
  336. }
  337. <text x="26" y="122">第 1 天</text>
  338. <text x="109.4" y="122" text-anchor="middle">第 7 天</text>
  339. <text x="206.7" y="122" text-anchor="middle">第 14 天</text>
  340. <text x="304" y="122" text-anchor="end">第 21 天</text>
  341. </svg>
  342. </div>
  343. </figure>
  344. <div class="memory-stats">
  345. <span><strong>{{ memoryCompletedCount() }} / {{ memoryFilteredItems().length }}</strong><small>任务完成</small></span>
  346. <span><strong>{{ memoryDueCount() }}</strong><small>到期待复习</small></span>
  347. <span><strong>{{ memoryCalendar().scheduledDays }}</strong><small>本月安排天数</small></span>
  348. </div>
  349. </section>
  350. @if (canStartPointReading) {
  351. <section class="memory-student-filter" aria-label="筛选抗遗忘学员">
  352. <div><span>当前查看</span><strong>{{ memorySelectedStudentName() }}</strong></div>
  353. <label><span>学员</span><select aria-label="筛选抗遗忘学员" [value]="memoryStudentId()" (change)="selectMemoryStudent($event)"><option value="">全部学员({{ memoryStudents().length }}人)</option>@for (student of memoryStudents(); track student.id) { <option [value]="student.id">{{ student.name }}({{ student.taskCount }}项)</option> }</select></label>
  354. </section>
  355. }
  356. <section class="memory-calendar" aria-label="复习日历">
  357. <header>
  358. <div><span>复习日历</span><strong>{{ memoryCalendar().monthLabel }}</strong></div>
  359. <nav aria-label="切换月份">
  360. <button type="button" aria-label="上个月" (click)="shiftMemoryMonth(-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="18" /></button>
  361. <button type="button" class="current-month" (click)="showCurrentMemoryMonth()">本月</button>
  362. <button type="button" aria-label="下个月" (click)="shiftMemoryMonth(1)"><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button>
  363. </nav>
  364. </header>
  365. <div class="memory-week" aria-hidden="true"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
  366. <div class="memory-month-grid" role="grid" [attr.aria-label]="memoryCalendar().monthLabel + '复习安排'">
  367. @for (day of memoryCalendar().days; track day.date) {
  368. <button type="button" role="gridcell" class="memory-date" [class.outside]="!day.inCurrentMonth" [class.today]="day.isToday" [class.selected]="day.isSelected" [class.completed]="day.state === 'completed'" [class.due]="day.state === 'due'" [class.scheduled]="day.state === 'scheduled'" [attr.aria-label]="day.date + (day.total ? ',' + day.total + '项复习任务' : ',无复习任务')" (click)="selectMemoryDay(day)">
  369. <span>{{ day.day }}</span>
  370. @if (day.total) { <small>{{ day.completed }}/{{ day.total }}</small> }
  371. </button>
  372. }
  373. </div>
  374. <footer><span><i class="completed"></i>已完成</span><span><i class="due"></i>待复习</span><span><i class="scheduled"></i>未到期</span><span><i></i>无任务</span></footer>
  375. </section>
  376. <section class="memory-date-tasks" aria-live="polite">
  377. <header><div><span>当日复习</span><strong>{{ memorySelectedLabel() }}</strong></div><b>{{ memorySelectedTasks().length }} 项</b></header>
  378. <div class="calendar-list">
  379. @for (item of memorySelectedTasks(); track $index) {
  380. <article [class.ready]="calendarReady(item)" [class.completed]="calendarCompleted(item)">
  381. <span class="task-state"><i></i>{{ memoryTaskStatus(item) }}</span>
  382. <div class="task-copy"><span class="task-heading"><em class="review-sequence">第 {{ calendarReviewNumber(item) }} 次复习</em><strong>{{ calendarTitle(item) }}</strong></span><small class="task-context">{{ calendarStudent(item) }}<em>复习日期 {{ calendarDate(item) }}</em></small><small class="first-learned-at">{{ calendarFirstLearnedAt(item) }}</small></div>
  383. <button type="button" [disabled]="!calendarReady(item) && !calendarCompleted(item)" (click)="openCalendar(item)">{{ calendarCompleted(item) ? '查看' : calendarReady(item) ? (canStartPointReading ? '带学生复习' : '开始复习') : '未到期' }}</button>
  384. </article>
  385. } @empty {
  386. <div class="memory-empty"><lucide-icon [img]="icons.Clock3" [size]="26" /><strong>当天没有复习任务</strong><span>{{ canStartPointReading ? '选择有颜色标记的日期查看关联学员任务' : '可以选择有颜色标记的日期查看计划' }}</span></div>
  387. }
  388. </div>
  389. </section>
  390. } @else if (isRecordPage) {
  391. <section class="record-overview">
  392. <header><div><span>单词复习</span><strong>先回忆,再核对</strong></div><b>{{ seen().size }} / {{ words().length }}</b></header>
  393. <div><span><strong>{{ seen().size }}</strong><small>已核对</small></span><span><strong>{{ selected().size }}</strong><small>遗忘词</small></span></div>
  394. </section>
  395. <div class="record-tip">{{ orderInfo()['if_writing_tips'] || '复习单词并将遗忘词加入生词本' }}</div>
  396. <div class="record-list">
  397. @for (word of words(); track word.id; let index = $index) {
  398. <article [class.selected]="selected().has(word.id)" [class.revealed]="recordRevealed().has(word.id)">
  399. <button type="button" class="record-check" [attr.aria-label]="(selected().has(word.id) ? '取消标记 ' : '标记遗忘词 ') + word.word" (click)="toggleWord(word)">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="17" /> }</button>
  400. <button type="button" class="record-word" [attr.aria-label]="recordRevealed().has(word.id) ? '隐藏中文释义' : '播放并查看中文释义'" (click)="toggleRecordAnswer(word)">
  401. <span class="record-index">{{ index + 1 }}</span>
  402. <span class="record-copy"><small>{{ recordRevealed().has(word.id) ? '中文释义' : '英文' }}</small><strong>{{ recordRevealed().has(word.id) ? (word.meaning || '暂无中文释义') : word.word }}</strong></span>
  403. <lucide-icon [img]="icons.Volume2" [size]="18" />
  404. </button>
  405. </article>
  406. }
  407. </div>
  408. <div class="sticky-actions">
  409. @if (!isWritingRecord()) { <button type="button" class="secondary-button" (click)="addRecordWords()">加入生词本</button> }
  410. <button type="button" class="primary-button" [disabled]="submitting()" (click)="endRecord()">结束本次复习</button>
  411. </div>
  412. } @else if (isAntiForgetPage) {
  413. <div class="anti-heading"><div><strong>今日复习任务</strong><span>已完成 {{ seen().size }} / {{ words().length }}</span></div><span>勾选遗忘词可加入生词本</span></div>
  414. <div class="anti-grid">
  415. @for (word of words(); track word.id) {
  416. <article [class.seen]="seen().has(word.id)">
  417. <button type="button" class="anti-check" [class.active]="selected().has(word.id)" aria-label="标记遗忘" (click)="toggleWord(word)">@if (selected().has(word.id)) { <lucide-icon [img]="icons.Check" [size]="16" /> }</button>
  418. <button type="button" class="anti-word" (click)="toggleSeen(word)">{{ seen().has(word.id) ? (word.meaning || word.word) : word.word }}</button>
  419. </article>
  420. }
  421. </div>
  422. <div class="sticky-actions single"><button type="button" class="primary-button" (click)="joinAntiWords()">加入生词本</button></div>
  423. } @else if (isExamPage) {
  424. @if (examSubmitted()) {
  425. <div class="report">
  426. <div class="report-icon"><lucide-icon [img]="icons.Flag" [size]="34" /></div>
  427. <h2>测试结果已保存</h2>
  428. <p>本次答对 {{ examScore() }} / {{ questions().length }} 题</p>
  429. <div class="report-numbers">
  430. <div><strong>{{ examScore() }}</strong><span>认识</span></div>
  431. <div><strong>{{ questions().length - examScore() }}</strong><span>待复习</span></div>
  432. <div><strong>{{ questions().length }}</strong><span>总题数</span></div>
  433. </div>
  434. <button type="button" class="primary-button" (click)="restart()"><lucide-icon [img]="icons.RotateCcw" [size]="18" />换一套再测</button>
  435. </div>
  436. } @else if (!examStarted()) {
  437. <div class="exam-intro">
  438. <div class="exam-mark"><lucide-icon [img]="icons.Flag" [size]="34" /></div>
  439. <h2>{{ askInfo()['Title'] || askInfo()['AskName'] || '词汇测试' }}</h2>
  440. <p>共 {{ questions().length }} 题,每题限时 10 秒</p>
  441. @if (previousExam()['GeneralID']) {
  442. <section class="previous-exam">
  443. <header><span>上次测试结果</span><strong>{{ examScoreValue(previousExam()) }} / {{ previousExam()['totalScore'] || questions().length }}</strong></header>
  444. <div class="previous-stats">
  445. <span><strong>{{ previousExam()['totalScore'] || questions().length }}</strong><small>测试</small></span>
  446. <span><strong>{{ examScoreValue(previousExam()) }}</strong><small>认识</small></span>
  447. <span><strong>{{ previousExam()['dontKnow'] || 0 }}</strong><small>不认识</small></span>
  448. <span><strong>{{ previousExam()['wrong'] || 0 }}</strong><small>选错</small></span>
  449. </div>
  450. <div class="exam-guidance"><strong>结果分析</strong><p>{{ examAnalysis(previousExam()) }}</p><strong>学习建议</strong><p>{{ examAdvice(previousExam()) }}</p></div>
  451. </section>
  452. }
  453. <button type="button" class="primary-button" [disabled]="!questions().length" (click)="startExam()"><lucide-icon [img]="icons.Play" [size]="18" />开始测试</button>
  454. </div>
  455. } @else {
  456. @if (currentQuestion(); as question) {
  457. <div class="exam-status"><span>第 {{ currentIndex() + 1 }} / {{ questions().length }} 题</span><strong><lucide-icon [img]="icons.Clock3" [size]="17" />{{ timer() }}s</strong></div>
  458. <article class="question-card">
  459. @if (question.QTitle) { <p class="question-word">{{ question.QTitle }}</p> }
  460. <div class="exam-options">
  461. @for (option of question.QOption; track $index) { <button type="button" (click)="answerExam(option, $index)">{{ option.text }}</button> }
  462. </div>
  463. </article>
  464. <div class="sticky-actions single"><button type="button" class="secondary-button" (click)="skipExamQuestion()">不认识,下一题</button></div>
  465. }
  466. }
  467. } @else if (isReportPage || completed()) {
  468. <div class="report">
  469. <div class="report-icon"><lucide-icon [img]="icons.Star" [size]="34" /></div>
  470. <h2>本次学习完成</h2>
  471. <p>坚持是最稳的进步方式</p>
  472. <div class="report-numbers">
  473. <div><strong>{{ words().length }}</strong><span>学习词数</span></div>
  474. <div><strong>{{ correct() }}</strong><span>已掌握</span></div>
  475. <div><strong>{{ unknown().length }}</strong><span>待复习</span></div>
  476. </div>
  477. <button type="button" class="primary-button" (click)="restart()"><lucide-icon [img]="icons.RotateCcw" [size]="18" />再学一次</button>
  478. <button type="button" class="secondary-button" (click)="leave()">返回首页</button>
  479. </div>
  480. } @else {
  481. <div class="empty-state"><div><lucide-icon [img]="icons.BookOpen" [size]="34" /><p>暂无学习内容</p></div></div>
  482. }
  483. </section>
  484. <ng-template #stageComplete let-title="title" let-text="text">
  485. <div class="report compact-report">
  486. <div class="report-icon"><lucide-icon [img]="icons.CheckCheck" [size]="34" /></div>
  487. <h2>{{ title }}</h2>
  488. <p>{{ text }}</p>
  489. <button type="button" class="primary-button" (click)="continueAfterStage()">继续下一关<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button>
  490. </div>
  491. </ng-template>