learning-page.component.html 42 KB

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