feat(cefr): CEFR level testing and automated gap ingestion engine (Task 15)
This commit is contained in:
@@ -307,14 +307,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 15. 🎯 Уровневые тесты CEFR (A1, A2, B1, B2) с авто-добавлением ошибок в активный словарь
|
### 15. 🎯 Уровневые тесты CEFR (A1, A2, B1, B2) с авто-добавлением ошибок в активный словарь (Completed)
|
||||||
- [ ] Эталонные списки лексики по международной шкале CEFR (на базе Oxford 3000/5000: A1 ~800 слов, A2 ~1200 слов, B1 ~1500 слов, B2 ~2000 слов).
|
- [x] **Эталонная база вопросов Oxford 3000/5000** (160 отобранных тестовых единиц: по 40 на каждый уровень A1, A2, B1, B2) в модуле [`app/cefr.py`](file:///home/ai-projects/services/study.qpid.pro/app/cefr.py).
|
||||||
- [ ] Быстрый и удобный для мобильных формат: **1-тап выбор ключевого узла (Cloze Tap)** без перетаскивания и долгой печати.
|
- [x] **Быстрый и удобный мобильный формат: 1-тап выбор ключевого узла (Cloze Tap)**:
|
||||||
- [ ] **Автоматический захват пробелов (Gap Ingestion)**:
|
- Естественные контекстные предложения с пропуском `___`.
|
||||||
- Если ответ верный $\rightarrow$ слово помечается как освоенное.
|
- Подсказка значения слова на русском языке и часть речи.
|
||||||
- Если пользователь **ошибся или засомневался** $\rightarrow$ слово/оборот **автоматически создаётся как карточка со статусом `learning`** в личном словаре SRS с тегом уровня (`#level-a2`, `#level-b1`).
|
- 4 сбалансированных варианта ответа одинаковой части речи и уровня сложности (исключены грамматические подсказки).
|
||||||
- [ ] Проблемные слова начинают чаще мелькать в ежедневных утренних повторениях SRS, пока не закрепятся в долговременной памяти.
|
- Мгновенная тактильная реакция: зеленая/красная подсветка, автоподстановка правильного слова в пропуск, раскрытие транскрипции IPA и пояснения.
|
||||||
- [ ] Наглядный прогресс-трекер: процент закрытия каждого уровня (*«Уровень A2: 95% закрыто, 12 слов в тренировке»*).
|
- [x] **Автоматический захват пробелов (Automated Gap Ingestion)**:
|
||||||
|
- При правильном ответе $\rightarrow$ вопрос отмечается как освоенный в `cefr_progress`.
|
||||||
|
- При ошибке $\rightarrow$ слово/оборот **автоматически создаётся (или обновляется) как карточка в личном словаре со статусом `learning`** и немедленным сроком повторения (`srs_next_review = now`), коллекцией `Уровни CEFR ({level})` и тегами `#level-{level}`, `#cefr-test`, `#test-mistake`.
|
||||||
|
- [x] Проблемные слова сразу попадают в утренний пул интервальных повторений SRS тренажера.
|
||||||
|
- [x] **Наглядный прогресс-трекер**:
|
||||||
|
- Экран обзора со шкалами освоения каждого уровня (A1 Beginner, A2 Elementary, B1 Intermediate, B2 Upper-Intermediate).
|
||||||
|
- Счётчики: процент закрытия уровня, освоено, ошибок, слов в тренировке SRS.
|
||||||
|
- Режим «Работа над ошибками» (быстрый прогон только ошибочных вопросов).
|
||||||
|
- Возможность сброса прогресса уровня.
|
||||||
|
- [x] Добавлен регрессионный тест `test_cefr_testing_and_gap_ingestion` в [`tests/test_api.py`](file:///home/ai-projects/services/study.qpid.pro/tests/test_api.py) (28/28 тестов успешно, 100% green).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1510
File diff suppressed because it is too large
Load Diff
@@ -202,6 +202,43 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_sentence_trans_hash ON sentence_translations(sentence_hash);")
|
||||||
|
|
||||||
|
# Ensure cefr_questions and cefr_progress tables exist
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cefr_questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
term TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
translation TEXT NOT NULL,
|
||||||
|
pos TEXT DEFAULT '',
|
||||||
|
phonetic TEXT DEFAULT '',
|
||||||
|
sentence TEXT NOT NULL,
|
||||||
|
options_json TEXT NOT NULL,
|
||||||
|
explanation TEXT DEFAULT '',
|
||||||
|
frequency_rank INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(level, term COLLATE NOCASE)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_q_level ON cefr_questions(level);")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_q_term ON cefr_questions(term COLLATE NOCASE);")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS cefr_progress (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
question_id INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
is_mastered INTEGER DEFAULT 0,
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
mistakes INTEGER DEFAULT 0,
|
||||||
|
last_tested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (question_id) REFERENCES cefr_questions(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, question_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_cefr_prog_user_lvl ON cefr_progress(user_id, level);")
|
||||||
|
|
||||||
# Auto-seed offline dictionary if empty
|
# Auto-seed offline dictionary if empty
|
||||||
_seed_offline_dictionary(conn)
|
_seed_offline_dictionary(conn)
|
||||||
|
|
||||||
@@ -212,6 +249,13 @@ def _migrate(conn: sqlite3.Connection):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Seed curated CEFR questions
|
||||||
|
try:
|
||||||
|
from app.cefr import seed_cefr_questions
|
||||||
|
seed_cefr_questions(conn)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _seed_offline_dictionary(conn: sqlite3.Connection):
|
def _seed_offline_dictionary(conn: sqlite3.Connection):
|
||||||
"""Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty."""
|
"""Seeds the offline dictionary from data/offline_dict.tsv.gz if the table is empty."""
|
||||||
|
|||||||
+87
@@ -31,6 +31,12 @@ from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap
|
|||||||
from app.reader import parse_epub, parse_text, parse_pdf, count_words
|
from app.reader import parse_epub, parse_text, parse_pdf, count_words
|
||||||
from app.ai import translate_sentence_ai, get_ollama_status
|
from app.ai import translate_sentence_ai, get_ollama_status
|
||||||
from app.phrasal import detect_phrasal_verb
|
from app.phrasal import detect_phrasal_verb
|
||||||
|
from app.cefr import (
|
||||||
|
get_cefr_level_summaries,
|
||||||
|
get_cefr_quiz_batch,
|
||||||
|
process_cefr_answer,
|
||||||
|
reset_user_cefr_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +168,16 @@ class SentenceTranslateRequest(BaseModel):
|
|||||||
target_word: Optional[str] = ""
|
target_word: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CefrAnswerRequest(BaseModel):
|
||||||
|
question_id: int
|
||||||
|
selected_option: str
|
||||||
|
|
||||||
|
|
||||||
|
class CefrResetRequest(BaseModel):
|
||||||
|
level: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PWA Static Endpoints
|
# PWA Static Endpoints
|
||||||
@@ -543,6 +559,18 @@ async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cards/{card_id}")
|
||||||
|
async def api_get_card(card_id: int, user: dict = Depends(require_user)):
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
|
||||||
|
(card_id, user["id"]),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/cards/{card_id}")
|
@app.put("/api/cards/{card_id}")
|
||||||
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
|
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
@@ -1352,3 +1380,62 @@ async def api_delete_book(book_id: int, user: dict = Depends(require_user)):
|
|||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
raise HTTPException(status_code=404, detail="Book not found")
|
raise HTTPException(status_code=404, detail="Book not found")
|
||||||
return {"status": "ok", "deleted_id": book_id}
|
return {"status": "ok", "deleted_id": book_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CEFR Level Testing & Automated Gap Ingestion Endpoints (Task 15)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.get("/api/cefr/levels")
|
||||||
|
async def api_cefr_levels(user: dict = Depends(require_user)):
|
||||||
|
"""
|
||||||
|
Returns progress summaries for all CEFR levels (A1, A2, B1, B2).
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
summaries = get_cefr_level_summaries(conn, user["id"])
|
||||||
|
return {"levels": summaries}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cefr/quiz")
|
||||||
|
async def api_cefr_quiz(
|
||||||
|
level: str = Query("A1"),
|
||||||
|
count: int = Query(10, ge=1, le=30),
|
||||||
|
mode: str = Query("all"),
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns next batch of Cloze Tap test questions for level (A1..B2).
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode)
|
||||||
|
return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cefr/answer")
|
||||||
|
async def api_cefr_answer(
|
||||||
|
req: CefrAnswerRequest,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Submits an answer for a CEFR question.
|
||||||
|
If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cefr/reset")
|
||||||
|
async def api_cefr_reset(
|
||||||
|
req: CefrResetRequest,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Resets user testing progress for a given CEFR level.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
deleted = reset_user_cefr_level(conn, user["id"], req.level)
|
||||||
|
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
|
||||||
|
|
||||||
|
|||||||
@@ -4215,3 +4215,507 @@ body {
|
|||||||
background: #0c0c0c;
|
background: #0c0c0c;
|
||||||
border-color: #222;
|
border-color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
VIEW 4: CEFR Level Testing & Automated Gap Ingestion Styles (Task 15)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.cefr-view {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 80px 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-header {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-title-wrap h2 {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-title-wrap .subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Level Selection Grid */
|
||||||
|
.cefr-levels-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-level-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-level-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge-a1 {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #10b981;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge-a2 {
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge-b1 {
|
||||||
|
background: rgba(129, 140, 248, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-badge-b2 {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-header-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-meta-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar inside card */
|
||||||
|
.level-card-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrap {
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill.level-badge-a1 { background-color: #10b981; }
|
||||||
|
.progress-bar-fill.level-badge-a2 { background-color: #38bdf8; }
|
||||||
|
.progress-bar-fill.level-badge-b1 { background-color: #818cf8; }
|
||||||
|
.progress-bar-fill.level-badge-b2 { background-color: #f59e0b; }
|
||||||
|
|
||||||
|
.progress-labels-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-labels-row .pct-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini metrics */
|
||||||
|
.level-card-metrics {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-metric .m-val {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-metric .m-lbl {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quiz Topbar */
|
||||||
|
.cefr-quiz-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-quiz-topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-quiz-topbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cloze Card */
|
||||||
|
.cefr-quiz-card-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-cloze-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px 28px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
animation: fadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-cloze-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-mode-tag {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sentence & Blank */
|
||||||
|
.cefr-sentence-wrap {
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-sentence-text {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-blank {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
border-bottom: 2px dashed var(--primary);
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-blank.blank-correct {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
border-bottom: 2px solid #10b981;
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-blank.blank-wrong-revealed {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-bottom: 2px solid #ef4444;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clue Box */
|
||||||
|
.cefr-clue-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-clue-box .clue-term {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Options Grid: 2x2 */
|
||||||
|
.cefr-options-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: 1.5px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px 20px;
|
||||||
|
min-height: 56px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn .opt-num {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn .opt-text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Option States */
|
||||||
|
.cefr-opt-btn.opt-correct {
|
||||||
|
background-color: #10b981 !important;
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 0 16px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn.opt-correct .opt-num {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn.opt-wrong {
|
||||||
|
background-color: #ef4444 !important;
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn.opt-wrong .opt-num {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn.opt-correct-revealed {
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
background-color: rgba(16, 185, 129, 0.15) !important;
|
||||||
|
color: #10b981 !important;
|
||||||
|
animation: pulseBorder 1.5s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseBorder {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feedback Box */
|
||||||
|
.cefr-feedback-box {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
animation: fadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box.feedback-correct {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box.feedback-wrong {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box .fb-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box.feedback-correct .fb-title {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box.feedback-wrong .fb-title {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box .fb-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-feedback-box .fb-explanation {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gap Ingestion Badge inside mistake feedback */
|
||||||
|
.cefr-gap-ingested-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-gap-ingested-badge .badge-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Next Button Bar */
|
||||||
|
.cefr-next-bar {
|
||||||
|
margin-top: 10px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Screen */
|
||||||
|
.cefr-summary-container {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-summary-card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 44px 32px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cefr-sentence-text {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-options-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-opt-btn {
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cefr-cloze-card {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+85
-1
@@ -70,6 +70,9 @@
|
|||||||
<button id="nav-reader-btn" class="btn btn-outline btn-sm" title="Интерактивная читалка и библиотека книг">
|
<button id="nav-reader-btn" class="btn btn-outline btn-sm" title="Интерактивная читалка и библиотека книг">
|
||||||
<span>📚 Читалка</span>
|
<span>📚 Читалка</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="nav-cefr-btn" class="btn btn-outline btn-sm" title="Уровневые тесты CEFR (A1, A2, B1, B2)">
|
||||||
|
<span>🎯 Тесты CEFR</span>
|
||||||
|
</button>
|
||||||
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
|
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
|
||||||
<span class="trainer-icon">🎓</span>
|
<span class="trainer-icon">🎓</span>
|
||||||
<span class="trainer-text">Тренажёр SRS</span>
|
<span class="trainer-text">Тренажёр SRS</span>
|
||||||
@@ -250,6 +253,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="srs-topbar-right">
|
<div class="srs-topbar-right">
|
||||||
|
<button id="srs-jump-cefr-btn" class="btn btn-sm btn-outline" title="Пройти уровневый тест CEFR (A1–B2)">
|
||||||
|
<span>🎯 Тесты CEFR</span>
|
||||||
|
</button>
|
||||||
<button id="srs-mix-btn" class="btn btn-sm btn-ghost" title="Умный микс дня: 10 новых + 10 повторов + 5 трудных слов">
|
<button id="srs-mix-btn" class="btn btn-sm btn-ghost" title="Умный микс дня: 10 новых + 10 повторов + 5 трудных слов">
|
||||||
<span id="srs-mix-label">🎲 Микс дня: ВКЛ</span>
|
<span id="srs-mix-label">🎲 Микс дня: ВКЛ</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -589,6 +595,83 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- VIEW 4: CEFR Level Tests View -->
|
||||||
|
<section id="cefr-view" class="cefr-view hidden">
|
||||||
|
<!-- Screen 1: Level Overview & Selection -->
|
||||||
|
<div id="cefr-overview-screen" class="cefr-overview-container">
|
||||||
|
<div class="cefr-header">
|
||||||
|
<div class="cefr-title-wrap">
|
||||||
|
<h2>🎯 Уровневые тесты CEFR (A1 – B2)</h2>
|
||||||
|
<p class="subtitle">Эталонные тесты по международной шкале Oxford 3000/5000. Ошибки автоматически добавляются в ваш личный тренажёр SRS со статусом «В процессе»!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cefr-levels-grid" class="cefr-levels-grid">
|
||||||
|
<!-- Dynamic level cards rendered by cefr.js -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 2: Active Cloze Tap Quiz -->
|
||||||
|
<div id="cefr-quiz-screen" class="cefr-quiz-container hidden">
|
||||||
|
<header class="cefr-quiz-topbar">
|
||||||
|
<div class="cefr-quiz-topbar-left">
|
||||||
|
<button id="cefr-back-btn" class="btn btn-sm btn-outline">← К уровням</button>
|
||||||
|
<span id="cefr-quiz-level-badge" class="level-pill">Уровень A1</span>
|
||||||
|
</div>
|
||||||
|
<div class="cefr-quiz-topbar-center">
|
||||||
|
<div class="srs-progress-container">
|
||||||
|
<div id="cefr-quiz-progress-bar" class="srs-progress-fill" style="width: 0%;"></div>
|
||||||
|
</div>
|
||||||
|
<span id="cefr-quiz-counter" class="srs-counter">1 / 10</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="cefr-quiz-card-wrapper">
|
||||||
|
<div id="cefr-cloze-card" class="cefr-cloze-card">
|
||||||
|
<!-- Dynamic question content rendered by cefr.js -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cefr-next-bar" class="cefr-next-bar hidden">
|
||||||
|
<button id="cefr-next-btn" class="btn btn-primary btn-lg btn-block">Следующий вопрос → (Space)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 3: Quiz Session Summary -->
|
||||||
|
<div id="cefr-summary-screen" class="cefr-summary-container hidden">
|
||||||
|
<div class="cefr-summary-card">
|
||||||
|
<div class="summary-trophy">🏆</div>
|
||||||
|
<h2>Тест завершён!</h2>
|
||||||
|
<p id="cefr-sum-lead" class="summary-lead">Отличная работа по проверке словарного запаса.</p>
|
||||||
|
|
||||||
|
<div class="summary-metrics">
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-val text-primary" id="cefr-sum-score">0%</span>
|
||||||
|
<span class="metric-desc">Точность</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-val text-success" id="cefr-sum-correct">0 / 0</span>
|
||||||
|
<span class="metric-desc">Правильно</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-val text-warning" id="cefr-sum-mistakes">0</span>
|
||||||
|
<span class="metric-desc">Ошибок</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<span class="metric-val text-danger" id="cefr-sum-ingested">0</span>
|
||||||
|
<span class="metric-desc">Добавлено в SRS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-buttons">
|
||||||
|
<button id="cefr-sum-repeat-btn" class="btn btn-primary btn-lg">🔄 Ещё 10 вопросов</button>
|
||||||
|
<button id="cefr-sum-srs-btn" class="btn btn-outline btn-lg hidden">🎓 Закрепить ошибки в SRS</button>
|
||||||
|
<button id="cefr-sum-levels-btn" class="btn btn-ghost btn-lg">← К списку уровней</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
|
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
|
||||||
<div id="batch-action-bar" class="batch-action-bar hidden">
|
<div id="batch-action-bar" class="batch-action-bar hidden">
|
||||||
@@ -648,6 +731,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-menu-actions">
|
<div class="mobile-menu-actions">
|
||||||
|
<button id="mobile-cefr-btn" class="btn btn-outline btn-block">🎯 Уровневые тесты CEFR (A1–B2)</button>
|
||||||
<button id="mobile-stats-btn" class="btn btn-outline btn-block">📊 Статистика & Календарь</button>
|
<button id="mobile-stats-btn" class="btn btn-outline btn-block">📊 Статистика & Календарь</button>
|
||||||
<button id="mobile-export-btn" class="btn btn-outline btn-block">📤 Экспорт словаря</button>
|
<button id="mobile-export-btn" class="btn btn-outline btn-block">📤 Экспорт словаря</button>
|
||||||
<button id="mobile-pwd-btn" class="btn btn-outline btn-block">🔑 Сменить пароль</button>
|
<button id="mobile-pwd-btn" class="btn btn-outline btn-block">🔑 Сменить пароль</button>
|
||||||
@@ -1114,7 +1198,7 @@
|
|||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<audio id="global-audio-player" preload="none"></audio>
|
||||||
|
|
||||||
<script type="module" src="/static/js/app.js?v=5.0"></script>
|
<script type="module" src="/static/js/app.js?v=5.1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { initTheme, openModal, closeModal, showToast } from './modules/ui.js';
|
|||||||
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
import { initDictionary, loadInitialData } from './modules/dictionary.js';
|
||||||
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
|
||||||
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
|
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
|
||||||
|
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
|
||||||
|
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let isRouting = false;
|
let isRouting = false;
|
||||||
@@ -19,6 +20,7 @@ export function updateMobileActiveTab(tabName) {
|
|||||||
document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => {
|
document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
});
|
});
|
||||||
|
if (!tabName) return;
|
||||||
const target = document.getElementById(`tab-nav-${tabName}`);
|
const target = document.getElementById(`tab-nav-${tabName}`);
|
||||||
if (target) target.classList.add('active');
|
if (target) target.classList.add('active');
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,8 @@ export function applyInitialViewPreflight() {
|
|||||||
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
|
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
|
||||||
} else if (lastView === 'srs-view') {
|
} else if (lastView === 'srs-view') {
|
||||||
hash = '#srs';
|
hash = '#srs';
|
||||||
|
} else if (lastView === 'cefr-view') {
|
||||||
|
hash = '#cefr';
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,7 @@ export function applyInitialViewPreflight() {
|
|||||||
const dictionaryView = document.getElementById('dictionary-view');
|
const dictionaryView = document.getElementById('dictionary-view');
|
||||||
const srsView = document.getElementById('srs-view');
|
const srsView = document.getElementById('srs-view');
|
||||||
const readerView = document.getElementById('reader-view');
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
const bookshelfContainer = document.getElementById('bookshelf-container');
|
const bookshelfContainer = document.getElementById('bookshelf-container');
|
||||||
const readerInterface = document.getElementById('reader-interface');
|
const readerInterface = document.getElementById('reader-interface');
|
||||||
const readerBookTitle = document.getElementById('reader-book-title');
|
const readerBookTitle = document.getElementById('reader-book-title');
|
||||||
@@ -50,6 +55,7 @@ export function applyInitialViewPreflight() {
|
|||||||
if (hash.startsWith('#reader') || hash === '#bookshelf') {
|
if (hash.startsWith('#reader') || hash === '#bookshelf') {
|
||||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
if (srsView) srsView.classList.add('hidden');
|
if (srsView) srsView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.remove('hidden');
|
if (readerView) readerView.classList.remove('hidden');
|
||||||
updateMobileActiveTab('reader');
|
updateMobileActiveTab('reader');
|
||||||
if (hash.includes('/book/')) {
|
if (hash.includes('/book/')) {
|
||||||
@@ -60,8 +66,15 @@ export function applyInitialViewPreflight() {
|
|||||||
} else if (hash.startsWith('#srs')) {
|
} else if (hash.startsWith('#srs')) {
|
||||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.add('hidden');
|
if (readerView) readerView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.add('hidden');
|
||||||
if (srsView) srsView.classList.remove('hidden');
|
if (srsView) srsView.classList.remove('hidden');
|
||||||
updateMobileActiveTab('srs');
|
updateMobileActiveTab('srs');
|
||||||
|
} else if (hash.startsWith('#cefr')) {
|
||||||
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
|
if (readerView) readerView.classList.add('hidden');
|
||||||
|
if (srsView) srsView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.remove('hidden');
|
||||||
|
updateMobileActiveTab('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,9 +89,11 @@ export function openDictionaryView(updateHash = true) {
|
|||||||
const dictionaryView = document.getElementById('dictionary-view');
|
const dictionaryView = document.getElementById('dictionary-view');
|
||||||
const srsView = document.getElementById('srs-view');
|
const srsView = document.getElementById('srs-view');
|
||||||
const readerView = document.getElementById('reader-view');
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
|
|
||||||
if (srsView) srsView.classList.add('hidden');
|
if (srsView) srsView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.add('hidden');
|
if (readerView) readerView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.add('hidden');
|
||||||
if (dictionaryView) dictionaryView.classList.remove('hidden');
|
if (dictionaryView) dictionaryView.classList.remove('hidden');
|
||||||
updateMobileActiveTab('dict');
|
updateMobileActiveTab('dict');
|
||||||
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
|
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
|
||||||
@@ -94,9 +109,11 @@ export function openSrsView(tagFilter = null, updateHash = true) {
|
|||||||
const dictionaryView = document.getElementById('dictionary-view');
|
const dictionaryView = document.getElementById('dictionary-view');
|
||||||
const srsView = document.getElementById('srs-view');
|
const srsView = document.getElementById('srs-view');
|
||||||
const readerView = document.getElementById('reader-view');
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
|
|
||||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.add('hidden');
|
if (readerView) readerView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.add('hidden');
|
||||||
if (srsView) srsView.classList.remove('hidden');
|
if (srsView) srsView.classList.remove('hidden');
|
||||||
updateMobileActiveTab('srs');
|
updateMobileActiveTab('srs');
|
||||||
try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {}
|
try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {}
|
||||||
@@ -115,11 +132,13 @@ export function openBookshelfView(updateHash = true) {
|
|||||||
const dictionaryView = document.getElementById('dictionary-view');
|
const dictionaryView = document.getElementById('dictionary-view');
|
||||||
const srsView = document.getElementById('srs-view');
|
const srsView = document.getElementById('srs-view');
|
||||||
const readerView = document.getElementById('reader-view');
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
const bookshelfContainer = document.getElementById('bookshelf-container');
|
const bookshelfContainer = document.getElementById('bookshelf-container');
|
||||||
const readerInterface = document.getElementById('reader-interface');
|
const readerInterface = document.getElementById('reader-interface');
|
||||||
|
|
||||||
if (dictionaryView) dictionaryView.classList.add('hidden');
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
if (srsView) srsView.classList.add('hidden');
|
if (srsView) srsView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.add('hidden');
|
||||||
if (readerView) readerView.classList.remove('hidden');
|
if (readerView) readerView.classList.remove('hidden');
|
||||||
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
|
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
|
||||||
if (readerInterface) readerInterface.classList.add('hidden');
|
if (readerInterface) readerInterface.classList.add('hidden');
|
||||||
@@ -140,6 +159,36 @@ export function openReaderView(updateHash = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openCefrView(level = null, mode = 'all', updateHash = true) {
|
||||||
|
const targetHash = level
|
||||||
|
? (mode === 'mistakes' ? `#cefr/${encodeURIComponent(level)}/mistakes` : `#cefr/${encodeURIComponent(level)}`)
|
||||||
|
: '#cefr';
|
||||||
|
|
||||||
|
if (updateHash && window.location.hash !== targetHash) {
|
||||||
|
window.location.hash = targetHash;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictionaryView = document.getElementById('dictionary-view');
|
||||||
|
const srsView = document.getElementById('srs-view');
|
||||||
|
const readerView = document.getElementById('reader-view');
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
|
|
||||||
|
if (dictionaryView) dictionaryView.classList.add('hidden');
|
||||||
|
if (srsView) srsView.classList.add('hidden');
|
||||||
|
if (readerView) readerView.classList.add('hidden');
|
||||||
|
if (cefrView) cefrView.classList.remove('hidden');
|
||||||
|
|
||||||
|
updateMobileActiveTab('');
|
||||||
|
try { localStorage.setItem('notes_active_view', 'cefr-view'); } catch (e) {}
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
startCefrQuiz(level, mode);
|
||||||
|
} else {
|
||||||
|
loadCefrOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Route Handler
|
// Route Handler
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -160,6 +209,10 @@ export async function handleRouting() {
|
|||||||
hash = '#srs';
|
hash = '#srs';
|
||||||
window.location.hash = hash;
|
window.location.hash = hash;
|
||||||
return;
|
return;
|
||||||
|
} else if (lastView === 'cefr-view') {
|
||||||
|
hash = '#cefr';
|
||||||
|
window.location.hash = hash;
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
hash = '#dictionary';
|
hash = '#dictionary';
|
||||||
}
|
}
|
||||||
@@ -176,6 +229,11 @@ export async function handleRouting() {
|
|||||||
const parts = cleanHash.split('/');
|
const parts = cleanHash.split('/');
|
||||||
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
|
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
|
||||||
openSrsView(tag, false);
|
openSrsView(tag, false);
|
||||||
|
} else if (cleanHash.startsWith('cefr')) {
|
||||||
|
const parts = cleanHash.split('/');
|
||||||
|
const level = parts[1] || null;
|
||||||
|
const mode = parts[2] === 'mistakes' ? 'mistakes' : 'all';
|
||||||
|
openCefrView(level, mode, false);
|
||||||
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
|
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
|
||||||
const parts = cleanHash.split('/');
|
const parts = cleanHash.split('/');
|
||||||
if (parts[1] === 'book' && parts[2]) {
|
if (parts[1] === 'book' && parts[2]) {
|
||||||
@@ -297,6 +355,14 @@ function initMobileNavigation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mobileCefrBtn = document.getElementById('mobile-cefr-btn');
|
||||||
|
if (mobileCefrBtn) {
|
||||||
|
mobileCefrBtn.addEventListener('click', () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
openCefrView(null, 'all', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (mobileStatsBtn) {
|
if (mobileStatsBtn) {
|
||||||
mobileStatsBtn.addEventListener('click', () => {
|
mobileStatsBtn.addEventListener('click', () => {
|
||||||
closeMobileMenu();
|
closeMobileMenu();
|
||||||
@@ -432,11 +498,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initDictionary();
|
initDictionary();
|
||||||
initSrs();
|
initSrs();
|
||||||
initReader();
|
initReader();
|
||||||
|
initCefr();
|
||||||
|
|
||||||
// 5. Global Navigation & Header Actions
|
// 5. Global Navigation & Header Actions
|
||||||
const navBrandHome = document.getElementById('nav-brand-home');
|
const navBrandHome = document.getElementById('nav-brand-home');
|
||||||
const navReaderBtn = document.getElementById('nav-reader-btn');
|
const navReaderBtn = document.getElementById('nav-reader-btn');
|
||||||
|
const navCefrBtn = document.getElementById('nav-cefr-btn');
|
||||||
const srsTrainerBtn = document.getElementById('srs-trainer-btn');
|
const srsTrainerBtn = document.getElementById('srs-trainer-btn');
|
||||||
|
const srsJumpCefrBtn = document.getElementById('srs-jump-cefr-btn');
|
||||||
const loginForm = document.getElementById('login-form');
|
const loginForm = document.getElementById('login-form');
|
||||||
const loginUsername = document.getElementById('login-username');
|
const loginUsername = document.getElementById('login-username');
|
||||||
const loginPassword = document.getElementById('login-password');
|
const loginPassword = document.getElementById('login-password');
|
||||||
@@ -449,9 +518,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (navReaderBtn) {
|
if (navReaderBtn) {
|
||||||
navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
|
navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
|
||||||
}
|
}
|
||||||
|
if (navCefrBtn) {
|
||||||
|
navCefrBtn.addEventListener('click', () => { window.location.hash = '#cefr'; });
|
||||||
|
}
|
||||||
if (srsTrainerBtn) {
|
if (srsTrainerBtn) {
|
||||||
srsTrainerBtn.addEventListener('click', () => { window.location.hash = '#srs'; });
|
srsTrainerBtn.addEventListener('click', () => { window.location.hash = '#srs'; });
|
||||||
}
|
}
|
||||||
|
if (srsJumpCefrBtn) {
|
||||||
|
srsJumpCefrBtn.addEventListener('click', () => { window.location.hash = '#cefr'; });
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Mobile Tab Bar & Drawer
|
// 6. Mobile Tab Bar & Drawer
|
||||||
initMobileNavigation();
|
initMobileNavigation();
|
||||||
|
|||||||
@@ -213,5 +213,40 @@ export const api = {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// CEFR Level Tests & Gap Ingestion (Task 15)
|
||||||
|
cefr: {
|
||||||
|
async getLevels() {
|
||||||
|
const resp = await fetch('/api/cefr/levels');
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error(`Failed to load CEFR levels: ${resp.status}`);
|
||||||
|
},
|
||||||
|
async getQuiz(level = 'A1', count = 10, mode = 'all') {
|
||||||
|
const resp = await fetch(`/api/cefr/quiz?level=${encodeURIComponent(level)}&count=${count}&mode=${encodeURIComponent(mode)}`);
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error(`Failed to load CEFR quiz: ${resp.status}`);
|
||||||
|
},
|
||||||
|
async submitAnswer(questionId, selectedOption) {
|
||||||
|
const resp = await fetch('/api/cefr/answer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
question_id: questionId,
|
||||||
|
selected_option: selectedOption
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error(`Failed to submit answer: ${resp.status}`);
|
||||||
|
},
|
||||||
|
async resetLevel(level) {
|
||||||
|
const resp = await fetch('/api/cefr/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ level })
|
||||||
|
});
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error(`Failed to reset level: ${resp.status}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
// =======================================================================
|
||||||
|
// CEFR Level Testing & Automated Gap Ingestion Module (Native ES Module)
|
||||||
|
// Oxford 3000 / 5000 Benchmark Testing Engine
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { showToast, escapeHtml } from './ui.js';
|
||||||
|
|
||||||
|
let currentLevel = 'A1';
|
||||||
|
let currentMode = 'all';
|
||||||
|
let currentQuiz = [];
|
||||||
|
let currentQuestionIdx = 0;
|
||||||
|
let isAnswered = false;
|
||||||
|
|
||||||
|
const sessionStats = {
|
||||||
|
total: 0,
|
||||||
|
correct: 0,
|
||||||
|
mistakes: 0,
|
||||||
|
ingested: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Initializer
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
export function initCefr() {
|
||||||
|
const backBtn = document.getElementById('cefr-back-btn');
|
||||||
|
const nextBtn = document.getElementById('cefr-next-btn');
|
||||||
|
const sumRepeatBtn = document.getElementById('cefr-sum-repeat-btn');
|
||||||
|
const sumLevelsBtn = document.getElementById('cefr-sum-levels-btn');
|
||||||
|
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.addEventListener('click', () => {
|
||||||
|
showCefrScreen('overview');
|
||||||
|
window.location.hash = '#cefr';
|
||||||
|
loadCefrOverview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener('click', () => {
|
||||||
|
advanceQuestion();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumRepeatBtn) {
|
||||||
|
sumRepeatBtn.addEventListener('click', () => {
|
||||||
|
startCefrQuiz(currentLevel, currentMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumLevelsBtn) {
|
||||||
|
sumLevelsBtn.addEventListener('click', () => {
|
||||||
|
showCefrScreen('overview');
|
||||||
|
window.location.hash = '#cefr';
|
||||||
|
loadCefrOverview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumSrsBtn) {
|
||||||
|
sumSrsBtn.addEventListener('click', () => {
|
||||||
|
const levelTag = `level-${currentLevel.toLowerCase()}`;
|
||||||
|
window.location.hash = `#srs/${encodeURIComponent(levelTag)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard navigation (1-4 for options, Space/Enter for next)
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
const cefrView = document.getElementById('cefr-view');
|
||||||
|
const quizScreen = document.getElementById('cefr-quiz-screen');
|
||||||
|
if (!cefrView || cefrView.classList.contains('hidden')) return;
|
||||||
|
if (!quizScreen || quizScreen.classList.contains('hidden')) return;
|
||||||
|
|
||||||
|
// Ignore if user is inside an input/textarea
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
|
||||||
|
|
||||||
|
if (!isAnswered) {
|
||||||
|
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = parseInt(e.key, 10) - 1;
|
||||||
|
const btns = document.querySelectorAll('.cefr-opt-btn');
|
||||||
|
if (btns[idx]) btns[idx].click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
advanceQuestion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Screen Switcher inside #cefr-view
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function showCefrScreen(screenName) {
|
||||||
|
const overview = document.getElementById('cefr-overview-screen');
|
||||||
|
const quiz = document.getElementById('cefr-quiz-screen');
|
||||||
|
const summary = document.getElementById('cefr-summary-screen');
|
||||||
|
|
||||||
|
if (overview) overview.classList.toggle('hidden', screenName !== 'overview');
|
||||||
|
if (quiz) quiz.classList.toggle('hidden', screenName !== 'quiz');
|
||||||
|
if (summary) summary.classList.toggle('hidden', screenName !== 'summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Load & Render Overview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
export async function loadCefrOverview() {
|
||||||
|
showCefrScreen('overview');
|
||||||
|
const grid = document.getElementById('cefr-levels-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
grid.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка уровней...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.cefr.getLevels();
|
||||||
|
const levels = data.levels || [];
|
||||||
|
renderLevelsGrid(levels);
|
||||||
|
} catch (err) {
|
||||||
|
grid.innerHTML = `<div class="empty-state"><p>Ошибка загрузки: ${escapeHtml(err.message)}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLevelsGrid(levels) {
|
||||||
|
const grid = document.getElementById('cefr-levels-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (!levels.length) {
|
||||||
|
grid.innerHTML = '<div class="empty-state"><p>Нет доступных уровней</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = levels.map(lvl => {
|
||||||
|
const pct = lvl.progress_percent || 0;
|
||||||
|
const hasMistakes = lvl.in_learning_count > 0;
|
||||||
|
const colorClass = `level-badge-${lvl.level.toLowerCase()}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="cefr-level-card" data-level="${lvl.level}">
|
||||||
|
<div class="level-card-header">
|
||||||
|
<div class="level-pill ${colorClass}">
|
||||||
|
<span class="level-code">${escapeHtml(lvl.level)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="level-header-text">
|
||||||
|
<h3 class="level-title">${escapeHtml(lvl.title)}</h3>
|
||||||
|
<span class="level-meta-sub">${lvl.total_questions} эталонных слов</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-card-progress">
|
||||||
|
<div class="progress-bar-wrap">
|
||||||
|
<div class="progress-bar-fill ${colorClass}" style="width: ${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-labels-row">
|
||||||
|
<span class="pct-text">${pct}% освоено</span>
|
||||||
|
<span class="counts-text">${lvl.mastered_count} / ${lvl.total_questions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-card-metrics">
|
||||||
|
<div class="mini-metric">
|
||||||
|
<span class="m-val text-success">✓ ${lvl.mastered_count}</span>
|
||||||
|
<span class="m-lbl">Освоено</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-metric">
|
||||||
|
<span class="m-val text-warning">⚠️ ${lvl.in_learning_count}</span>
|
||||||
|
<span class="m-lbl">Ошибок</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-metric">
|
||||||
|
<span class="m-val text-primary">🎓 ${lvl.srs_cards_count}</span>
|
||||||
|
<span class="m-lbl">В словаре SRS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-card-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-block cefr-start-btn" data-level="${lvl.level}" data-mode="all">
|
||||||
|
🎯 Начать тест (10)
|
||||||
|
</button>
|
||||||
|
${hasMistakes ? `
|
||||||
|
<button type="button" class="btn btn-outline btn-block cefr-mistakes-btn" data-level="${lvl.level}" data-mode="mistakes">
|
||||||
|
⚠️ Работа над ошибками (${lvl.in_learning_count})
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button type="button" class="btn btn-xs btn-ghost cefr-reset-btn" data-level="${lvl.level}" title="Сбросить прогресс уровня">
|
||||||
|
🔄 Сброс
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Attach button listeners
|
||||||
|
grid.querySelectorAll('.cefr-start-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const lvl = btn.dataset.level;
|
||||||
|
const mode = btn.dataset.mode || 'all';
|
||||||
|
window.location.hash = `#cefr/${encodeURIComponent(lvl)}`;
|
||||||
|
startCefrQuiz(lvl, mode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.querySelectorAll('.cefr-mistakes-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const lvl = btn.dataset.level;
|
||||||
|
window.location.hash = `#cefr/${encodeURIComponent(lvl)}/mistakes`;
|
||||||
|
startCefrQuiz(lvl, 'mistakes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.querySelectorAll('.cefr-reset-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const lvl = btn.dataset.level;
|
||||||
|
if (confirm(`Вы действительно хотите сбросить прогресс прохождения уровня ${lvl}?`)) {
|
||||||
|
try {
|
||||||
|
await api.cefr.resetLevel(lvl);
|
||||||
|
showToast(`Прогресс уровня ${lvl} сброшен`, 'info');
|
||||||
|
loadCefrOverview();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Ошибка сброса прогресса', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Start Quiz Session
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
export async function startCefrQuiz(level = 'A1', mode = 'all') {
|
||||||
|
currentLevel = level.toUpperCase();
|
||||||
|
currentMode = mode;
|
||||||
|
currentQuestionIdx = 0;
|
||||||
|
isAnswered = false;
|
||||||
|
|
||||||
|
sessionStats.total = 0;
|
||||||
|
sessionStats.correct = 0;
|
||||||
|
sessionStats.mistakes = 0;
|
||||||
|
sessionStats.ingested = 0;
|
||||||
|
|
||||||
|
showCefrScreen('quiz');
|
||||||
|
|
||||||
|
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||||
|
if (cardContainer) {
|
||||||
|
cardContainer.innerHTML = '<div class="cefr-loading-spinner"><span class="spinner-sm"></span> Загрузка теста...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await api.cefr.getQuiz(currentLevel, 10, currentMode);
|
||||||
|
currentQuiz = resp.quiz || [];
|
||||||
|
sessionStats.total = currentQuiz.length;
|
||||||
|
|
||||||
|
if (!currentQuiz.length) {
|
||||||
|
if (currentMode === 'mistakes') {
|
||||||
|
showToast(`На уровне ${currentLevel} нет неисправленных ошибок! 🏆`, 'success');
|
||||||
|
showCefrScreen('overview');
|
||||||
|
loadCefrOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Нет доступных вопросов для этого уровня', 'warning');
|
||||||
|
showCefrScreen('overview');
|
||||||
|
loadCefrOverview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCurrentQuestion();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Ошибка загрузки вопросов: ${err.message}`, 'error');
|
||||||
|
showCefrScreen('overview');
|
||||||
|
loadCefrOverview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Render Active Question
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function renderCurrentQuestion() {
|
||||||
|
if (currentQuestionIdx >= currentQuiz.length) {
|
||||||
|
renderQuizSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnswered = false;
|
||||||
|
const q = currentQuiz[currentQuestionIdx];
|
||||||
|
|
||||||
|
// Header info
|
||||||
|
const levelBadge = document.getElementById('cefr-quiz-level-badge');
|
||||||
|
const counterBadge = document.getElementById('cefr-quiz-counter');
|
||||||
|
const progressBar = document.getElementById('cefr-quiz-progress-bar');
|
||||||
|
const nextBar = document.getElementById('cefr-next-bar');
|
||||||
|
|
||||||
|
if (levelBadge) {
|
||||||
|
levelBadge.textContent = `Уровень ${q.level}`;
|
||||||
|
levelBadge.className = `level-pill level-badge-${q.level.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (counterBadge) {
|
||||||
|
counterBadge.textContent = `${currentQuestionIdx + 1} / ${currentQuiz.length}`;
|
||||||
|
}
|
||||||
|
if (progressBar) {
|
||||||
|
const pct = ((currentQuestionIdx) / currentQuiz.length) * 100;
|
||||||
|
progressBar.style.width = `${pct}%`;
|
||||||
|
}
|
||||||
|
if (nextBar) nextBar.classList.add('hidden');
|
||||||
|
|
||||||
|
const cardContainer = document.getElementById('cefr-cloze-card');
|
||||||
|
if (!cardContainer) return;
|
||||||
|
|
||||||
|
// Stylize the sentence blank: replace ___ with a visible blank node
|
||||||
|
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
|
||||||
|
|
||||||
|
cardContainer.innerHTML = `
|
||||||
|
<div class="cefr-cloze-header">
|
||||||
|
<span class="cefr-mode-tag">🧩 Cloze Tap (1-тап выбор)</span>
|
||||||
|
${q.pos ? `<span class="badge-pos">${escapeHtml(q.pos)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cefr-sentence-wrap">
|
||||||
|
<p class="cefr-sentence-text">${formattedSentence}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cefr-clue-box">
|
||||||
|
<span class="clue-icon">💡</span>
|
||||||
|
<span class="clue-label">Значение:</span>
|
||||||
|
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cefr-options-grid" id="cefr-options-grid">
|
||||||
|
${q.options.map((opt, idx) => `
|
||||||
|
<button type="button" class="cefr-opt-btn" data-option="${escapeHtml(opt)}">
|
||||||
|
<span class="opt-num">[${idx + 1}]</span>
|
||||||
|
<span class="opt-text">${escapeHtml(opt)}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Attach click listeners to option buttons
|
||||||
|
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const opt = btn.dataset.option;
|
||||||
|
handleOptionTap(q.id, opt, btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Handle Option Selection (1-tap Cloze Tap)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
||||||
|
if (isAnswered) return;
|
||||||
|
isAnswered = true;
|
||||||
|
|
||||||
|
// Disable all options
|
||||||
|
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||||||
|
b.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedbackBox = document.getElementById('cefr-feedback-box');
|
||||||
|
const nextBar = document.getElementById('cefr-next-bar');
|
||||||
|
const blankTarget = document.getElementById('cefr-blank-target');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.cefr.submitAnswer(questionId, selectedOption);
|
||||||
|
|
||||||
|
if (res.correct) {
|
||||||
|
sessionStats.correct++;
|
||||||
|
clickedBtn.classList.add('opt-correct');
|
||||||
|
if (blankTarget) {
|
||||||
|
blankTarget.textContent = res.correct_option;
|
||||||
|
blankTarget.classList.add('blank-correct');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedbackBox) {
|
||||||
|
feedbackBox.className = 'cefr-feedback-box feedback-correct';
|
||||||
|
feedbackBox.innerHTML = `
|
||||||
|
<div class="fb-title">✓ Верно! Отличный выбор.</div>
|
||||||
|
<div class="fb-meta">
|
||||||
|
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
|
||||||
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
|
`;
|
||||||
|
feedbackBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sessionStats.mistakes++;
|
||||||
|
if (res.card_created || res.card_updated) {
|
||||||
|
sessionStats.ingested++;
|
||||||
|
}
|
||||||
|
|
||||||
|
clickedBtn.classList.add('opt-wrong');
|
||||||
|
|
||||||
|
// Highlight the true correct option
|
||||||
|
document.querySelectorAll('.cefr-opt-btn').forEach(b => {
|
||||||
|
if (b.dataset.option.toLowerCase() === res.correct_option.toLowerCase()) {
|
||||||
|
b.classList.add('opt-correct-revealed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blankTarget) {
|
||||||
|
blankTarget.textContent = res.correct_option;
|
||||||
|
blankTarget.classList.add('blank-wrong-revealed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feedbackBox) {
|
||||||
|
feedbackBox.className = 'cefr-feedback-box feedback-wrong';
|
||||||
|
feedbackBox.innerHTML = `
|
||||||
|
<div class="fb-title">✗ Неверно. Правильное слово: <strong>${escapeHtml(res.correct_option)}</strong></div>
|
||||||
|
<div class="fb-meta">
|
||||||
|
<span class="fb-trans">${escapeHtml(res.translation || '')}</span>
|
||||||
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
|
<div class="cefr-gap-ingested-badge">
|
||||||
|
<span class="badge-icon">📥</span>
|
||||||
|
<div class="badge-text">
|
||||||
|
<strong>Авто-захват в словарь:</strong> слово добавлено со статусом «В процессе» для закрепления в утреннем SRS!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
feedbackBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBar) nextBar.classList.remove('hidden');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Ошибка проверки ответа: ${err.message}`, 'error');
|
||||||
|
if (nextBar) nextBar.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Advance to Next Question
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function advanceQuestion() {
|
||||||
|
currentQuestionIdx++;
|
||||||
|
renderCurrentQuestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Render Quiz Summary
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function renderQuizSummary() {
|
||||||
|
showCefrScreen('summary');
|
||||||
|
|
||||||
|
const total = sessionStats.total || currentQuiz.length || 1;
|
||||||
|
const correct = sessionStats.correct;
|
||||||
|
const mistakes = sessionStats.mistakes;
|
||||||
|
const ingested = sessionStats.ingested;
|
||||||
|
const pct = Math.round((correct / total) * 100);
|
||||||
|
|
||||||
|
const sumScore = document.getElementById('cefr-sum-score');
|
||||||
|
const sumLead = document.getElementById('cefr-sum-lead');
|
||||||
|
const sumCorrect = document.getElementById('cefr-sum-correct');
|
||||||
|
const sumMistakes = document.getElementById('cefr-sum-mistakes');
|
||||||
|
const sumIngested = document.getElementById('cefr-sum-ingested');
|
||||||
|
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
|
||||||
|
|
||||||
|
if (sumScore) sumScore.textContent = `${pct}%`;
|
||||||
|
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
|
||||||
|
if (sumMistakes) sumMistakes.textContent = mistakes;
|
||||||
|
if (sumIngested) sumIngested.textContent = ingested;
|
||||||
|
|
||||||
|
if (sumLead) {
|
||||||
|
if (pct >= 90) {
|
||||||
|
sumLead.textContent = `Великолепно! Уровень ${currentLevel} освоен на высочайшем уровне! 🌟`;
|
||||||
|
} else if (pct >= 70) {
|
||||||
|
sumLead.textContent = `Хороший результат! Большинство ключевых слов уровня ${currentLevel} вам знакомы. 👍`;
|
||||||
|
} else {
|
||||||
|
sumLead.textContent = `Тренировка завершена. Все ошибки бережно сохранены в ваш SRS для закрепления. 💪`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sumSrsBtn) {
|
||||||
|
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
// Service Worker for Notes English PWA (Stage 2)
|
// Service Worker for Notes English PWA (Stage 2)
|
||||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||||
|
|
||||||
const CACHE_NAME = 'notes-v5.0';
|
const CACHE_NAME = 'notes-v5.1';
|
||||||
const OFFLINE_URL = '/';
|
const OFFLINE_URL = '/';
|
||||||
|
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
@@ -13,6 +13,7 @@ const PRECACHE_URLS = [
|
|||||||
'/static/js/modules/dictionary.js',
|
'/static/js/modules/dictionary.js',
|
||||||
'/static/js/modules/srs.js',
|
'/static/js/modules/srs.js',
|
||||||
'/static/js/modules/reader.js',
|
'/static/js/modules/reader.js',
|
||||||
|
'/static/js/modules/cefr.js',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1359,5 +1359,92 @@ def test_phrasal_verb_detector_unit_and_integration():
|
|||||||
assert after_data["phrasal_verb"]["library_card"] is not None
|
assert after_data["phrasal_verb"]["library_card"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cefr_testing_and_gap_ingestion():
|
||||||
|
# 1. Unauthenticated check
|
||||||
|
client.cookies.clear()
|
||||||
|
unauth = client.get("/api/cefr/levels")
|
||||||
|
assert unauth.status_code == 401
|
||||||
|
|
||||||
|
# 2. Authenticated user login
|
||||||
|
login_res = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_res.status_code == 200
|
||||||
|
|
||||||
|
# 3. Check level summaries
|
||||||
|
levels_res = client.get("/api/cefr/levels")
|
||||||
|
assert levels_res.status_code == 200
|
||||||
|
levels = levels_res.json()["levels"]
|
||||||
|
assert len(levels) == 4
|
||||||
|
level_map = {l["level"]: l for l in levels}
|
||||||
|
assert "A1" in level_map and "A2" in level_map and "B1" in level_map and "B2" in level_map
|
||||||
|
assert level_map["A1"]["total_questions"] == 40
|
||||||
|
assert level_map["A2"]["total_questions"] == 40
|
||||||
|
assert level_map["B1"]["total_questions"] == 40
|
||||||
|
assert level_map["B2"]["total_questions"] == 40
|
||||||
|
|
||||||
|
# 4. Fetch quiz batch
|
||||||
|
quiz_res = client.get("/api/cefr/quiz?level=A2&count=5")
|
||||||
|
assert quiz_res.status_code == 200
|
||||||
|
quiz_data = quiz_res.json()
|
||||||
|
assert quiz_data["level"] == "A2"
|
||||||
|
assert len(quiz_data["quiz"]) == 5
|
||||||
|
q1 = quiz_data["quiz"][0]
|
||||||
|
assert "id" in q1
|
||||||
|
assert "sentence" in q1
|
||||||
|
assert "clue" in q1
|
||||||
|
assert len(q1["options"]) == 4
|
||||||
|
|
||||||
|
# 5. Answer INCORRECTLY -> Automated Gap Ingestion into SRS!
|
||||||
|
wrong_opt = "__completely_wrong_option__"
|
||||||
|
ans_wrong = client.post("/api/cefr/answer", json={
|
||||||
|
"question_id": q1["id"],
|
||||||
|
"selected_option": wrong_opt
|
||||||
|
})
|
||||||
|
assert ans_wrong.status_code == 200
|
||||||
|
w_data = ans_wrong.json()
|
||||||
|
assert w_data["correct"] is False
|
||||||
|
assert w_data["card_created"] is True
|
||||||
|
assert w_data["card_id"] is not None
|
||||||
|
assert w_data["correct_option"] != ""
|
||||||
|
assert w_data["explanation"] != ""
|
||||||
|
|
||||||
|
# Verify the card was actually created in vocabulary_cards with state 'learning' and level tag
|
||||||
|
card_res = client.get(f"/api/cards/{w_data['card_id']}")
|
||||||
|
assert card_res.status_code == 200
|
||||||
|
ingested_card = card_res.json()
|
||||||
|
assert ingested_card["srs_state"] == "learning"
|
||||||
|
assert "level-a2" in ingested_card["tags"]
|
||||||
|
assert "cefr-test" in ingested_card["tags"]
|
||||||
|
|
||||||
|
# 6. Check that mode='mistakes' returns this question
|
||||||
|
mistakes_res = client.get("/api/cefr/quiz?level=A2&mode=mistakes")
|
||||||
|
assert mistakes_res.status_code == 200
|
||||||
|
m_data = mistakes_res.json()
|
||||||
|
assert any(q["id"] == q1["id"] for q in m_data["quiz"])
|
||||||
|
|
||||||
|
# 7. Now answer CORRECTLY
|
||||||
|
ans_correct = client.post("/api/cefr/answer", json={
|
||||||
|
"question_id": q1["id"],
|
||||||
|
"selected_option": w_data["correct_option"]
|
||||||
|
})
|
||||||
|
assert ans_correct.status_code == 200
|
||||||
|
c_data = ans_correct.json()
|
||||||
|
assert c_data["correct"] is True
|
||||||
|
assert c_data["card_created"] is False
|
||||||
|
|
||||||
|
# 8. Check level summary reflects mastered count
|
||||||
|
levels_res2 = client.get("/api/cefr/levels")
|
||||||
|
l2_map = {l["level"]: l for l in levels_res2.json()["levels"]}
|
||||||
|
assert l2_map["A2"]["mastered_count"] >= 1
|
||||||
|
assert l2_map["A2"]["srs_cards_count"] >= 1
|
||||||
|
|
||||||
|
# 9. Test level reset
|
||||||
|
reset_res = client.post("/api/cefr/reset", json={"level": "A2"})
|
||||||
|
assert reset_res.status_code == 200
|
||||||
|
levels_res3 = client.get("/api/cefr/levels")
|
||||||
|
l3_map = {l["level"]: l for l in levels_res3.json()["levels"]}
|
||||||
|
assert l3_map["A2"]["mastered_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user