|
|
@@ -1,9 +1,13 @@
|
|
|
-import { Component, OnInit, ViewChild } from '@angular/core';
|
|
|
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
-import { Router, NavigationStart } from '@angular/router';
|
|
|
+import { Router } from '@angular/router';
|
|
|
+import { HttpClient } from '@angular/common/http';
|
|
|
+import { firstValueFrom } from 'rxjs';
|
|
|
import { WordStoryService, StoryResponse } from './word-story.service';
|
|
|
-import { CloudObject, CloudUser } from '../recite-words/ncloud';
|
|
|
+import { CloudObject, CloudQuery, CloudUser } from '../recite-words/ncloud';
|
|
|
+import { RECITE_WORDLIST_ASSET_KEY } from '../recite-words/word-progress-storage.service';
|
|
|
+import { PronunciationButtonComponent } from '../pronunciation-button/pronunciation-button.component';
|
|
|
import {
|
|
|
IonContent,
|
|
|
IonFooter,
|
|
|
@@ -31,6 +35,11 @@ interface WordItem {
|
|
|
word: string;
|
|
|
meaning: string;
|
|
|
selected: boolean;
|
|
|
+ studyCount: number;
|
|
|
+ isNewDone: boolean;
|
|
|
+ lastReviewAt?: string;
|
|
|
+ phonetic?: string;
|
|
|
+ partOfSpeech?: string;
|
|
|
}
|
|
|
|
|
|
// 闪卡单词接口
|
|
|
@@ -51,6 +60,11 @@ interface HistoryRecordState {
|
|
|
score: number;
|
|
|
}
|
|
|
|
|
|
+interface WordListInfo {
|
|
|
+ id: string | null;
|
|
|
+ assetKey: string;
|
|
|
+}
|
|
|
+
|
|
|
/** 段落中的片段:文本或挖空 */
|
|
|
export type ParagraphSegment = { type: 'text'; value: string } | { type: 'blank'; index: number };
|
|
|
|
|
|
@@ -65,17 +79,20 @@ export type ParagraphSegment = { type: 'text'; value: string } | { type: 'blank'
|
|
|
IonContent,
|
|
|
IonIcon,
|
|
|
IonFooter,
|
|
|
+ PronunciationButtonComponent,
|
|
|
]
|
|
|
})
|
|
|
export class WordStoryPage implements OnInit {
|
|
|
|
|
|
// IonContent 引用,用于滚动控制
|
|
|
@ViewChild(IonContent) content!: IonContent;
|
|
|
+ @ViewChild('learnedScroll') learnedScrollRef?: ElementRef<HTMLElement>;
|
|
|
+ @ViewChild('learningScroll') learningScrollRef?: ElementRef<HTMLElement>;
|
|
|
// 在类属性中添加
|
|
|
listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 当前标签页
|
|
|
- currentTab: 'learning' | 'learned' = 'learning';
|
|
|
+ currentTab: 'learning' | 'learned' = 'learned';
|
|
|
/** 0=待学习,1=已学习;用于“拖拽中预览另一页”和指示器跟随 */
|
|
|
tabSwipeProgress: number = 0;
|
|
|
/** 列表 track 的 transition(拖拽时为 none,松手吸附时为带动画) */
|
|
|
@@ -121,31 +138,20 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
/** 评分结果:null 未评分 */
|
|
|
scoreResult: { correct: number; total: number; filled: number; percent: number } | null = null;
|
|
|
|
|
|
- // 待学习单词列表
|
|
|
- learningWords: WordItem[] = [
|
|
|
- { id: '1', word: 'add', meaning: 'v. 增加;相加', selected: false },
|
|
|
- { id: '2', word: 'addition', meaning: 'n. 增添;添加物;加法;扩建部分', selected: false },
|
|
|
- { id: '3', word: 'additional', meaning: 'adj. 附加的,额外的', selected: false },
|
|
|
- { id: '4', word: 'suggest', meaning: 'vt. 建议;推荐;表明;暗示', selected: false },
|
|
|
- { id: '5', word: 'suggestion', meaning: 'n. 建议,提议;暗示;迹象', selected: false },
|
|
|
- { id: '6', word: 'design', meaning: 'n. 设计;设计方案', selected: false },
|
|
|
- { id: '7', word: 'develop', meaning: 'v. 发展;开发;养成', selected: false },
|
|
|
- { id: '8', word: 'development', meaning: 'n. 发展;开发;发育', selected: false },
|
|
|
- { id: '9', word: 'achieve', meaning: 'v. 达到;完成;实现', selected: false },
|
|
|
- { id: '10', word: 'achievement', meaning: 'n. 成就;完成;达到', selected: false },
|
|
|
- { id: '11', word: 'environment', meaning: 'n. 环境;外界;周围状况', selected: false },
|
|
|
- { id: '12', word: 'environmental', meaning: 'adj. 环境的;周围的', selected: false }
|
|
|
- ];
|
|
|
-
|
|
|
- // 已学习单词列表
|
|
|
- learnedWords: WordItem[] = [
|
|
|
- { id: '101', word: 'apple', meaning: 'n. 苹果', selected: false },
|
|
|
- { id: '102', word: 'banana', meaning: 'n. 香蕉', selected: false },
|
|
|
- { id: '103', word: 'computer', meaning: 'n. 计算机;电脑', selected: false },
|
|
|
- { id: '104', word: 'university', meaning: 'n. 大学;综合性大学', selected: false },
|
|
|
- { id: '105', word: 'education', meaning: 'n. 教育;培养;教育学', selected: false },
|
|
|
- { id: '106', word: 'knowledge', meaning: 'n. 知识;学问;了解', selected: false }
|
|
|
- ];
|
|
|
+ // 待学习单词列表(动态加载)
|
|
|
+ learningWords: WordItem[] = [];
|
|
|
+
|
|
|
+ // 已学习单词列表(动态加载)
|
|
|
+ learnedWords: WordItem[] = [];
|
|
|
+ isLoadingWordProgress = false;
|
|
|
+ wordProgressError = '';
|
|
|
+ readonly pageSize = 80;
|
|
|
+ visibleLearningCount = this.pageSize;
|
|
|
+ visibleLearnedCount = this.pageSize;
|
|
|
+ private readonly tabScrollTop: Record<'learning' | 'learned', number> = {
|
|
|
+ learning: 0,
|
|
|
+ learned: 0
|
|
|
+ };
|
|
|
|
|
|
// 风格选择弹窗状态
|
|
|
showStyleModal: boolean = false;
|
|
|
@@ -174,6 +180,9 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
// 闪卡单词列表
|
|
|
flashWords: FlashWord[] = [];
|
|
|
currentFlashIndex: number = 0;
|
|
|
+ isAmericanPronunciation: boolean = true;
|
|
|
+ private flashWordSourceMap = new Map<string, WordItem>();
|
|
|
+ private flashBuildToken = 0;
|
|
|
|
|
|
// 闪卡触摸/鼠标滑动相关
|
|
|
private touchStartX: number = 0;
|
|
|
@@ -181,6 +190,8 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
private isDragging: boolean = false;
|
|
|
isSliding: boolean = false;
|
|
|
cardTransform: string = '';
|
|
|
+ private dragFrameId: number | null = null;
|
|
|
+ private pendingDragDiff: number = 0;
|
|
|
|
|
|
// 向导相关
|
|
|
wizardSteps = [
|
|
|
@@ -206,7 +217,11 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
// 风格多选的最大数量
|
|
|
readonly maxStyleCount = 3;
|
|
|
|
|
|
- constructor(private router: Router, private wordStoryService: WordStoryService) {
|
|
|
+ constructor(
|
|
|
+ private router: Router,
|
|
|
+ private wordStoryService: WordStoryService,
|
|
|
+ private http: HttpClient
|
|
|
+ ) {
|
|
|
addIcons({
|
|
|
arrowBackOutline,
|
|
|
helpCircleOutline,
|
|
|
@@ -258,10 +273,210 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
// 与 currentTab 保持一致
|
|
|
- this.tabSwipeProgress = this.currentTab === 'learned' ? 1 : 0;
|
|
|
+ this.tabSwipeProgress = this.currentTab === 'learned' ? 0 : 1;
|
|
|
+ this.loadWordsFromProgress();
|
|
|
if (this.exerciseStory && !this.isHistoryReplay) this.initExerciseState();
|
|
|
}
|
|
|
|
|
|
+ async ionViewWillEnter(): Promise<void> {
|
|
|
+ this.resetListBrowseStateOnEnter();
|
|
|
+ await this.loadWordsFromProgress();
|
|
|
+ this.restoreTabScrollTop(this.currentTab);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadWordsFromProgress(): Promise<void> {
|
|
|
+ this.isLoadingWordProgress = true;
|
|
|
+ this.wordProgressError = '';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const user = await CloudUser.getCurrent();
|
|
|
+ if (!user?.id) {
|
|
|
+ this.learningWords = [];
|
|
|
+ this.learnedWords = [];
|
|
|
+ this.wordProgressError = '请先登录后查看单词';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const wordList = await this.resolveWordListInfo();
|
|
|
+ const baseWords = await this.loadWordsFromAsset(wordList.assetKey);
|
|
|
+ const rowList = await this.loadProgressRowsByList(user.id, wordList.id);
|
|
|
+ const rows = rowList.length > 0 ? rowList : await this.loadProgressRowsByList(user.id, null);
|
|
|
+
|
|
|
+ const normalizedMap = new Map<string, WordItem>();
|
|
|
+ for (const baseWord of baseWords) {
|
|
|
+ normalizedMap.set(baseWord.word.toLowerCase(), { ...baseWord });
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const row of rows) {
|
|
|
+ const wordText = String(row.get('wordText') ?? '').trim();
|
|
|
+ if (!wordText) continue;
|
|
|
+
|
|
|
+ const normalized = wordText.toLowerCase();
|
|
|
+ const existing = normalizedMap.get(normalized);
|
|
|
+
|
|
|
+ const studyCount = Number(row.get('studyCount'));
|
|
|
+ const safeStudyCount = Number.isNaN(studyCount) ? 0 : studyCount;
|
|
|
+ const isNewDone = !!row.get('isNewDone');
|
|
|
+
|
|
|
+ normalizedMap.set(normalized, {
|
|
|
+ id: existing?.id || row.id || normalized,
|
|
|
+ word: existing?.word || wordText,
|
|
|
+ meaning: existing?.meaning && existing.meaning !== '暂无释义'
|
|
|
+ ? existing.meaning
|
|
|
+ : this.extractMeaning(row),
|
|
|
+ selected: existing?.selected ?? false,
|
|
|
+ studyCount: safeStudyCount,
|
|
|
+ isNewDone,
|
|
|
+ lastReviewAt: this.parseDateToIso(row.get('lastReviewAt')),
|
|
|
+ phonetic: existing?.phonetic,
|
|
|
+ partOfSpeech: existing?.partOfSpeech
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const allWords = Array.from(normalizedMap.values());
|
|
|
+ this.learnedWords = allWords
|
|
|
+ .filter((item) => item.studyCount >= 3 || item.isNewDone)
|
|
|
+ .sort((a, b) => this.getSortTimestamp(b.lastReviewAt) - this.getSortTimestamp(a.lastReviewAt));
|
|
|
+ this.learningWords = allWords
|
|
|
+ .filter((item) => !(item.studyCount >= 3 || item.isNewDone))
|
|
|
+ .sort((a, b) => a.word.localeCompare(b.word));
|
|
|
+ this.rebuildFlashWordSourceMap();
|
|
|
+ this.visibleLearningCount = Math.min(this.pageSize, this.learningWords.length || this.pageSize);
|
|
|
+ this.visibleLearnedCount = Math.min(this.pageSize, this.learnedWords.length || this.pageSize);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[WordStory] 加载动态单词失败:', error);
|
|
|
+ this.wordProgressError = '加载单词失败,请稍后重试';
|
|
|
+ this.learningWords = [];
|
|
|
+ this.learnedWords = [];
|
|
|
+ } finally {
|
|
|
+ this.isLoadingWordProgress = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private extractMeaning(row: CloudObject): string {
|
|
|
+ const candidateKeys = ['meaning', 'translation', 'wordMeaning', 'chineseMeaning'];
|
|
|
+ for (const key of candidateKeys) {
|
|
|
+ const value = row.get(key);
|
|
|
+ if (typeof value === 'string' && value.trim()) {
|
|
|
+ return this.limitMeaningSegments(value.trim(), 2);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return '暂无释义';
|
|
|
+ }
|
|
|
+
|
|
|
+ private async resolveWordListInfo(): Promise<WordListInfo> {
|
|
|
+ const q = new CloudQuery('wordList');
|
|
|
+ q.equalTo('assetKey', RECITE_WORDLIST_ASSET_KEY);
|
|
|
+ const row = await q.first();
|
|
|
+ if (!row) {
|
|
|
+ return { id: null, assetKey: RECITE_WORDLIST_ASSET_KEY };
|
|
|
+ }
|
|
|
+ const assetKey = String(row.get('assetKey') ?? RECITE_WORDLIST_ASSET_KEY).trim() || RECITE_WORDLIST_ASSET_KEY;
|
|
|
+ return { id: row.id, assetKey };
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadProgressRowsByList(userId: string, listId: string | null): Promise<CloudObject[]> {
|
|
|
+ const q = new CloudQuery('wordUserProgress');
|
|
|
+ q.equalTo('user', { __type: 'Pointer', className: '_User', objectId: userId });
|
|
|
+ if (listId) {
|
|
|
+ q.equalTo('list', { __type: 'Pointer', className: 'wordList', objectId: listId });
|
|
|
+ }
|
|
|
+ return q.find();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadWordsFromAsset(assetKey: string): Promise<WordItem[]> {
|
|
|
+ const safeAssetKey = assetKey || RECITE_WORDLIST_ASSET_KEY;
|
|
|
+ const text = await firstValueFrom(this.http.get(`assets/${safeAssetKey}.txt`, { responseType: 'text' }));
|
|
|
+ const lines = text.split('\n');
|
|
|
+ const result: WordItem[] = [];
|
|
|
+ const seen = new Set<string>();
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ const trimmed = line.trim();
|
|
|
+ if (!trimmed) continue;
|
|
|
+ const parts = trimmed.split('#');
|
|
|
+ if (parts.length < 3) continue;
|
|
|
+
|
|
|
+ const word = parts[0].trim();
|
|
|
+ if (!word) continue;
|
|
|
+
|
|
|
+ const normalized = word.toLowerCase();
|
|
|
+ if (seen.has(normalized)) continue;
|
|
|
+ seen.add(normalized);
|
|
|
+
|
|
|
+ const rawMeaning = parts[2].trim();
|
|
|
+ const meaningParts = parts.slice(2).map((x) => x.trim()).filter(Boolean);
|
|
|
+ const meaning = this.buildMeaningWithPos(meaningParts);
|
|
|
+ const phonetic = parts[1].trim();
|
|
|
+ const posMatch = rawMeaning.match(/^([a-z]+\.)/i);
|
|
|
+ const partOfSpeech = posMatch ? posMatch[1].toLowerCase() : '';
|
|
|
+
|
|
|
+ result.push({
|
|
|
+ id: `asset-${normalized}`,
|
|
|
+ word,
|
|
|
+ meaning,
|
|
|
+ selected: false,
|
|
|
+ studyCount: 0,
|
|
|
+ isNewDone: false,
|
|
|
+ lastReviewAt: undefined,
|
|
|
+ phonetic,
|
|
|
+ partOfSpeech,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private parseDateToIso(value: unknown): string | undefined {
|
|
|
+ if (!value) return undefined;
|
|
|
+ if (typeof value === 'string') return value;
|
|
|
+ if (typeof value === 'object') {
|
|
|
+ const maybeDate = value as { __type?: string; iso?: string };
|
|
|
+ if (maybeDate.__type === 'Date' && typeof maybeDate.iso === 'string') {
|
|
|
+ return maybeDate.iso;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ private getSortTimestamp(iso?: string): number {
|
|
|
+ if (!iso) return -1;
|
|
|
+ const ts = new Date(iso).getTime();
|
|
|
+ return Number.isNaN(ts) ? -1 : ts;
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildMeaningWithPos(parts: string[]): string {
|
|
|
+ const firstPart = parts[0]?.trim();
|
|
|
+ if (!firstPart) {
|
|
|
+ return '暂无释义';
|
|
|
+ }
|
|
|
+
|
|
|
+ const formatted = this.normalizeMeaningPart(firstPart);
|
|
|
+ return formatted || '暂无释义';
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeMeaningPart(rawPart: string): string {
|
|
|
+ const part = String(rawPart ?? '').trim();
|
|
|
+ if (!part) return '';
|
|
|
+
|
|
|
+ const matched = part.match(/^([a-z]+\.)\s*(.+)$/i);
|
|
|
+ if (!matched) {
|
|
|
+ return this.limitMeaningSegments(part, 2);
|
|
|
+ }
|
|
|
+
|
|
|
+ const pos = matched[1].toLowerCase();
|
|
|
+ const meaning = this.limitMeaningSegments(matched[2], 2);
|
|
|
+ return `${pos} ${meaning}`.trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private limitMeaningSegments(raw: string, maxSegments: number): string {
|
|
|
+ const segments = this.splitMeaningOutsideBrackets(String(raw ?? ''));
|
|
|
+ if (!segments.length) {
|
|
|
+ return String(raw ?? '').trim();
|
|
|
+ }
|
|
|
+ return segments.slice(0, maxSegments).join(';');
|
|
|
+ }
|
|
|
+
|
|
|
/** 仅在“选择单词列表页”时禁用页面整体滚动,让列表区域单独滚动 */
|
|
|
get isWordListMode(): boolean {
|
|
|
return !this.isGenerating && !this.storyResponse && !this.showExerciseView;
|
|
|
@@ -420,6 +635,34 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
return this.currentTab === 'learning' ? this.learningWords : this.learnedWords;
|
|
|
}
|
|
|
|
|
|
+ get visibleLearningWords(): WordItem[] {
|
|
|
+ return this.learningWords.slice(0, this.visibleLearningCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ get visibleLearnedWords(): WordItem[] {
|
|
|
+ return this.learnedWords.slice(0, this.visibleLearnedCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ get hasMoreLearningWords(): boolean {
|
|
|
+ return this.visibleLearningCount < this.learningWords.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ get hasMoreLearnedWords(): boolean {
|
|
|
+ return this.visibleLearnedCount < this.learnedWords.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ loadMoreLearningWords(): void {
|
|
|
+ this.visibleLearningCount = Math.min(this.visibleLearningCount + this.pageSize, this.learningWords.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ loadMoreLearnedWords(): void {
|
|
|
+ this.visibleLearnedCount = Math.min(this.visibleLearnedCount + this.pageSize, this.learnedWords.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ trackByWordId(index: number, word: WordItem): string {
|
|
|
+ return word.id || word.word;
|
|
|
+ }
|
|
|
+
|
|
|
// 获取已选中的单词数量(包括待学习和已学习)
|
|
|
get selectedCount(): number {
|
|
|
return [...this.learningWords, ...this.learnedWords].filter(word => word.selected).length;
|
|
|
@@ -427,10 +670,12 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 切换标签页
|
|
|
switchTab(tab: 'learning' | 'learned') {
|
|
|
+ this.captureCurrentTabScrollTop();
|
|
|
this.currentTab = tab;
|
|
|
// 吸附到目标页
|
|
|
this.wordListTransition = 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)';
|
|
|
- this.tabSwipeProgress = tab === 'learned' ? 1 : 0;
|
|
|
+ this.tabSwipeProgress = tab === 'learned' ? 0 : 1;
|
|
|
+ this.restoreTabScrollTop(tab);
|
|
|
}
|
|
|
|
|
|
// 切换单词选中状态
|
|
|
@@ -467,7 +712,10 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
if (diffX > diffY && diffX > 10) {
|
|
|
this.isListDragging = true;
|
|
|
this.listHasHorizontalMove = true;
|
|
|
- event.preventDefault(); // 只有水平滑动才阻止页面滚动
|
|
|
+ // 某些浏览器在滚动进行中会把 touchmove 标记为不可取消,需先判断避免 Intervention 警告
|
|
|
+ if (event.cancelable) {
|
|
|
+ event.preventDefault(); // 只有水平滑动才阻止页面滚动
|
|
|
+ }
|
|
|
|
|
|
const currentX = event.touches[0].clientX;
|
|
|
const deltaX = currentX - this.listTouchStartX;
|
|
|
@@ -543,16 +791,18 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
/** 松手后吸附到最近的 tab,并给出方向提示(不做动画脉冲,只保留指示器本身) */
|
|
|
private snapToNearestTab() {
|
|
|
// 提高吸附阈值:需要超过 60% 才切到另一页,避免轻微滑动就切换
|
|
|
- const targetTab: 'learning' | 'learned' = this.tabSwipeProgress >= 0.6 ? 'learned' : 'learning';
|
|
|
+ const targetTab: 'learning' | 'learned' = this.tabSwipeProgress >= 0.6 ? 'learning' : 'learned';
|
|
|
const fromTab = this.currentTab;
|
|
|
|
|
|
+ this.captureCurrentTabScrollTop();
|
|
|
this.wordListTransition = 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)';
|
|
|
this.currentTab = targetTab;
|
|
|
- this.tabSwipeProgress = targetTab === 'learned' ? 1 : 0;
|
|
|
+ this.tabSwipeProgress = targetTab === 'learned' ? 0 : 1;
|
|
|
+ this.restoreTabScrollTop(targetTab);
|
|
|
|
|
|
// 仅在确实切换时展示方向指示器
|
|
|
if (fromTab !== targetTab) {
|
|
|
- this.listSwipeDirection = targetTab === 'learned' ? 'left' : 'right';
|
|
|
+ this.listSwipeDirection = targetTab === 'learned' ? 'right' : 'left';
|
|
|
if (window.navigator && window.navigator.vibrate) {
|
|
|
window.navigator.vibrate(10);
|
|
|
}
|
|
|
@@ -627,16 +877,64 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
const threshold = 50; // 滑动阈值
|
|
|
|
|
|
if (Math.abs(diff) > threshold) {
|
|
|
- if (diff > 0 && this.currentTab === 'learned') {
|
|
|
- // 向右滑动,切换到待学习
|
|
|
- this.switchTab('learning');
|
|
|
- } else if (diff < 0 && this.currentTab === 'learning') {
|
|
|
- // 向左滑动,切换到已学习
|
|
|
+ if (diff > 0 && this.currentTab === 'learning') {
|
|
|
+ // 向右滑动,切换到已学习
|
|
|
this.switchTab('learned');
|
|
|
+ } else if (diff < 0 && this.currentTab === 'learned') {
|
|
|
+ // 向左滑动,切换到待学习
|
|
|
+ this.switchTab('learning');
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ onWordListScroll(tab: 'learning' | 'learned'): void {
|
|
|
+ const el = this.getScrollElement(tab);
|
|
|
+ if (!el) return;
|
|
|
+ this.tabScrollTop[tab] = el.scrollTop;
|
|
|
+ }
|
|
|
+
|
|
|
+ private captureCurrentTabScrollTop(): void {
|
|
|
+ const el = this.getScrollElement(this.currentTab);
|
|
|
+ if (!el) return;
|
|
|
+ this.tabScrollTop[this.currentTab] = el.scrollTop;
|
|
|
+ }
|
|
|
+
|
|
|
+ private restoreTabScrollTop(tab: 'learning' | 'learned'): void {
|
|
|
+ const el = this.getScrollElement(tab);
|
|
|
+ if (!el) return;
|
|
|
+ const targetTop = this.tabScrollTop[tab] ?? 0;
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ el.scrollTop = targetTop;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private getScrollElement(tab: 'learning' | 'learned'): HTMLElement | null {
|
|
|
+ return tab === 'learned'
|
|
|
+ ? (this.learnedScrollRef?.nativeElement ?? null)
|
|
|
+ : (this.learningScrollRef?.nativeElement ?? null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 页面重新进入时重置列表浏览状态:
|
|
|
+ * - 回到默认 tab(已学习)
|
|
|
+ * - 清空两侧列表滚动位置
|
|
|
+ */
|
|
|
+ private resetListBrowseStateOnEnter(): void {
|
|
|
+ this.currentTab = 'learned';
|
|
|
+ this.tabSwipeProgress = 0;
|
|
|
+ this.tabScrollTop.learning = 0;
|
|
|
+ this.tabScrollTop.learned = 0;
|
|
|
+ this.learningWords.forEach((word) => { word.selected = false; });
|
|
|
+ this.learnedWords.forEach((word) => { word.selected = false; });
|
|
|
+
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const learnedEl = this.learnedScrollRef?.nativeElement;
|
|
|
+ const learningEl = this.learningScrollRef?.nativeElement;
|
|
|
+ if (learnedEl) learnedEl.scrollTop = 0;
|
|
|
+ if (learningEl) learningEl.scrollTop = 0;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
/** ========== 闪卡相关方法 ========== */
|
|
|
|
|
|
// 获取当前闪卡单词
|
|
|
@@ -646,6 +944,16 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 解析单词为闪卡格式
|
|
|
private parseWordItem(wordText: string): FlashWord {
|
|
|
+ const dynamicWord = this.flashWordSourceMap.get(wordText.toLowerCase());
|
|
|
+ if (dynamicWord) {
|
|
|
+ return {
|
|
|
+ word: dynamicWord.word,
|
|
|
+ phonetic: dynamicWord.phonetic || '',
|
|
|
+ partOfSpeech: dynamicWord.partOfSpeech || '',
|
|
|
+ chineseMeaning: this.normalizeFlashMeaning(dynamicWord.meaning)
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
const wordMap: { [key: string]: FlashWord } = {
|
|
|
'add': { word: 'add', phonetic: '/æd/', partOfSpeech: 'v.', chineseMeaning: '增加;相加' },
|
|
|
'addition': { word: 'addition', phonetic: '/əˈdɪʃn/', partOfSpeech: 'n.', chineseMeaning: '增添;添加物' },
|
|
|
@@ -667,38 +975,79 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
'knowledge': { word: 'knowledge', phonetic: '/ˈnɑːlɪdʒ/', partOfSpeech: 'n.', chineseMeaning: '知识' }
|
|
|
};
|
|
|
|
|
|
- return wordMap[wordText] || {
|
|
|
- word: wordText,
|
|
|
- phonetic: '',
|
|
|
- partOfSpeech: 'n.',
|
|
|
- chineseMeaning: '未知'
|
|
|
+ const fallback = wordMap[wordText] || {
|
|
|
+ word: wordText,
|
|
|
+ phonetic: '',
|
|
|
+ partOfSpeech: 'n.',
|
|
|
+ chineseMeaning: '未知'
|
|
|
};
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...fallback,
|
|
|
+ chineseMeaning: this.normalizeFlashMeaning(fallback.chineseMeaning)
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeFlashMeaning(rawMeaning?: string): string {
|
|
|
+ const text = String(rawMeaning ?? '').trim();
|
|
|
+ if (!text) return '暂无释义';
|
|
|
+
|
|
|
+ const matched = text.match(/^([a-z]+\.)\s*(.+)$/i);
|
|
|
+ if (!matched) {
|
|
|
+ const defs = this.splitMeaningOutsideBrackets(text);
|
|
|
+ return defs.slice(0, 2).join(';') || text;
|
|
|
+ }
|
|
|
+
|
|
|
+ const pos = matched[1].toLowerCase();
|
|
|
+ const defs = this.splitMeaningOutsideBrackets(matched[2]);
|
|
|
+ const kept = defs.slice(0, 2).join(';');
|
|
|
+ return kept ? `${pos} ${kept}` : pos;
|
|
|
+ }
|
|
|
+
|
|
|
+ private splitMeaningOutsideBrackets(text: string): string[] {
|
|
|
+ const result: string[] = [];
|
|
|
+ let current = '';
|
|
|
+ let depth = 0;
|
|
|
+ const separators = new Set([';', ';', '、', ',', ',']);
|
|
|
+
|
|
|
+ for (const ch of text) {
|
|
|
+ if (ch === '(' || ch === '(') {
|
|
|
+ depth++;
|
|
|
+ current += ch;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (ch === ')' || ch === ')') {
|
|
|
+ depth = Math.max(0, depth - 1);
|
|
|
+ current += ch;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (depth === 0 && separators.has(ch)) {
|
|
|
+ const trimmed = current.trim();
|
|
|
+ if (trimmed) {
|
|
|
+ result.push(trimmed);
|
|
|
+ }
|
|
|
+ current = '';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ current += ch;
|
|
|
+ }
|
|
|
+
|
|
|
+ const tail = current.trim();
|
|
|
+ if (tail) {
|
|
|
+ result.push(tail);
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
}
|
|
|
|
|
|
// 触摸移动 - 优化跟随手感
|
|
|
onTouchMove(event: TouchEvent) {
|
|
|
if (!this.isDragging) return;
|
|
|
-
|
|
|
+
|
|
|
const currentX = event.touches[0].clientX;
|
|
|
- const diff = currentX - this.touchStartX;
|
|
|
-
|
|
|
- // 阻力系数:越往外拉越费劲
|
|
|
- const resistance = 0.8;
|
|
|
- const resistedDiff = diff * resistance;
|
|
|
-
|
|
|
- // 旋转角度随拖动距离增加
|
|
|
- const rotate = resistedDiff * 0.1;
|
|
|
-
|
|
|
- // 轻微缩放模拟抬起效果
|
|
|
- const scale = 1 - Math.abs(resistedDiff) * 0.0002;
|
|
|
-
|
|
|
- this.cardTransform = `translateX(${resistedDiff}px) rotate(${rotate}deg) scale(${scale})`;
|
|
|
-
|
|
|
- if (diff > 0) {
|
|
|
- this.swipeDirection = 'right';
|
|
|
- } else if (diff < 0) {
|
|
|
- this.swipeDirection = 'left';
|
|
|
- }
|
|
|
+ this.scheduleDragUpdate(currentX - this.touchStartX);
|
|
|
}
|
|
|
|
|
|
// 触摸事件处理
|
|
|
@@ -729,27 +1078,15 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
// 鼠标移动
|
|
|
onMouseMove(event: MouseEvent) {
|
|
|
if (!this.isDragging) return;
|
|
|
-
|
|
|
+
|
|
|
const currentX = event.clientX;
|
|
|
- const diff = currentX - this.touchStartX;
|
|
|
-
|
|
|
- const resistance = 0.8;
|
|
|
- const resistedDiff = diff * resistance;
|
|
|
- const rotate = resistedDiff * 0.1;
|
|
|
- const scale = 1 - Math.abs(resistedDiff) * 0.0002;
|
|
|
-
|
|
|
- this.cardTransform = `translateX(${resistedDiff}px) rotate(${rotate}deg) scale(${scale})`;
|
|
|
-
|
|
|
- if (diff > 0) {
|
|
|
- this.swipeDirection = 'right';
|
|
|
- } else if (diff < 0) {
|
|
|
- this.swipeDirection = 'left';
|
|
|
- }
|
|
|
+ this.scheduleDragUpdate(currentX - this.touchStartX);
|
|
|
}
|
|
|
|
|
|
// 处理滑动
|
|
|
private handleSwipe() {
|
|
|
if (this.isAnimating) return;
|
|
|
+ this.cancelDragFrame();
|
|
|
|
|
|
const diff = this.touchEndX - this.touchStartX;
|
|
|
const threshold = 60;
|
|
|
@@ -768,6 +1105,43 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
this.cardTransform = '';
|
|
|
this.isDragging = false;
|
|
|
this.isSliding = false;
|
|
|
+ this.swipeDirection = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private scheduleDragUpdate(diff: number): void {
|
|
|
+ this.pendingDragDiff = diff;
|
|
|
+ if (this.dragFrameId !== null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.dragFrameId = window.requestAnimationFrame(() => {
|
|
|
+ this.dragFrameId = null;
|
|
|
+ this.applyDragTransform(this.pendingDragDiff);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private applyDragTransform(diff: number): void {
|
|
|
+ const resistance = 0.8;
|
|
|
+ const resistedDiff = diff * resistance;
|
|
|
+ const rotate = resistedDiff * 0.1;
|
|
|
+ const scale = 1 - Math.abs(resistedDiff) * 0.0002;
|
|
|
+
|
|
|
+ this.cardTransform = `translateX(${resistedDiff}px) rotate(${rotate}deg) scale(${scale})`;
|
|
|
+
|
|
|
+ if (diff > 0) {
|
|
|
+ this.swipeDirection = 'right';
|
|
|
+ } else if (diff < 0) {
|
|
|
+ this.swipeDirection = 'left';
|
|
|
+ } else {
|
|
|
+ this.swipeDirection = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private cancelDragFrame(): void {
|
|
|
+ if (this.dragFrameId !== null) {
|
|
|
+ window.cancelAnimationFrame(this.dragFrameId);
|
|
|
+ this.dragFrameId = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -972,10 +1346,7 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 打开风格选择弹窗(重置向导)
|
|
|
generateStory() {
|
|
|
- // 从两个列表中获取所有选中的单词
|
|
|
- const selectedWords = [...this.learningWords, ...this.learnedWords]
|
|
|
- .filter(word => word.selected)
|
|
|
- .map(word => word.word);
|
|
|
+ const selectedWords = this.getSelectedWords();
|
|
|
|
|
|
if (selectedWords.length < 5 || selectedWords.length > 10) {
|
|
|
return;
|
|
|
@@ -998,18 +1369,19 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 确认风格并生成故事
|
|
|
confirmStyle() {
|
|
|
- // 从两个列表中获取所有选中的单词
|
|
|
- const selectedWords = [...this.learningWords, ...this.learnedWords]
|
|
|
- .filter(word => word.selected)
|
|
|
- .map(word => word.word);
|
|
|
+ const selectedWords = this.getSelectedWords();
|
|
|
|
|
|
- // 构建闪卡数据(用于滑动复习)
|
|
|
- this.flashWords = selectedWords.map(word => this.parseWordItem(word));
|
|
|
+ // 构建等待页动态单词序列:已选 -> 已学习 -> 待学习(去重)
|
|
|
+ const waitingWords = this.getWaitingFlashWordOrder(selectedWords);
|
|
|
+ this.cancelFlashWordBuild();
|
|
|
+ this.flashWords = [];
|
|
|
this.currentFlashIndex = 0;
|
|
|
|
|
|
this.closeStyleModal();
|
|
|
this.isGenerating = true;
|
|
|
this.storyError = null;
|
|
|
+ // 先渲染等待页,再分批构建闪卡,避免点击瞬间卡顿
|
|
|
+ setTimeout(() => this.buildFlashWordsInBatches(waitingWords), 0);
|
|
|
|
|
|
// 将风格数组转为逗号分隔的字符串
|
|
|
const styleString = this.wizardData.styles.join('、');
|
|
|
@@ -1039,6 +1411,71 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
this.confirmStyle();
|
|
|
}
|
|
|
|
|
|
+ private getSelectedWords(): string[] {
|
|
|
+ return [...this.learningWords, ...this.learnedWords]
|
|
|
+ .filter((word) => word.selected)
|
|
|
+ .map((word) => word.word);
|
|
|
+ }
|
|
|
+
|
|
|
+ private getWaitingFlashWordOrder(selectedWords: string[]): string[] {
|
|
|
+ const selected = selectedWords;
|
|
|
+ const learned = this.learnedWords.map((item) => item.word);
|
|
|
+ const learning = this.learningWords.map((item) => item.word);
|
|
|
+ return this.dedupeWordsKeepOrder([...selected, ...learned, ...learning]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private rebuildFlashWordSourceMap(): void {
|
|
|
+ const map = new Map<string, WordItem>();
|
|
|
+ for (const item of [...this.learningWords, ...this.learnedWords]) {
|
|
|
+ map.set(item.word.toLowerCase(), item);
|
|
|
+ }
|
|
|
+ this.flashWordSourceMap = map;
|
|
|
+ }
|
|
|
+
|
|
|
+ private cancelFlashWordBuild(): void {
|
|
|
+ this.flashBuildToken++;
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildFlashWordsInBatches(words: string[]): void {
|
|
|
+ const token = ++this.flashBuildToken;
|
|
|
+ const result: FlashWord[] = [];
|
|
|
+ const batchSize = 120;
|
|
|
+ let index = 0;
|
|
|
+
|
|
|
+ const processBatch = () => {
|
|
|
+ if (token !== this.flashBuildToken) return;
|
|
|
+
|
|
|
+ const end = Math.min(index + batchSize, words.length);
|
|
|
+ for (let i = index; i < end; i++) {
|
|
|
+ result.push(this.parseWordItem(words[i]));
|
|
|
+ }
|
|
|
+ index = end;
|
|
|
+ this.flashWords = [...result];
|
|
|
+
|
|
|
+ if (index < words.length) {
|
|
|
+ setTimeout(processBatch, 0);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ processBatch();
|
|
|
+ }
|
|
|
+
|
|
|
+ private dedupeWordsKeepOrder(words: string[]): string[] {
|
|
|
+ const set = new Set<string>();
|
|
|
+ const result: string[] = [];
|
|
|
+ for (const word of words) {
|
|
|
+ const normalized = String(word ?? '').trim().toLowerCase();
|
|
|
+ if (!normalized || set.has(normalized)) continue;
|
|
|
+ set.add(normalized);
|
|
|
+ result.push(String(word).trim());
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ onFlashPronunciationToggled(isAmerican: boolean): void {
|
|
|
+ this.isAmericanPronunciation = isAmerican;
|
|
|
+ }
|
|
|
+
|
|
|
// 返回上一页
|
|
|
goBack() {
|
|
|
// 历史回放模式:返回直接回历史列表,不弹框
|
|
|
@@ -1060,6 +1497,7 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
}
|
|
|
if (this.isGenerating) {
|
|
|
this.isGenerating = false;
|
|
|
+ this.cancelFlashWordBuild();
|
|
|
this.flashWords = [];
|
|
|
this.currentFlashIndex = 0;
|
|
|
} else {
|
|
|
@@ -1145,6 +1583,7 @@ listSwipeDirection: 'left' | 'right' | null = null;
|
|
|
|
|
|
// 重置页面状态
|
|
|
private resetState() {
|
|
|
+ this.cancelFlashWordBuild();
|
|
|
this.showExerciseView = false;
|
|
|
this.exerciseStory = null;
|
|
|
this.exerciseBlanks = [];
|