Просмотр исходного кода

fix(mobile): save and exit reading exercises clearly

彭峰 1 месяц назад
Родитель
Сommit
d0f5d33845

+ 8 - 1
projects/xiaoshu-mobile/src/app/app.routes.ts

@@ -1,4 +1,4 @@
-import { Route, Routes } from '@angular/router';
+import { CanDeactivateFn, Route, Routes } from '@angular/router';
 import { unavailableReason } from './core/feature-policy';
 import { authGuard } from './core/auth.guard';
 import { PageKind } from './core/models';
@@ -7,6 +7,12 @@ import { AppShellComponent } from './layout/app-shell.component';
 
 type ComponentLoader = Exclude<Route['loadComponent'], undefined>;
 
+interface ConfirmableReadingPage {
+  canDeactivate(): boolean | Promise<boolean>;
+}
+
+const canLeaveReadingExercise: CanDeactivateFn<ConfirmableReadingPage> = (component) => component.canDeactivate();
+
 const components: Record<PageKind, ComponentLoader> = {
   home: () => import('./features/home/home-page.component').then((module) => module.HomePageComponent),
   account: () => import('./features/home/account-page.component').then((module) => module.AccountPageComponent),
@@ -61,6 +67,7 @@ export const routes: Routes = [
         path: 'words/reading',
         loadComponent: () => import('./features/english-reading/reading-exercise.component').then((module) => module.ReadingExerciseComponent),
         canActivate: [authGuard],
+        canDeactivate: [canLeaveReadingExercise],
         data: { meta: { path: 'words/reading', title: '阅读练习', domain: 'learn', kind: 'learning', hideHeader: true, hideTabbar: true } },
       },
       {

+ 19 - 3
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.html

@@ -1,5 +1,9 @@
 <section class="exercise-page">
-  <header class="exercise-toolbar"><span>{{ article()?.content?.split(' ')?.length || 0 }} words</span><strong>Reading Exercise</strong><button type="button" (click)="print()" [disabled]="!article()?.objectId"><lucide-icon [img]="icons.FileDown" [size]="17" />打印</button></header>
+  <header class="exercise-toolbar">
+    <button type="button" class="toolbar-back" (click)="returnToOrigin()" aria-label="返回原页面"><lucide-icon [img]="icons.ArrowLeft" [size]="19" /><span>返回</span></button>
+    <div class="toolbar-title"><strong>阅读理解</strong><small>{{ article()?.objectId ? (article()?.content?.split(' ')?.length || 0) + ' words · 内容已自动保存' : '正在准备内容' }}</small></div>
+    <button type="button" class="toolbar-print" (click)="print()" [disabled]="!article()?.objectId || saving()"><lucide-icon [img]="icons.FileDown" [size]="17" />打印</button>
+  </header>
 
   @if (error()) {
     <div class="page-state error-state"><lucide-icon [img]="icons.XCircle" [size]="30" /><strong>加载阅读练习失败</strong><p>{{ error() }}</p><button type="button" class="secondary-button" (click)="load()">重新加载</button></div>
@@ -17,7 +21,7 @@
         @for (question of questions(); track question.objectId; let questionIndex = $index) {
           <article class="question-block">
             <header><span>Q{{ questionIndex + 1 }}</span><strong>{{ question.title }}</strong>@if (submitted()) { <lucide-icon [img]="isCorrect(question, questionIndex) ? icons.CheckCircle2 : icons.XCircle" [class.correct]="isCorrect(question, questionIndex)" [size]="21" /> }</header>
-            <div class="options-grid">@for (option of question.options; track option.value; let optionIndex = $index) { <button type="button" class="option" [class.selected]="answers[questionIndex] === option.value" [class.correct-option]="submitted() && option.check" [class.wrong-option]="submitted() && answers[questionIndex] === option.value && !option.check" (click)="selectOption(questionIndex, option.value)"><span>{{ optionLabels[optionIndex] }}</span><b>{{ option.value }}</b>@if (submitted() && option.check) { <lucide-icon [img]="icons.CheckCircle2" [size]="18" /> }</button> }</div>
+            <div class="options-grid">@for (option of question.options; track option.value; let optionIndex = $index) { <button type="button" class="option" [disabled]="submitted() || saving()" [class.selected]="answers[questionIndex] === option.value" [class.correct-option]="submitted() && option.check" [class.wrong-option]="submitted() && answers[questionIndex] === option.value && !option.check" (click)="selectOption(questionIndex, option.value)"><span>{{ optionLabels[optionIndex] }}</span><b>{{ option.value }}</b>@if (submitted() && option.check) { <lucide-icon [img]="icons.CheckCircle2" [size]="18" /> }</button> }</div>
             @if (submitted()) { <div class="answer-analysis"><strong>解析</strong><p>{{ question.answer || '请结合原文定位正确答案。' }}</p>@for (option of question.options; track option.value) { @if (option.answer) { <small><b>{{ option.value }}:</b>{{ option.answer }}</small> } }</div> }
           </article>
         }
@@ -25,7 +29,19 @@
     }
   }
 
-  @if (!loading() && !error()) { <footer class="answer-footer"><button type="button" class="primary-button submit-button" [class.retry]="submitted()" [disabled]="!submitted() && answeredCount < questions().length" (click)="submit()"><lucide-icon [img]="submitted() ? icons.RotateCcw : icons.CheckCircle2" [size]="18" />{{ submitted() ? (score() === questions().length ? '已完成' : '再试一次') : '提交答案' }}({{ answeredCount }}/{{ questions().length }})</button><button type="button" class="footer-tool" (click)="openHistory()"><lucide-icon [img]="icons.History" [size]="19" /><span>{{ 3 - remainingArticles() }}/3</span></button>@if (submitted() && score() === questions().length && remainingArticles() > 0) { <button type="button" class="footer-tool new-button" (click)="createNewArticle()"><lucide-icon [img]="icons.CirclePlus" [size]="19" /><span>新文章</span></button> }</footer> }
+  @if (!loading() && !error()) {
+    <footer class="answer-footer">
+      <div class="answer-footer-inner">
+        <button type="button" class="primary-button submit-button" [class.retry]="saveFailed()" [disabled]="saving() || (!submitted() && (!questions().length || answeredCount < questions().length))" (click)="primaryAction()">
+          <lucide-icon [img]="saving() ? icons.LoaderCircle : icons.CheckCircle2" [class.spin]="saving()" [size]="18" />
+          <span>{{ primaryButtonLabel }}({{ answeredCount }}/{{ questions().length }})</span>
+        </button>
+        @if (submitted()) { <button type="button" class="footer-tool retry-button" (click)="reset()"><lucide-icon [img]="icons.RotateCcw" [size]="19" /><span>再做一次</span></button> }
+        <button type="button" class="footer-tool" [disabled]="saving()" (click)="openHistory()"><lucide-icon [img]="icons.History" [size]="19" /><span>历史 {{ 3 - remainingArticles() }}/3</span></button>
+        @if (submitted() && score() === questions().length && remainingArticles() > 0) { <button type="button" class="footer-tool new-button" (click)="createNewArticle()"><lucide-icon [img]="icons.CirclePlus" [size]="19" /><span>新文章</span></button> }
+      </div>
+    </footer>
+  }
 </section>
 
 @if (historyOpen()) { <div class="dialog-backdrop" (click)="closeHistory()"><section class="history-dialog" role="dialog" aria-modal="true" (click)="$event.stopPropagation()"><header><div><h2>阅读历史</h2><p>相同生词最多生成三篇文章</p></div><button type="button" (click)="closeHistory()" aria-label="关闭" title="关闭"><lucide-icon [img]="icons.X" [size]="20" /></button></header><div class="history-list">@if (historyLoading()) { <div class="dialog-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="24" />正在加载</div> } @else if (!historyItems().length) { <div class="dialog-state"><lucide-icon [img]="icons.BookOpen" [size]="28" />暂无阅读历史</div> } @else { @for (item of historyItems(); track item.article.objectId) { <button type="button" class="history-item" (click)="selectHistory(item)"><div><strong>{{ item.article.title }}</strong><p>{{ item.article.createOptions.params.level }} · {{ item.article.createOptions.params.type }}</p></div><span>{{ item.attemptCount ? item.bestScore + '分' : '未练习' }}</span><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button> } @if (historyHasMore()) { <button type="button" class="load-more" [disabled]="historyLoadingMore()" (click)="loadMoreHistory()">{{ historyLoadingMore() ? '加载中...' : '加载更多' }}</button> } @else { <p class="no-more">已经显示全部历史记录</p> } }</div></section></div> }

+ 14 - 6
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.scss

@@ -1,9 +1,12 @@
 :host { display: block; min-height: 100%; background: var(--surface-muted); }
 .exercise-page { min-height: 100%; padding-bottom: 86px; }
 .exercise-toolbar { position: sticky; z-index: 4; top: 0; display: grid; min-height: 46px; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 16px; background: white; border-bottom: 1px solid var(--line); }
-.exercise-toolbar > span { color: var(--ink-soft); font-size: 11px; }
-.exercise-toolbar strong { font-size: 14px; }
-.exercise-toolbar button { display: inline-flex; align-items: center; justify-self: end; padding: 7px 9px; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 4px; font-size: 12px; gap: 5px; }
+.exercise-toolbar button { display: inline-flex; align-items: center; padding: 7px 9px; color: var(--brand-strong); background: var(--brand-pale); border: 0; border-radius: 7px; font-size: 12px; gap: 5px; }
+.exercise-toolbar .toolbar-back { justify-self: start; }
+.exercise-toolbar .toolbar-print { justify-self: end; }
+.toolbar-title { display: flex; align-items: center; flex-direction: column; }
+.toolbar-title strong { font-size: 14px; }
+.toolbar-title small { color: var(--ink-soft); font-size: 9px; white-space: nowrap; }
 .article-section, .questions-section { max-width: 860px; margin: 0 auto; background: white; border-inline: 1px solid var(--line); }
 .article-section { padding: 24px 26px 28px; border-bottom: 1px solid var(--line); }
 .article-section h2 { margin: 14px 0 18px; font-size: 23px; line-height: 1.3; letter-spacing: 0; }
@@ -24,6 +27,7 @@
 .question-block > header lucide-icon.correct { color: var(--brand-strong); }
 .options-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; gap: 9px; }
 .option { display: grid; min-height: 54px; grid-template-columns: 30px minmax(0, 1fr) auto; align-items: center; padding: 8px 10px; color: var(--ink); text-align: left; background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); gap: 9px; }
+.option:disabled { opacity: 1; }
 .option > span { display: grid; width: 28px; height: 28px; color: var(--ink-soft); background: var(--surface-muted); border-radius: 50%; font-size: 12px; font-weight: 700; place-items: center; }
 .option b { min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 550; }
 .option.selected { border-color: var(--brand-strong); box-shadow: 0 0 0 2px rgb(39 125 105 / 10%); }
@@ -34,10 +38,14 @@
 .answer-analysis > strong { font-size: 13px; }
 .answer-analysis p { margin: 5px 0; color: var(--ink-soft); font-size: 12px; line-height: 1.55; }
 .answer-analysis small { display: block; margin-top: 6px; color: var(--ink-soft); line-height: 1.5; }
-.answer-footer { position: fixed; z-index: 5; display: flex; right: 0; bottom: 0; left: 0; min-height: 68px; align-items: center; justify-content: center; padding: 10px 16px; background: white; border-top: 1px solid var(--line); box-shadow: 0 -7px 20px rgb(24 54 47 / 8%); gap: 8px; }
-.submit-button { display: flex; width: min(550px, calc(100% - 150px)); align-items: center; justify-content: center; gap: 7px; }
+.answer-footer { position: fixed; z-index: 5; right: 0; bottom: 0; left: 0; min-height: 68px; padding: 10px 16px; background: white; border-top: 1px solid var(--line); box-shadow: 0 -7px 20px rgb(24 54 47 / 8%); }
+.answer-footer-inner { display: flex; width: min(860px, 100%); align-items: center; justify-content: center; margin: 0 auto; gap: 8px; }
+.submit-button { display: flex; min-width: 180px; max-width: 550px; align-items: center; justify-content: center; flex: 1; gap: 7px; }
+.submit-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .submit-button.retry { background: #a97720; }
 .footer-tool { display: flex; min-width: 64px; min-height: 44px; align-items: center; justify-content: center; color: var(--ink-soft); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); flex-direction: column; font-size: 10px; gap: 2px; }
+.footer-tool:disabled { opacity: .55; }
+.footer-tool.retry-button { min-width: 82px; color: #8c6118; border-color: #d6b679; }
 .footer-tool.new-button { color: var(--brand-strong); border-color: var(--brand); }
 .page-state { display: flex; min-height: 360px; align-items: center; justify-content: center; padding: 24px; color: var(--ink-soft); text-align: center; flex-direction: column; gap: 8px; }
 .page-state p { margin: 0 0 10px; }
@@ -55,4 +63,4 @@
 .load-more { display: block; min-height: 42px; margin: 12px auto; padding: 8px 18px; color: var(--brand-strong); background: white; border: 1px solid var(--brand); border-radius: var(--radius-sm); }.no-more { margin: 14px; color: var(--ink-soft); text-align: center; font-size: 11px; }
 .result-backdrop { position: fixed; z-index: 40; display: grid; inset: 0; padding: 18px; background: rgb(14 25 22 / 60%); place-items: center; }.result-dialog { width: min(420px, 100%); padding: 25px 22px 22px; text-align: center; background: white; border-top: 4px solid var(--danger); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); }.result-dialog.success { border-top-color: var(--brand-strong); }.result-dialog.warning { border-top-color: var(--accent); }.result-dialog h2 { margin: 10px 0 7px; font-size: 21px; letter-spacing: 0; }.result-dialog p { min-height: 42px; margin: 0 0 18px; color: var(--ink-soft); line-height: 1.5; white-space: pre-line; }.result-dialog button { width: 100%; }.result-icon { color: var(--danger); }.result-dialog.success .result-icon { color: var(--brand-strong); }.result-dialog.warning .result-icon { color: var(--accent); }
 .spin { animation: spin .8s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }@keyframes pulse { 50% { opacity: .45; } }
-@media (max-width: 620px) { .article-section, .question-block { padding-inline: 16px; }.article-section h2 { font-size: 20px; }.article-content { font-size: 16px; }.options-grid { grid-template-columns: 1fr; }.submit-button { width: min(100%, calc(100% - 72px)); }.answer-footer { padding-inline: 10px; }.new-button { position: absolute; right: 10px; bottom: 74px; background: white; box-shadow: 0 5px 15px rgb(24 54 47 / 14%); } }
+@media (max-width: 620px) { .exercise-toolbar { padding-inline: 10px; }.toolbar-back span { display: none; }.toolbar-title small { max-width: 190px; overflow: hidden; text-overflow: ellipsis; }.article-section, .question-block { padding-inline: 16px; }.article-section h2 { font-size: 20px; }.article-content { font-size: 16px; }.options-grid { grid-template-columns: 1fr; }.submit-button { min-width: 0; }.answer-footer { padding-inline: 10px; }.footer-tool { min-width: 58px; }.footer-tool.retry-button { min-width: 76px; }.new-button { position: absolute; right: 10px; bottom: 74px; background: white; box-shadow: 0 5px 15px rgb(24 54 47 / 14%); } }

+ 123 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.spec.ts

@@ -0,0 +1,123 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { PlatformService } from '../../core/platform.service';
+import { SessionService } from '../../core/session.service';
+import { EnglishReadingService } from './english-reading.service';
+import { ReadingCloudService } from './reading-cloud.service';
+import { ReadingExerciseComponent } from './reading-exercise.component';
+import { ReadingArticle, ReadingLog, ReadingQuestion } from './reading.models';
+
+describe('ReadingExerciseComponent', () => {
+  let fixture: ComponentFixture<ReadingExerciseComponent>;
+  let component: ReadingExerciseComponent;
+  let reading: jasmine.SpyObj<EnglishReadingService>;
+  let router: jasmine.SpyObj<Router>;
+  let platform: jasmine.SpyObj<PlatformService>;
+
+  const article: ReadingArticle = {
+    objectId:'article-1',
+    type:'reading',
+    title:'A story',
+    content:'apple river',
+    createOptions:{ tpl:'template', params:{ wordList:['apple'], count:120, stage:'小学', level:'三年级', type:'记叙文', theme:'校园' } },
+  };
+  const questions: ReadingQuestion[] = [
+    { objectId:'question-1', type:'reading-item', parent:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' }, index:0, title:'Question 1', options:[{ value:'A', check:true }, { value:'B', check:false }] },
+    { objectId:'question-2', type:'reading-item', parent:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' }, index:1, title:'Question 2', options:[{ value:'C', check:true }, { value:'D', check:false }] },
+  ];
+  const savedLog: ReadingLog = {
+    objectId:'log-1', surveyItem:{ __type:'Pointer', className:'SurveyItem', objectId:'article-1' },
+    answer:{ 'question-1':'A', 'question-2':'D' }, right:1, wrong:1, grade:50, type:'exam',
+  };
+
+  beforeEach(async () => {
+    reading = jasmine.createSpyObj<EnglishReadingService>('EnglishReadingService', ['saveLog']);
+    router = jasmine.createSpyObj<Router>('Router', ['navigate', 'navigateByUrl']);
+    router.navigate.and.resolveTo(true);
+    router.navigateByUrl.and.resolveTo(true);
+    platform = jasmine.createSpyObj<PlatformService>('PlatformService', ['toast', 'confirm']);
+    platform.confirm.and.resolveTo(true);
+    const cloud = jasmine.createSpyObj<ReadingCloudService>('ReadingCloudService', ['ensureUser']);
+
+    await TestBed.configureTestingModule({
+      imports:[ReadingExerciseComponent],
+      providers:[
+        { provide:ActivatedRoute, useValue:{ snapshot:{ queryParamMap:convertToParamMap({}) } } },
+        { provide:Router, useValue:router },
+        { provide:SessionService, useValue:{ groupId:()=>1, user:()=>({ userId:7 }), student:()=>null } },
+        { provide:PlatformService, useValue:platform },
+      ],
+    }).overrideComponent(ReadingExerciseComponent, {
+      set:{ providers:[
+        { provide:ReadingCloudService, useValue:cloud },
+        { provide:EnglishReadingService, useValue:reading },
+      ] },
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(ReadingExerciseComponent);
+    component = fixture.componentInstance;
+    component.params = {
+      name:'学生', phone:'', level:'三年级', wordList:['apple'], type:'记叙文', theme:'校园',
+      returnUrl:'/pages/his/his?date=2026-09-10',
+    };
+    component.article.set(article);
+    component.questions.set(questions);
+    component.answers = ['A', 'D'];
+  });
+
+  it('waits for the answer log to save before showing completion and blocks duplicate clicks', async () => {
+    let resolveSave!: (value: ReadingLog) => void;
+    reading.saveLog.and.returnValue(new Promise((resolve) => { resolveSave = resolve; }));
+
+    const saving = component.submit();
+    void component.submit();
+
+    expect(component.saving()).toBeTrue();
+    expect(component.submitted()).toBeFalse();
+    expect(reading.saveLog).toHaveBeenCalledTimes(1);
+
+    resolveSave(savedLog);
+    await saving;
+
+    expect(component.saving()).toBeFalse();
+    expect(component.submitted()).toBeTrue();
+    expect(component.score()).toBe(1);
+    expect(component.dialog()?.title).toBe('答题记录已保存');
+    expect(platform.toast).toHaveBeenCalledWith('答题记录已保存', 'success');
+  });
+
+  it('keeps all answers available for an idempotent retry when saving fails', async () => {
+    reading.saveLog.and.rejectWith(new Error('网络连接失败'));
+
+    await component.submit();
+
+    expect(component.answers).toEqual(['A', 'D']);
+    expect(component.submitted()).toBeFalse();
+    expect(component.saveFailed()).toBeTrue();
+    expect(component.primaryButtonLabel).toBe('保存失败,重新保存');
+    expect(platform.toast).toHaveBeenCalledWith('网络连接失败', 'error');
+  });
+
+  it('uses a separate completed action to return without resetting the saved attempt', async () => {
+    component.submitted.set(true);
+    component.score.set(1);
+
+    await component.primaryAction();
+
+    expect(component.answers).toEqual(['A', 'D']);
+    expect(router.navigateByUrl).toHaveBeenCalledWith('/pages/his/his?date=2026-09-10', { replaceUrl:true });
+    expect(reading.saveLog).not.toHaveBeenCalled();
+  });
+
+  it('asks before leaving only when selected answers have not been saved', async () => {
+    platform.confirm.and.resolveTo(false);
+    component.answers = ['A', ''];
+    expect(await component.canDeactivate()).toBeFalse();
+    expect(platform.confirm).toHaveBeenCalledWith('本次答案尚未提交保存,确定离开吗?');
+
+    platform.confirm.calls.reset();
+    component.submitted.set(true);
+    expect(await component.canDeactivate()).toBeTrue();
+    expect(platform.confirm).not.toHaveBeenCalled();
+  });
+});

+ 77 - 25
projects/xiaoshu-mobile/src/app/features/english-reading/reading-exercise.component.ts

@@ -1,13 +1,13 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostListener, inject, OnInit, signal } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
-import { BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, LucideAngularModule, RotateCcw, X, XCircle } from 'lucide-angular';
+import { ArrowLeft, BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, LucideAngularModule, RotateCcw, X, XCircle } from 'lucide-angular';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingArticle, ReadingHistoryEntry, ReadingProgress, ReadingQuestion, ReadingRouteParams } from './reading.models';
-import { highlightWords, normalizeReadingParams, shuffle } from './reading.utils';
+import { highlightWords, normalizeReadingParams, normalizeReadingReturnUrl, shuffle } from './reading.utils';
 
 interface ReadingDialog {
   title: string;
@@ -27,13 +27,15 @@ interface ReadingDialog {
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class ReadingExerciseComponent implements OnInit {
-  readonly icons = { BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, RotateCcw, X, XCircle };
+  readonly icons = { ArrowLeft, BookOpen, CheckCircle2, ChevronRight, CirclePlus, FileDown, History, LoaderCircle, RotateCcw, X, XCircle };
   readonly optionLabels = 'ABCDEFGHIJK'.split('');
   readonly article = signal<ReadingArticle | null>(null);
   readonly questions = signal<ReadingQuestion[]>([]);
   readonly loading = signal(true);
   readonly progress = signal<ReadingProgress>({ task: 'article', message: '正在准备阅读内容' });
   readonly submitted = signal(false);
+  readonly saving = signal(false);
+  readonly saveFailed = signal(false);
   readonly score = signal(0);
   readonly historyOpen = signal(false);
   readonly historyLoading = signal(false);
@@ -69,6 +71,15 @@ export class ReadingExerciseComponent implements OnInit {
   private readonly platform = inject(PlatformService);
 
   get answeredCount(): number { return this.answers.filter(Boolean).length; }
+  get hasUnsavedAnswers(): boolean { return this.answeredCount > 0 && !this.submitted(); }
+  get primaryButtonLabel(): string {
+    if (this.submitted()) return '完成并返回';
+    if (this.saving()) return '正在保存';
+    if (this.saveFailed()) return '保存失败,重新保存';
+    if (!this.questions().length) return '暂无可提交题目';
+    if (this.answeredCount < this.questions().length) return '请完成全部题目';
+    return '提交并保存';
+  }
   get articleHtml(): string {
     const article = this.article();
     return article ? highlightWords(article.content, article.createOptions.params.wordList) : '';
@@ -89,7 +100,7 @@ export class ReadingExerciseComponent implements OnInit {
   }
 
   selectOption(questionIndex: number, value: string): void {
-    if (this.submitted()) return;
+    if (this.submitted() || this.saving()) return;
     this.answers = [...this.answers];
     this.answers[questionIndex] = value;
   }
@@ -99,28 +110,48 @@ export class ReadingExerciseComponent implements OnInit {
   }
 
   async submit(): Promise<void> {
-    if (this.submitted()) { this.reset(); return; }
+    if (this.submitted() || this.saving()) return;
     if (this.answeredCount < this.questions().length) return;
+    const article = this.article();
+    if (!article) return;
     const score = this.questions().filter((question, index) => this.isCorrect(question, index)).length;
-    this.score.set(score);
-    this.submitted.set(true);
-    this.showScoreDialog(score, this.questions().length);
+    this.saving.set(true);
+    this.saveFailed.set(false);
     try {
-      const article = this.article();
-      if (article) await this.reading.saveLog(article, this.questions(), this.answers, score);
-    } catch (error) { this.showError(error); }
+      await this.reading.saveLog(article, this.questions(), this.answers, score);
+      this.score.set(score);
+      this.submitted.set(true);
+      this.platform.toast('答题记录已保存', 'success');
+      this.showScoreDialog(score, this.questions().length);
+    } catch (error) {
+      this.saveFailed.set(true);
+      this.showError(error);
+    } finally { this.saving.set(false); }
+  }
+
+  async primaryAction(): Promise<void> {
+    if (this.submitted()) {
+      await this.returnToOrigin();
+      return;
+    }
+    await this.submit();
   }
 
   reset(): void {
+    if (this.saving()) return;
     this.questions.set(shuffle(this.questions()).map((question) => ({ ...question, options: shuffle(question.options) })));
     this.answers = new Array(this.questions().length).fill('');
     this.score.set(0);
     this.submitted.set(false);
+    this.saveFailed.set(false);
+    this.dialog.set(null);
   }
 
   async print(): Promise<void> {
     const id = this.article()?.objectId;
-    if (id) await this.router.navigate(['/words/reading/pdf', id],{queryParams:{studentId:this.params.studentId}});
+    if (id) await this.router.navigate(['/words/reading/pdf', id], {
+      queryParams: { ...this.params, wordList:this.params.wordList.join(',') },
+    });
   }
 
   async createNewArticle(): Promise<void> {
@@ -164,10 +195,19 @@ export class ReadingExerciseComponent implements OnInit {
   async selectHistory(entry: ReadingHistoryEntry): Promise<void> {
     const values = entry.article.createOptions.params;
     this.closeHistory();
-    await this.router.navigate(['/words/reading'], {
-      queryParams: { ...values, studentId:this.readingLearner()?.userId, sid:entry.article.objectId, wordList: values.wordList.join(','), t: Date.now() },
+    const navigated = await this.router.navigate(['/words/reading'], {
+      queryParams: {
+        ...values,
+        studentId:this.readingLearner()?.userId,
+        sourceStudyId:this.params.sourceStudyId,
+        returnUrl:this.params.returnUrl,
+        sid:entry.article.objectId,
+        wordList:values.wordList.join(','),
+        t:Date.now(),
+      },
       replaceUrl: true,
     });
+    if (!navigated) return;
     this.params = { ...this.params, ...values, sid: entry.article.objectId, renew:false };
     await this.load();
   }
@@ -179,7 +219,7 @@ export class ReadingExerciseComponent implements OnInit {
       this.retryCount += 1;
       await this.load();
     } else if (current?.action === 'back') {
-      await this.goReadingPage();
+      await this.returnToOrigin();
     }
   }
 
@@ -190,6 +230,8 @@ export class ReadingExerciseComponent implements OnInit {
     this.questions.set([]);
     this.answers = [];
     this.submitted.set(false);
+    this.saving.set(false);
+    this.saveFailed.set(false);
     this.loadingTip.set(this.tips[Math.floor(Math.random() * this.tips.length)]);
     try {
       await this.cloud.ensureUser(this.readingLearner(), this.params.phone);
@@ -221,14 +263,14 @@ export class ReadingExerciseComponent implements OnInit {
     } catch (error) {
       const message = error instanceof Error ? error.message : '阅读内容加载失败';
       if (message.includes('Invalid session token')) {
-        await this.goReadingPage();
+        await this.returnToOrigin();
       } else {
         this.error.set(message);
         const canRetry = this.retryCount < this.maxRetry;
         this.dialog.set({
           title: '遇到问题',
           message: `${message}${canRetry ? `(剩余重试次数:${this.maxRetry - this.retryCount})` : ''}`,
-          buttonText: canRetry ? '立即重试' : '返回首页',
+          buttonText: canRetry ? '立即重试' : '返回原页面',
           tone: 'error',
           action: canRetry ? 'retry' : 'back',
         });
@@ -265,11 +307,11 @@ export class ReadingExerciseComponent implements OnInit {
 
   private showScoreDialog(score: number, total: number): void {
     if (score === total) {
-      this.dialog.set({ title: 'Great Job!', message: 'You completed all levels with perfect score!', buttonText: 'Awesome!', tone: 'success', action: 'close' });
+      this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n可以查看解析后完成返回。`, buttonText: '查看解析', tone: 'success', action: 'close' });
     } else if (score === 0) {
-      this.dialog.set({ title: 'Oops! Try Again', message: 'You missed all the questions this time.\nKeep practicing!', buttonText: "I'll Improve!", tone: 'error', action: 'close' });
+      this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n查看解析后可以重做,或完成返回。`, buttonText: '查看解析', tone: 'error', action: 'close' });
     } else {
-      this.dialog.set({ title: 'Good Effort!', message: `You got ${score} answers right!\nReview the mistakes to do better next time.`, buttonText: 'Think Twice...', tone: 'warning', action: 'close' });
+      this.dialog.set({ title: '答题记录已保存', message: `本次答对 ${score}/${total} 题。\n查看解析后可以重做,或完成返回。`, buttonText: '查看解析', tone: 'warning', action: 'close' });
     }
   }
 
@@ -278,10 +320,20 @@ export class ReadingExerciseComponent implements OnInit {
     this.remainingArticles.set(Math.max(0, 3 - history.length));
   }
 
-  private async goReadingPage(): Promise<void> {
-    await this.router.navigate(['/words/go-reading'], {
-      queryParams: { ...this.params, wordList: this.params.wordList.join(',') },
-    });
+  async returnToOrigin(): Promise<void> {
+    await this.router.navigateByUrl(normalizeReadingReturnUrl(this.params.returnUrl), { replaceUrl: true });
+  }
+
+  async canDeactivate(): Promise<boolean> {
+    if (!this.hasUnsavedAnswers) return true;
+    return this.platform.confirm('本次答案尚未提交保存,确定离开吗?');
+  }
+
+  @HostListener('window:beforeunload', ['$event'])
+  protectUnsavedAnswers(event: BeforeUnloadEvent): void {
+    if (!this.hasUnsavedAnswers) return;
+    event.preventDefault();
+    event.returnValue = '';
   }
 
   private async withTimeout<T>(task: Promise<T>, duration: number, name: string): Promise<T> {

+ 29 - 4
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -1,13 +1,13 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, ElementRef, inject, OnInit, signal, ViewChild } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
 import { ArrowLeft, Download, LoaderCircle, LucideAngularModule } from 'lucide-angular';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { EnglishReadingService } from './english-reading.service';
-import { ReadingArticle, ReadingQuestion } from './reading.models';
-import { highlightWords } from './reading.utils';
+import { ReadingArticle, ReadingQuestion, ReadingRouteParams } from './reading.models';
+import { highlightWords, normalizeReadingParams, normalizeReadingReturnUrl } from './reading.utils';
 
 @Component({
   selector: 'app-reading-pdf',
@@ -28,8 +28,10 @@ export class ReadingPdfComponent implements OnInit {
   readonly exporting = signal(false);
   readonly wechatHint = signal(false);
   readonly error = signal('');
+  params!: ReadingRouteParams;
 
   private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
   private readonly sid = this.route.snapshot.paramMap.get('sid') ?? '';
   private readonly cloud = inject(ReadingCloudService);
   private readonly reading = inject(EnglishReadingService);
@@ -47,6 +49,11 @@ export class ReadingPdfComponent implements OnInit {
   }
 
   async ngOnInit(): Promise<void> {
+    const raw = Object.fromEntries(this.route.snapshot.queryParamMap.keys.map((key) => {
+      const values = this.route.snapshot.queryParamMap.getAll(key);
+      return [key, values.length > 1 ? values : values[0]];
+    }));
+    this.params = normalizeReadingParams(raw);
     if (!this.sid) { this.error.set('未提供阅读文章编号'); this.loading.set(false); return; }
     try {
       await this.cloud.ensureUser(this.readingLearner());
@@ -59,7 +66,25 @@ export class ReadingPdfComponent implements OnInit {
     } finally { this.loading.set(false); }
   }
 
-  back(): void { history.back(); }
+  async back(): Promise<void> {
+    const article = this.article();
+    if (!article) {
+      await this.router.navigateByUrl(normalizeReadingReturnUrl(this.params?.returnUrl), { replaceUrl:true });
+      return;
+    }
+    const values = article.createOptions.params;
+    await this.router.navigate(['/words/reading'], {
+      queryParams: {
+        ...this.params,
+        ...values,
+        sid:article.objectId,
+        renew:false,
+        wordList:values.wordList.join(','),
+        t:Date.now(),
+      },
+      replaceUrl:true,
+    });
+  }
 
   async exportPdf(): Promise<void> {
     const element = this.pdfContent?.nativeElement;

+ 47 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.spec.ts

@@ -0,0 +1,47 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { PlatformService } from '../../core/platform.service';
+import { SessionService } from '../../core/session.service';
+import { EnglishReadingService } from './english-reading.service';
+import { ReadingCloudService } from './reading-cloud.service';
+import { ReadingSetupComponent } from './reading-setup.component';
+
+describe('ReadingSetupComponent', () => {
+  it('carries the learner, source record and original page into the exercise', async () => {
+    const router = jasmine.createSpyObj<Router>('Router', ['navigate', 'navigateByUrl']);
+    router.navigate.and.resolveTo(true);
+    const cloud = jasmine.createSpyObj<ReadingCloudService>('ReadingCloudService', ['ensureUser', 'updateUser'], { user:null });
+    cloud.ensureUser.and.resolveTo({ objectId:'user-1', sessionToken:'r:token' });
+    const reading = jasmine.createSpyObj<EnglishReadingService>('EnglishReadingService', ['findArticles', 'duplicateExists']);
+    reading.findArticles.and.resolveTo([]);
+    reading.duplicateExists.and.resolveTo(false);
+
+    await TestBed.configureTestingModule({
+      imports:[ReadingSetupComponent],
+      providers:[
+        { provide:ActivatedRoute, useValue:{ snapshot:{ queryParamMap:convertToParamMap({
+          GeneralID:'201', returnUrl:'/pages/his/his?date=2026-09-10', wordList:'apple,river', level:'三年级',
+        }) } } },
+        { provide:Router, useValue:router },
+        { provide:SessionService, useValue:{ groupId:()=>1, user:()=>({ userId:7, honeyName:'小树', mobile:'13800000000' }), student:()=>null } },
+        { provide:PlatformService, useValue:jasmine.createSpyObj<PlatformService>('PlatformService', ['toast']) },
+      ],
+    }).overrideComponent(ReadingSetupComponent, {
+      set:{ providers:[
+        { provide:ReadingCloudService, useValue:cloud },
+        { provide:EnglishReadingService, useValue:reading },
+      ] },
+    }).compileComponents();
+
+    const component = TestBed.createComponent(ReadingSetupComponent).componentInstance;
+    component.ngOnInit();
+    await component.start();
+
+    expect(router.navigate).toHaveBeenCalledWith(['/words/reading'], { queryParams:jasmine.objectContaining({
+      studentId:7,
+      sourceStudyId:'201',
+      returnUrl:'/pages/his/his?date=2026-09-10',
+      wordList:'apple,river',
+    }) });
+  });
+});

+ 15 - 6
projects/xiaoshu-mobile/src/app/features/english-reading/reading-setup.component.ts

@@ -9,7 +9,7 @@ import { SessionService } from '../../core/session.service';
 import { EnglishReadingService } from './english-reading.service';
 import { ReadingCloudService } from './reading-cloud.service';
 import { ReadingHistoryEntry, ReadingRouteParams } from './reading.models';
-import { normalizeReadingParams, READING_LEVELS, READING_THEMES, READING_TYPES, splitWords } from './reading.utils';
+import { normalizeReadingParams, normalizeReadingReturnUrl, READING_LEVELS, READING_THEMES, READING_TYPES, splitWords } from './reading.utils';
 
 @Component({
   selector: 'app-reading-setup',
@@ -41,6 +41,7 @@ export class ReadingSetupComponent implements OnInit {
   theme = '随机';
   private historyPageIndex = 0;
   private readonly historyPageSize = 10;
+  private routeParams!: ReadingRouteParams;
 
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
@@ -63,6 +64,7 @@ export class ReadingSetupComponent implements OnInit {
       return [key, values.length > 1 ? values : values[0]];
     }));
     const params = normalizeReadingParams(raw);
+    this.routeParams = params;
     const appUser = this.readingLearner();
     const ncloudUser = this.cloud.user;
     this.nickname = ncloudUser?.nickname || params.name || String(appUser?.honeyName || appUser?.userName || '');
@@ -77,9 +79,7 @@ export class ReadingSetupComponent implements OnInit {
   markNameDirty(): void { this.nameDirty.set(true); }
 
   goBack(): void {
-    const requestedUrl = this.route.snapshot.queryParamMap.get('returnUrl');
-    const fallbackUrl = '/pages/his/his';
-    const returnUrl = requestedUrl?.startsWith('/') && !requestedUrl.startsWith('//') ? requestedUrl : fallbackUrl;
+    const returnUrl = normalizeReadingReturnUrl(this.routeParams?.returnUrl);
     void this.router.navigateByUrl(returnUrl, { replaceUrl: true });
   }
 
@@ -115,7 +115,8 @@ export class ReadingSetupComponent implements OnInit {
       this.theme = selectedTheme;
       const params: ReadingRouteParams = {
         studentId:this.readingLearner()?.userId,
-        sourceStudyId:this.route.snapshot.queryParamMap.get('GeneralID')||'',
+        sourceStudyId:this.routeParams.sourceStudyId,
+        returnUrl:this.routeParams.returnUrl,
         name: this.nickname, phone: this.mobile, level: this.level, wordList: words,
         type: selectedType, theme: selectedTheme, t: Date.now(),
       };
@@ -165,7 +166,15 @@ export class ReadingSetupComponent implements OnInit {
     const params = entry.article.createOptions.params;
     this.closeHistory();
     await this.router.navigate(['/words/reading'], {
-      queryParams: { ...params, studentId:this.readingLearner()?.userId, sid:entry.article.objectId, wordList: params.wordList.join(','), t: Date.now() },
+      queryParams: {
+        ...params,
+        studentId:this.readingLearner()?.userId,
+        sourceStudyId:this.routeParams.sourceStudyId,
+        returnUrl:this.routeParams.returnUrl,
+        sid:entry.article.objectId,
+        wordList:params.wordList.join(','),
+        t:Date.now(),
+      },
     });
   }
 

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.models.ts

@@ -71,6 +71,7 @@ export interface ReadingLog extends ReadingRecord {
 export interface ReadingRouteParams {
   studentId?: number;
   sourceStudyId?: string;
+  returnUrl?: string;
   name: string;
   phone: string;
   level: string;

+ 11 - 2
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.spec.ts

@@ -1,16 +1,25 @@
 import { ReadingQuestion } from './reading.models';
-import { highlightWords, normalizeReadingParams, normalizeReadingQuestions, readingStage, readingWordLimit, splitWords } from './reading.utils';
+import { highlightWords, normalizeReadingParams, normalizeReadingQuestions, normalizeReadingReturnUrl, readingStage, readingWordLimit, splitWords } from './reading.utils';
 
 describe('reading utilities', () => {
   it('normalizes packed UniApp params and common word separators', () => {
-    const packed = encodeURIComponent(JSON.stringify({ wordList: ['apple', ' book '], token: 'session' }));
+    const packed = encodeURIComponent(JSON.stringify({ wordList: ['apple', ' book '], token: 'session', returnUrl:'/pages/his/his?date=2026-09-10' }));
     const params = normalizeReadingParams({ params: packed });
     expect(params.wordList).toEqual(['apple', 'book']);
     expect(params.token).toBe('session');
     expect(params.level).toBe('九年级');
+    expect(params.returnUrl).toBe('/pages/his/his?date=2026-09-10');
     expect(splitWords('apple,book; apple;car')).toEqual(['apple', 'book', 'apple', 'car']);
   });
 
+  it('allows only local reading return addresses and falls back to study records', () => {
+    expect(normalizeReadingReturnUrl('/english')).toBe('/english');
+    expect(normalizeReadingReturnUrl('//outside.example/path')).toBe('/pages/his/his');
+    expect(normalizeReadingReturnUrl('/\\outside.example/path')).toBe('/pages/his/his');
+    expect(normalizeReadingReturnUrl('https://outside.example/path')).toBe('/pages/his/his');
+    expect(normalizeReadingParams({ returnUrl:'javascript:alert(1)' }).returnUrl).toBe('/pages/his/his');
+  });
+
   it('maps grade aliases and keeps the original article length rules', () => {
     expect(readingStage('九年级')).toBe('初中');
     expect(readingStage('高二')).toBe('高中');

+ 6 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading.utils.ts

@@ -13,6 +13,11 @@ export const READING_THEMES = [
   '文学故事', '人际沟通', '历史发展', '古代科技',
 ] as const;
 
+export function normalizeReadingReturnUrl(value: unknown, fallback = '/pages/his/his'): string {
+  const url = String(value ?? '').trim();
+  return url.startsWith('/') && !/^\/[\\/]/.test(url) ? url : fallback;
+}
+
 export function splitWords(value: unknown): string[] {
   const source = Array.isArray(value) ? value : String(value ?? '').split(/[,,;;]/);
   return source.map((word) => String(word).trim()).filter(Boolean);
@@ -25,6 +30,7 @@ export function normalizeReadingParams(value: Record<string, unknown>): ReadingR
   return {
     studentId:Number(params['studentId'])||undefined,
     sourceStudyId:String(params['GeneralID']||params['sourceStudyId']||''),
+    returnUrl: normalizeReadingReturnUrl(params['returnUrl']),
     name: String(params['name'] ?? params['nickname'] ?? ''),
     phone: String(params['phone'] ?? params['mobile'] ?? ''),
     level: String(params['level'] ?? '九年级'),