فهرست منبع

增加历史记录页面

s202226701051 7 ماه پیش
والد
کامیت
630d1e587d

+ 4 - 0
word-app/src/app/app.routes.ts

@@ -67,6 +67,10 @@ export const routes: Routes = [
     path: 'word-match',
     loadComponent: () => import('./word-match/word-match.page').then( m => m.WordMatchPage)
   },
+  {
+    path: 'word-story-history',
+    loadComponent: () => import('./word-story-history/word-story-history.page').then( m => m.WordStoryHistoryPage)
+  },
 
 
 

+ 65 - 1
word-app/src/app/recite-words/word-progress-storage.service.ts

@@ -16,6 +16,8 @@ export interface WordListContext {
 export class WordProgressStorageService {
   /** 未配置阶梯时的默认间隔(天) */
   private readonly defaultIntervalDays = [1, 2, 4, 7, 15, 30, 60];
+  /** 同一 user/list/word 的写入串行队列,避免并发 create 导致重复记录 */
+  private readonly upsertQueueByKey = new Map<string, Promise<void>>();
 
   async getCurrentUserId(): Promise<string | null> {
     const u = await CloudUser.getCurrent();
@@ -100,6 +102,43 @@ export class WordProgressStorageService {
     listObjectId: string,
     intervalDays: number[],
     prevStudyCount?: number
+  ): Promise<void> {
+    const normalizedWordText = this.normalizeWordText(word.word);
+    const queueKey = `${userId}|${listObjectId}|${normalizedWordText}`;
+
+    const prevTask = this.upsertQueueByKey.get(queueKey) ?? Promise.resolve();
+    const currentTask = prevTask
+      .catch(() => {
+        // 前序任务失败不阻断后续保存
+      })
+      .then(async () => {
+        await this.upsertWordProgressInternal(
+          word,
+          userId,
+          listObjectId,
+          intervalDays,
+          normalizedWordText,
+          prevStudyCount
+        );
+      });
+
+    this.upsertQueueByKey.set(queueKey, currentTask);
+    try {
+      await currentTask;
+    } finally {
+      if (this.upsertQueueByKey.get(queueKey) === currentTask) {
+        this.upsertQueueByKey.delete(queueKey);
+      }
+    }
+  }
+
+  private async upsertWordProgressInternal(
+    word: Word,
+    userId: string,
+    listObjectId: string,
+    intervalDays: number[],
+    normalizedWordText: string,
+    prevStudyCount?: number
   ): Promise<void> {
     const isNewDone = word.studyCount >= 3;
     let ladderStep = word.ladderStep ?? 0;
@@ -124,7 +163,8 @@ export class WordProgressStorageService {
     const payload: Record<string, unknown> = {
       user: { __type: 'Pointer', className: '_User', objectId: userId },
       list: { __type: 'Pointer', className: 'wordList', objectId: listObjectId },
-      wordText: word.word,
+      // 统一为小写 key,避免同词因大小写/空格差异生成多条记录
+      wordText: normalizedWordText,
       studyCount: word.studyCount,
       isNewDone,
       ladderStep,
@@ -132,6 +172,13 @@ export class WordProgressStorageService {
       nextReviewAt
     };
 
+    if (!word.progressObjectId) {
+      const existing = await this.findExistingProgressRowId(userId, listObjectId, normalizedWordText);
+      if (existing) {
+        word.progressObjectId = existing;
+      }
+    }
+
     const obj = new CloudObject('wordUserProgress');
     if (word.progressObjectId) {
       obj.id = word.progressObjectId;
@@ -147,6 +194,23 @@ export class WordProgressStorageService {
     word.nextReviewAt = nextReviewAt ? this.isoFromParseValue(nextReviewAt) : undefined;
   }
 
+  private async findExistingProgressRowId(
+    userId: string,
+    listObjectId: string,
+    normalizedWordText: string
+  ): Promise<string | null> {
+    const q = new CloudQuery('wordUserProgress');
+    q.equalTo('user', { __type: 'Pointer', className: '_User', objectId: userId });
+    q.equalTo('list', { __type: 'Pointer', className: 'wordList', objectId: listObjectId });
+    q.equalTo('wordText', normalizedWordText);
+    const row = await q.first();
+    return row?.id ?? null;
+  }
+
+  private normalizeWordText(wordText: string): string {
+    return String(wordText ?? '').trim().toLowerCase();
+  }
+
   private pointerObjectId(v: unknown): string | null {
     if (!v || typeof v !== 'object') {
       return null;

+ 72 - 0
word-app/src/app/word-story-history/word-story-history.page.html

@@ -0,0 +1,72 @@
+<ion-content class="history-content">
+  <div class="ocean-bg"></div>
+
+  <div class="history-container" [class.detail-mode]="!!selectedHistory">
+    <div class="nav-header">
+      <button type="button" class="back-btn" (click)="goBack()" aria-label="返回">
+        <ion-icon name="arrow-back-outline"></ion-icon>
+      </button>
+      <h1 class="page-title">
+        <ion-icon *ngIf="selectedHistory" name="boat-outline" class="title-icon"></ion-icon>
+        {{ selectedHistory ? '串词成文' : '历史记录' }}
+      </h1>
+      <div class="placeholder"></div>
+    </div>
+
+    <div class="history-list" *ngIf="!selectedHistory && historyList.length > 0; else emptyState">
+      <div class="history-card" *ngFor="let item of historyList" (click)="openHistory(item)">
+        <h2 class="title-en">{{ item.titleEn }}</h2>
+        <p class="title-zh">{{ item.titleZh }}</p>
+        <div class="meta-row">
+          <div class="tags">
+            <span class="tag">{{ item.difficulty }}</span>
+            <span class="tag">{{ item.genre }}</span>
+          </div>
+          <span class="created-at">{{ item.createdAt }}</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="history-detail" *ngIf="selectedHistory">
+      <div class="exercise-title-wrapper">
+        <div class="exercise-title-container">
+          <div class="exercise-title-main">
+            <h2 class="exercise-title-en">{{ selectedHistory.titleEn }}</h2>
+            <p class="exercise-title-zh" *ngIf="selectedHistory.titleZh">{{ selectedHistory.titleZh }}</p>
+          </div>
+        </div>
+
+        <div class="exercise-score-header">
+          <div class="score-box-number">{{ selectedHistory.score }}</div>
+          <div class="score-box-equal">=</div>
+        </div>
+      </div>
+
+      <div class="exercise-article-wrap">
+        <div class="exercise-content">
+          <div class="exercise-paragraph-block" *ngFor="let p of selectedHistory.paragraphs; let pi = index">
+            <div class="exercise-paragraph">
+            <ng-container *ngFor="let seg of getParagraphSegmentsForView(p.en, getParagraphBlankStarts()[pi])">
+              <ng-container *ngIf="seg.type === 'text'">{{ seg.value }}</ng-container>
+              <span *ngIf="seg.type === 'blank'"
+                    class="blank-slot"
+                    [class.filled]="selectedHistory.userBlanks[seg.index]"
+                    [class.correct]="isBlankCorrect(seg.index)"
+                    [class.wrong]="isBlankWrong(seg.index)">
+                <span class="blank-text">{{ selectedHistory.userBlanks[seg.index] || ' ' }}</span>
+              </span>
+            </ng-container>
+            </div>
+            <p class="exercise-paragraph-zh" *ngIf="p.zh">{{ p.zh }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <ng-template #emptyState>
+      <div class="empty-box" *ngIf="!selectedHistory">暂无历史记录</div>
+    </ng-template>
+
+    <p class="hint-text" *ngIf="!selectedHistory">仅保留最近7天记录</p>
+  </div>
+</ion-content>

+ 355 - 0
word-app/src/app/word-story-history/word-story-history.page.scss

@@ -0,0 +1,355 @@
+$ocean-deep: #01579b;
+$ocean-mid: #0288d1;
+$ocean-light: #29b6f6;
+$ocean-surface: #81d4fa;
+$ocean-foam: #e1f5fe;
+$pearl: #ffffff;
+
+:host {
+  background: $ocean-foam;
+  --ion-background-color: #{$ocean-foam};
+}
+
+.history-content {
+  --background: #{$ocean-foam};
+}
+
+.ocean-bg {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 280px;
+  background: linear-gradient(180deg, $ocean-surface 0%, $ocean-foam 100%);
+  border-radius: 0 0 50% 50% / 0 0 30% 30%;
+  z-index: 0;
+  pointer-events: none;
+}
+
+.history-container {
+  position: relative;
+  z-index: 1;
+  min-height: 100%;
+  padding: 0 16px 24px;
+
+  &.detail-mode {
+    padding: 0;
+
+    .nav-header {
+      padding: 8% 5%;
+    }
+  }
+}
+
+.nav-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8% 2% 6%;
+
+  .back-btn {
+    width: 40px;
+    height: 40px;
+    border: none;
+    background: $pearl;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 2px 8px rgba(2, 136, 209, 0.15);
+
+    ion-icon {
+      font-size: 1.7rem;
+      color: $ocean-mid;
+    }
+  }
+
+  .page-title {
+    margin: 0;
+    font-size: 1.6rem; // 与 word-story 顶部「串词成文」一致
+    font-weight: 600;
+    color: $ocean-deep;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    .title-icon {
+      color: $ocean-mid;
+      font-size: 2rem;
+      font-weight: 700;
+    }
+  }
+
+  .placeholder {
+    width: 40px;
+    height: 40px;
+  }
+}
+
+.history-list {
+  padding: 2px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.history-card {
+  background: $pearl;
+  border: 3.5px solid #41c2f8;
+  border-radius: 20px;
+  box-shadow: 0 8px 20px rgba(2, 136, 209, 0.12);
+  padding: 16px 18px 14px;
+  cursor: pointer;
+  transition: transform 0.16s ease, box-shadow 0.2s ease;
+
+  &:active {
+    transform: scale(0.985);
+    box-shadow: 0 5px 14px rgba(2, 136, 209, 0.16);
+  }
+
+  .title-en {
+    margin: 0;
+    font-size: 1.38rem;
+    font-weight: 500;
+    color: $ocean-deep;
+    line-height: 1.25;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .title-zh {
+    margin: 6px 0 0;
+    font-size: 0.94rem;
+    color: #607d8b;
+    line-height: 1.25;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .meta-row {
+    margin-top: 14px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .tags {
+    display: flex;
+    gap: 8px;
+  }
+
+  .tag {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    min-width: 48px;
+    padding: 4px 12px;
+    border-radius: 999px;
+    font-size: 0.96rem;
+    color: $ocean-deep;
+    background: rgba($ocean-foam, 0.95);
+    border: 1px solid rgba($ocean-light, 0.38);
+    line-height: 1;
+  }
+
+  .created-at {
+    font-size: 1.02rem;
+    color: #546e7a;
+    white-space: nowrap;
+  }
+}
+
+.hint-text {
+  margin: 10px 0 0;
+  text-align: center;
+  font-size: 1.08rem;
+  color: #9ea5ad;
+}
+
+.empty-box {
+  margin: 16px 2px 0;
+  padding: 32px 20px;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.82);
+  color: #7a8f9e;
+  text-align: center;
+  font-size: 1.35rem;
+  border: 1px dashed rgba($ocean-mid, 0.35);
+}
+
+.history-detail {
+  .exercise-title-wrapper {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin: 0 auto 8px;
+    max-width: 700px;
+    justify-content: center;
+    padding: 0 12px;
+  }
+
+  .exercise-title-container {
+    background: $pearl;
+    padding: 16px 15px;
+    border-radius: 16px;
+    box-shadow: 0 2px 12px rgba(2, 136, 209, 0.1);
+    flex: 1;
+    max-width: 640px;
+  }
+
+  .exercise-title-main {
+    flex: 1;
+    text-align: center;
+  }
+
+  .exercise-title-en {
+    font-size: 1.35rem;
+    font-weight: 700;
+    color: $ocean-deep;
+    margin: 0;
+    text-align: center;
+  }
+
+  .exercise-title-zh {
+    font-size: 1.15rem;
+    color: $ocean-deep;
+    margin: 8px 0 0 0;
+    font-weight: 700;
+    text-align: center;
+  }
+
+  .exercise-score-header {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 4px;
+    min-width: 80px;
+    padding: 10px 12px;
+    background: $pearl;
+    border-radius: 16px;
+    box-shadow: 0 2px 12px rgba(2, 136, 209, 0.1);
+  }
+
+  .score-box-number {
+    font-size: 2rem;
+    font-weight: 700;
+    color: #e53935;
+    line-height: 1;
+    min-width: 60px;
+    text-align: center;
+  }
+
+  .score-box-equal {
+    font-size: 2rem;
+    color: #e53935;
+    line-height: 1;
+    font-weight: 700;
+  }
+
+  .exercise-article-wrap {
+    padding: 16px 20px;
+    background: $pearl;
+    margin: 0 auto 0px auto;
+    margin-bottom: 0;
+    max-width: 640px;
+    border-radius: 16px;
+    box-shadow: 0 2px 12px rgba(2, 136, 209, 0.1);
+    width: 100%;
+  }
+
+  .exercise-content {
+    background: transparent;
+  }
+
+  .exercise-paragraph-block {
+    margin-bottom: 0.5em;
+  }
+
+  .exercise-paragraph-block:last-child {
+    margin-bottom: 0;
+  }
+
+  .exercise-paragraph {
+    font-size: 1.05rem;
+    line-height: 1.75;
+    color: #333;
+    margin-bottom: 0.5em;
+    text-align: justify;
+    text-justify: inter-ideograph;
+    hyphens: auto;
+    -webkit-hyphens: auto;
+    -moz-hyphens: auto;
+    hyphenate-limit-chars: 4 2 2;
+    text-indent: 3.5ch;
+  }
+
+  .blank-slot {
+    display: inline-flex;
+    width: auto;
+    min-width: 80px;
+    border-bottom: 3px solid $ocean-light;
+    margin: 0 2px;
+    padding: 0 6px;
+    cursor: text;
+    color: $ocean-deep;
+    font-weight: 600;
+    border-radius: 2px;
+    transition: border-color 0.2s, background 0.2s;
+    box-sizing: border-box;
+    align-items: flex-end;
+    justify-content: center;
+    height: 1.4em;
+    line-height: 1.4em;
+    text-align: center;
+    text-indent: 0;
+    position: relative;
+    vertical-align: baseline;
+
+    &:empty::before,
+    &:not(.filled)::before {
+      content: '\00a0';
+      height: 1.4em;
+      display: inline-block;
+      vertical-align: baseline;
+    }
+
+    &.focused {
+      border-color: $ocean-mid;
+      background: $ocean-foam;
+      outline: none;
+      caret-color: $ocean-deep;
+    }
+  }
+
+  .blank-slot.filled {
+    border-bottom-style: solid;
+    color: $ocean-deep;
+  }
+
+  .blank-slot.wrong {
+    color: #e53935;
+    border-bottom-color: #e53935;
+  }
+
+  .blank-text {
+    display: inline-block;
+    text-align: center;
+    text-indent: 0;
+    width: 100%;
+    line-height: 1.4em;
+    vertical-align: baseline;
+  }
+
+  .exercise-paragraph-zh {
+    font-size: 0.95rem;
+    line-height: 1.7;
+    color: #546e7a;
+    margin: 4px 0 0 0;
+    text-align: justify;
+    text-indent: 2em;
+  }
+}

+ 17 - 0
word-app/src/app/word-story-history/word-story-history.page.spec.ts

@@ -0,0 +1,17 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { WordStoryHistoryPage } from './word-story-history.page';
+
+describe('WordStoryHistoryPage', () => {
+  let component: WordStoryHistoryPage;
+  let fixture: ComponentFixture<WordStoryHistoryPage>;
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(WordStoryHistoryPage);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 163 - 0
word-app/src/app/word-story-history/word-story-history.page.ts

@@ -0,0 +1,163 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { IonContent, IonIcon } from '@ionic/angular/standalone';
+import { addIcons } from 'ionicons';
+import { arrowBackOutline, boatOutline } from 'ionicons/icons';
+import { CloudQuery, CloudUser } from '../recite-words/ncloud';
+
+interface StoryHistoryItem {
+  objectId: string;
+  titleEn: string;
+  titleZh: string;
+  difficulty: string;
+  genre: string;
+  createdAt: string;
+  score: number;
+  paragraphs: { en: string; zh: string }[];
+  answers: string[];
+  userBlanks: string[];
+  distractors: string[];
+}
+
+type ParagraphSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
+
+@Component({
+  selector: 'app-word-story-history',
+  templateUrl: './word-story-history.page.html',
+  styleUrls: ['./word-story-history.page.scss'],
+  standalone: true,
+  imports: [IonContent, IonIcon, CommonModule, FormsModule]
+})
+export class WordStoryHistoryPage implements OnInit {
+  historyList: StoryHistoryItem[] = [];
+  selectedHistory: StoryHistoryItem | null = null;
+
+  constructor(private router: Router) {
+    addIcons({ arrowBackOutline, boatOutline });
+  }
+
+  ngOnInit() {
+    void this.loadHistoryList();
+  }
+
+  ionViewWillEnter(): void {
+    // 每次进入页面都刷新历史记录
+    void this.loadHistoryList();
+  }
+
+  private async loadHistoryList(): Promise<void> {
+    try {
+      const user = await CloudUser.getCurrent();
+      if (!user?.id) {
+        this.historyList = [];
+        return;
+      }
+
+      const query = new CloudQuery('word_story_history');
+      query.equalTo('user', { __type: 'Pointer', className: '_User', objectId: user.id });
+      const rows = await query.find();
+
+      this.historyList = rows
+        .map((row) => ({
+          objectId: row.id || '',
+          titleEn: String(row.get('titleEn') ?? ''),
+          titleZh: String(row.get('titleZh') ?? ''),
+          difficulty: String(row.get('difficulty') ?? '--'),
+          genre: String(row.get('genre') ?? '--'),
+          createdAt: this.formatCreatedAt(row.createdAt),
+          score: this.toNumber(row.get('score')),
+          paragraphs: this.toParagraphs(row.get('paragraphs')),
+          answers: this.toStringArray(row.get('answers')),
+          userBlanks: this.toStringArray(row.get('userBlanks')),
+          distractors: this.toStringArray(row.get('distractors'))
+        }))
+        .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
+    } catch (error) {
+      console.error('[WordStoryHistory] 加载历史记录失败', error);
+      this.historyList = [];
+    }
+  }
+
+  private formatCreatedAt(value: unknown): string {
+    const d = typeof value === 'string' ? new Date(value) : (value instanceof Date ? value : null);
+    if (!d || Number.isNaN(d.getTime())) return '';
+
+    const yyyy = d.getFullYear();
+    const mm = String(d.getMonth() + 1).padStart(2, '0');
+    const dd = String(d.getDate()).padStart(2, '0');
+    const hh = String(d.getHours()).padStart(2, '0');
+    const mi = String(d.getMinutes()).padStart(2, '0');
+    return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
+  }
+
+  private toNumber(v: unknown): number {
+    const n = Number(v);
+    return Number.isNaN(n) ? 0 : n;
+  }
+
+  private toStringArray(v: unknown): string[] {
+    if (!Array.isArray(v)) return [];
+    return v.map((x) => String(x ?? ''));
+  }
+
+  private toParagraphs(v: unknown): { en: string; zh: string }[] {
+    if (!Array.isArray(v)) return [];
+    return v.map((p) => ({
+      en: String((p as any)?.en ?? ''),
+      zh: String((p as any)?.zh ?? '')
+    }));
+  }
+
+  openHistory(item: StoryHistoryItem): void {
+    this.selectedHistory = item;
+  }
+
+  goBack(): void {
+    if (this.selectedHistory) {
+      this.selectedHistory = null;
+      return;
+    }
+    this.router.navigate(['/word-story']);
+  }
+
+  getParagraphBlankStarts(): number[] {
+    if (!this.selectedHistory) return [];
+    const starts: number[] = [];
+    let idx = 0;
+    for (const p of this.selectedHistory.paragraphs) {
+      starts.push(idx);
+      idx += (p.en.match(/______/g) || []).length;
+    }
+    return starts;
+  }
+
+  getParagraphSegmentsForView(paragraphEn: string, startBlankIndex: number): ParagraphSegment[] {
+    const parts = paragraphEn.split('______');
+    const segments: ParagraphSegment[] = [];
+    let next = startBlankIndex;
+    parts.forEach((text, i) => {
+      if (text) segments.push({ type: 'text', value: text });
+      if (i < parts.length - 1) {
+        segments.push({ type: 'blank', index: next });
+        next++;
+      }
+    });
+    return segments;
+  }
+
+  isBlankCorrect(index: number): boolean {
+    if (!this.selectedHistory) return false;
+    const ans = (this.selectedHistory.answers[index] || '').trim().toLowerCase();
+    const user = (this.selectedHistory.userBlanks[index] || '').trim().toLowerCase();
+    return !!user && user === ans;
+  }
+
+  isBlankWrong(index: number): boolean {
+    if (!this.selectedHistory) return false;
+    const ans = (this.selectedHistory.answers[index] || '').trim().toLowerCase();
+    const user = (this.selectedHistory.userBlanks[index] || '').trim().toLowerCase();
+    return !!user && user !== ans;
+  }
+}

+ 28 - 5
word-app/src/app/word-story/word-story.page.html

@@ -9,7 +9,7 @@
        [class.word-list-mode]="isWordListMode">
     
     <!-- 顶部导航 - 添加船图标 -->
-    <div class="nav-header">
+    <div class="nav-header" [class.history-replay-header]="isHistoryReplay">
       <div class="back-btn" (click)="goBack()">
         <ion-icon name="arrow-back-outline"></ion-icon>
       </div>
@@ -18,10 +18,19 @@
            串词成文
       </h1>
       <!-- 保存按钮:只在评分结果页面显示 -->
-      <div class="save-btn" *ngIf="showExerciseView && scoreResult" (click)="saveStory()">
+      <div class="save-btn" *ngIf="showExerciseView && scoreResult && !isHistoryReplay" (click)="saveStory()">
         <img src="assets/icon/保存故事.svg" alt="保存故事">
       </div>
-      <div class="placeholder" *ngIf="!(showExerciseView && scoreResult)"></div>
+      <button
+        type="button"
+        class="history-btn"
+        *ngIf="!showExerciseView && !isGenerating && !storyResponse"
+        (click)="onHistoryClick($event)"
+        aria-label="历史记录"
+      >
+        <img src="assets/icon/历史记录.svg" alt="历史记录">
+      </button>
+      <div class="placeholder" *ngIf="!(showExerciseView && scoreResult) && (showExerciseView || isGenerating || !!storyResponse)"></div>
     </div>
 
     <!-- ========== 做题页:挖空文章 + 词池 + 去评分 ========== -->
@@ -62,7 +71,7 @@
                       [class.wrong]="scoreResult && isBlankWrong(seg.index)"
                       (click)="selectBlank(seg.index)">
                   <span class="blank-text">
-                    {{ scoreResult ? (exerciseStory.answers[seg.index] || ' ') : (exerciseBlanks[seg.index] || ' ') }}
+                    {{ exerciseBlanks[seg.index] || ' ' }}
                   </span>
                 </span>
               </ng-container>
@@ -361,10 +370,24 @@
       </div>
     </div>
 
+    <!-- 保存确认弹窗(复用返回弹窗样式) -->
+    <div class="wizard-modal-overlay" *ngIf="showSaveModal">
+      <div class="wizard-modal-container exit-modal-container" (click)="$event.stopPropagation()">
+        <div class="wizard-header">
+          <h3>是否保存</h3>
+        </div>
+
+        <div class="wizard-footer">
+          <button class="wizard-btn btn-secondary" (click)="closeSaveModal()">取消</button>
+          <button class="wizard-btn btn-primary btn-highlight" [disabled]="isSavingStory" (click)="confirmSaveStory()">保存</button>
+        </div>
+      </div>
+    </div>
+
   </div>
 </ion-content>
 
-<ion-footer class="word-story-footer" [class.modal-open]="showStyleModal || showExitModal">
+<ion-footer class="word-story-footer" [class.modal-open]="showStyleModal || showExitModal || showSaveModal">
 
   <!-- 非做题模式:底部操作栏 -->
   <div class="bottom-bar" *ngIf="!isGenerating && !storyResponse && !showExerciseView">

+ 57 - 7
word-app/src/app/word-story/word-story.page.scss

@@ -63,6 +63,7 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
     justify-content: space-between;
     padding: 8% 5%;
     background: transparent;
+    position: relative;
 
     .back-btn {
       width: 40px;
@@ -109,6 +110,35 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
       }
     }
 
+    .history-btn {
+      width: 40px;
+      height: 40px;
+      border: 2px solid rgba($ocean-surface, 0.85);
+      background: linear-gradient(135deg, rgba($pearl, 0.95), rgba($ocean-foam, 0.95));
+      border-radius: 12px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-shadow: 0 2px 8px rgba(2, 136, 209, 0.15);
+      cursor: pointer;
+      transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+      padding: 0;
+      overflow: hidden;
+
+      img {
+        width: 28px;
+        height: 28px;
+        object-fit: contain;
+        display: block;
+      }
+
+      &:active {
+        transform: scale(0.95);
+        border-color: $ocean-light;
+        box-shadow: 0 1px 5px rgba(2, 136, 209, 0.2);
+      }
+    }
+
     .page-title {
       font-size: 1.6rem;
       font-weight: 600;
@@ -128,6 +158,16 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
     .placeholder {
       width: 40px;
     }
+
+    &.history-replay-header {
+      .page-title {
+        position: absolute;
+        left: 50%;
+        transform: translateX(-50%);
+        white-space: nowrap;
+        pointer-events: none;
+      }
+    }
   }
 
   // 标签切换 - 居中对齐(使用每个 tab 自己的下划线指示器)
@@ -759,6 +799,7 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
     margin: 0 auto 8px;
     max-width: 700px;
     justify-content: center;
+    padding: 0 12px;
   }
 
   .exercise-title-container {
@@ -1113,11 +1154,20 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
     display: inline-block;
     text-align: center;
 
-    &:hover {
-      border-color: #29B6F6;
-      background: #29B6F6;
-      color: white;
-      transform: translateY(-1px);
+    &:not(.selected) {
+      background: #E1F5FE;
+      border-color: #B3E5FC;
+      color: #01579B;
+    }
+
+    // 仅在支持 hover 的设备上应用,避免移动端点击后悬停态残留
+    @media (hover: hover) and (pointer: fine) {
+      &:hover {
+        border-color: #29B6F6;
+        background: #29B6F6;
+        color: white;
+        transform: translateY(-1px);
+      }
     }
 
     &:active {
@@ -1125,9 +1175,9 @@ $seaweed: #4CAF50;          // 海藻绿(点缀)
     }
 
     &.selected {
-      background: #4FC3F7;
+      background: #29B6F6;
       color: white;
-      border-color: #4FC3F7;
+      border-color: #29B6F6;
       box-shadow: 0 2px 8px rgba(41, 182, 246, 0.25);
     }
 

+ 120 - 14
word-app/src/app/word-story/word-story.page.ts

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Router, NavigationStart } from '@angular/router';
 import { WordStoryService, StoryResponse } from './word-story.service';
+import { CloudObject, CloudUser } from '../recite-words/ncloud';
 import { 
   IonContent, 
   IonFooter, 
@@ -20,7 +21,8 @@ import {
   chevronForwardOutline,
   createOutline,
   saveOutline,
-  addOutline
+  addOutline,
+  timeOutline
 } from 'ionicons/icons';
 
 // 单词数据接口
@@ -39,6 +41,16 @@ interface FlashWord {
   chineseMeaning: string;
 }
 
+interface HistoryRecordState {
+  titleEn: string;
+  titleZh: string;
+  paragraphs: { en: string; zh: string }[];
+  answers: string[];
+  distractors: string[];
+  userBlanks: string[];
+  score: number;
+}
+
 /** 段落中的片段:文本或挖空 */
 export type ParagraphSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
 
@@ -98,6 +110,7 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   // 做题页状态
   showExerciseView = false;
+  isHistoryReplay = false;
   exerciseStory: StoryResponse | null = null;
   /** 每个空当前填写的词,null 表示未填 */
   exerciseBlanks: (string | null)[] = [];
@@ -140,6 +153,10 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   // 退出确认弹窗状态
   showExitModal: boolean = false;
+  // 保存确认弹窗状态
+  showSaveModal: boolean = false;
+  // 保存中状态,防重复点击
+  isSavingStory: boolean = false;
 
   // 预设选项数据
   styleOptions: string[] = ['童话', '青春', '悬疑', '励志', '科幻', '日常', '历史', '浪漫'];
@@ -201,7 +218,8 @@ listSwipeDirection: 'left' | 'right' | null = null;
       chevronForwardOutline,
       createOutline,
       saveOutline,
-      addOutline
+      addOutline,
+      timeOutline
     });
     // 从其他页通过 state 进入做题页时恢复故事
     const nav = this.router.getCurrentNavigation();
@@ -209,12 +227,39 @@ listSwipeDirection: 'left' | 'right' | null = null;
       this.exerciseStory = nav.extras.state['story'] as StoryResponse;
       this.showExerciseView = true;
     }
+    if (nav?.extras?.state?.['historyRecord']) {
+      this.isHistoryReplay = true;
+      const history = nav.extras.state['historyRecord'] as HistoryRecordState;
+      this.exerciseStory = {
+        titleEn: history.titleEn || '',
+        titleZh: history.titleZh || '',
+        paragraphs: history.paragraphs || [],
+        answers: history.answers || [],
+        distractors: history.distractors || []
+      };
+      this.showExerciseView = true;
+      this.exerciseBlanks = (history.userBlanks || []).map((x) => x ?? '');
+      this.availableWords = [];
+      this.focusedBlankIndex = null;
+      const total = this.exerciseStory.answers.length;
+      const filled = this.exerciseBlanks.filter((x) => !!String(x).trim()).length;
+      const correct = this.exerciseStory.answers.reduce((acc, ans, idx) => {
+        const user = (this.exerciseBlanks[idx] || '').trim().toLowerCase();
+        return acc + (user && user === String(ans || '').trim().toLowerCase() ? 1 : 0);
+      }, 0);
+      this.scoreResult = {
+        correct,
+        total,
+        filled,
+        percent: Number(history.score ?? 0)
+      };
+    }
   }
 
   ngOnInit(): void {
     // 与 currentTab 保持一致
     this.tabSwipeProgress = this.currentTab === 'learned' ? 1 : 0;
-    if (this.exerciseStory) this.initExerciseState();
+    if (this.exerciseStory && !this.isHistoryReplay) this.initExerciseState();
   }
 
   /** 仅在“选择单词列表页”时禁用页面整体滚动,让列表区域单独滚动 */
@@ -288,6 +333,7 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   /** 点击挖空区域:若已有词则退回词池并选中该空;若无词则仅选中该空 */
   selectBlank(index: number): void {
+    if (this.scoreResult) return;
     const current = this.exerciseBlanks[index];
     if (current != null) {
       this.availableWords = [...this.availableWords, current];
@@ -817,13 +863,15 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   // 选择难度
   selectDifficulty(difficulty: string) {
-    this.wizardData.difficulty = difficulty;
+    this.wizardData.difficulty =
+      this.wizardData.difficulty === difficulty ? '' : difficulty;
     this.showOtherDifficultyInput = false;
   }
 
   // 选择体裁
   selectGenre(genre: string) {
-    this.wizardData.genre = genre;
+    this.wizardData.genre =
+      this.wizardData.genre === genre ? '' : genre;
     this.showOtherGenreInput = false;
   }
 
@@ -993,6 +1041,12 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   // 返回上一页
   goBack() {
+    // 历史回放模式:返回直接回历史列表,不弹框
+    if (this.isHistoryReplay) {
+      this.router.navigate(['/word-story-history']);
+      return;
+    }
+
     // 评分结果页面点击返回,弹出确认弹窗
     if (this.showExerciseView && this.scoreResult) {
       this.showExitModal = true;
@@ -1027,16 +1081,66 @@ listSwipeDirection: 'left' | 'right' | null = null;
 
   // 保存故事 - 存入数据库(先不实现),然后重置状态回到tab2页面
   saveStory() {
-    // TODO: 将数据存入数据库
-    console.log('保存故事:', {
-      story: this.exerciseStory,
-      score: this.scoreResult,
-      answers: this.exerciseBlanks
+    // 评分页点击保存图标时,先弹确认框
+    if (!this.showExerciseView || !this.scoreResult || !this.exerciseStory) return;
+    this.showSaveModal = true;
+  }
+
+  closeSaveModal() {
+    if (this.isSavingStory) return;
+    this.showSaveModal = false;
+  }
+
+  async confirmSaveStory() {
+    if (this.isSavingStory) return;
+    this.isSavingStory = true;
+    try {
+      const ok = await this.persistStoryHistory();
+      if (!ok) {
+        return;
+      }
+      this.showSaveModal = false;
+      // 保存成功后停留在当前页面(评分页)
+    } finally {
+      this.isSavingStory = false;
+    }
+  }
+
+  private async persistStoryHistory(): Promise<boolean> {
+    if (!this.exerciseStory || !this.scoreResult) {
+      console.warn('[WordStory] 保存失败:评分数据不完整');
+      return false;
+    }
+
+    const user = await CloudUser.getCurrent();
+    if (!user?.id) {
+      console.warn('[WordStory] 保存失败:当前未登录');
+      return false;
+    }
+
+    const row = new CloudObject('word_story_history');
+    row.set({
+      user: { __type: 'Pointer', className: '_User', objectId: user.id },
+      titleEn: this.exerciseStory.titleEn || '',
+      titleZh: this.exerciseStory.titleZh || '',
+      paragraphs: this.exerciseStory.paragraphs || [],
+      answers: this.exerciseStory.answers || [],
+      userBlanks: this.exerciseBlanks.map((x) => x ?? ''),
+      distractors: this.exerciseStory.distractors || [],
+      score: this.scoreResult.percent,
+      difficulty: this.wizardData.difficulty || '',
+      genre: this.wizardData.genre || '',
+      styles: this.wizardData.styles || []
     });
-    
-    // 重置状态并回到tab2页面
-    this.resetState();
-    this.router.navigate(['/tabs/tab2']);
+
+    await row.save();
+    console.log('[WordStory] 保存成功', row.id);
+    return true;
+  }
+
+  onHistoryClick(event: Event): void {
+    event.stopPropagation();
+    this.router.navigate(['/word-story-history']);
   }
 
   // 重置页面状态
@@ -1054,6 +1158,8 @@ listSwipeDirection: 'left' | 'right' | null = null;
     this.currentFlashIndex = 0;
     this.showStyleModal = false;
     this.showExitModal = false;
+    this.showSaveModal = false;
+    this.isSavingStory = false;
     // 重置单词选中状态
     this.learningWords.forEach(word => word.selected = false);
     this.learnedWords.forEach(word => word.selected = false);

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
word-app/src/assets/icon/历史记录.svg


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است