/* ============================================================ KidsLab — 平台逻辑 levels / categories / search / i18n / themes / collapse ============================================================ */ (() => { 'use strict'; /* ---------- 常量 ---------- */ const LS = { lang: 'kidslab.lang', theme: 'kidslab.theme', accent: 'kidslab.accent', level: 'kidslab.level', grade: 'kidslab.grade', collapsed: 'kidslab.sidebar', }; const PROGRESS_PREFIX = 'kidslab.progress.'; const local = { get(key) { try { return localStorage.getItem(key); } catch { return null; } }, set(key, value) { try { localStorage.setItem(key, value); return true; } catch { return false; } }, remove(key) { try { localStorage.removeItem(key); return true; } catch { return false; } }, keys() { try { return Object.keys(localStorage); } catch { return []; } }, }; const session = { get(key) { try { return sessionStorage.getItem(key); } catch { return null; } }, set(key, value) { try { sessionStorage.setItem(key, value); return true; } catch { return false; } }, }; const LEVELS = ['primary', 'junior', 'senior']; const GRADES = [ { id: 'g1', level: 'primary' }, { id: 'g2', level: 'primary' }, { id: 'g3', level: 'primary' }, { id: 'g4', level: 'primary' }, { id: 'g5', level: 'primary' }, { id: 'g6', level: 'primary' }, { id: 'g7', level: 'junior' }, { id: 'g8', level: 'junior' }, { id: 'g9', level: 'junior' }, { id: 'g10', level: 'senior' }, { id: 'g11', level: 'senior' }, { id: 'g12', level: 'senior' }, ]; const GRADE_IDS = new Set(GRADES.map((g) => g.id)); const LEVEL_GRADE_IDS = Object.fromEntries(LEVELS.map((lv) => [lv, new Set(GRADES.filter((g) => g.level === lv).map((g) => g.id))])); const CN_GRADES = new Map([ ['一年级', 'g1'], ['二年级', 'g2'], ['三年级', 'g3'], ['四年级', 'g4'], ['五年级', 'g5'], ['六年级', 'g6'], ['七年级', 'g7'], ['八年级', 'g8'], ['九年级', 'g9'], ['十年级', 'g10'], ['十一年级', 'g11'], ['十二年级', 'g12'], ['高一', 'g10'], ['高二', 'g11'], ['高三', 'g12'], ]); const CATS = [ { id: 'all', icon: '✨', color: 'var(--accent)' }, { id: 'recent', icon: '👀', color: 'var(--c-recent)', special: true }, { id: 'newest', icon: '🆕', color: 'var(--c-newest)', special: true }, { id: 'math', icon: '🧮', color: 'var(--c-math)' }, { id: 'physics', icon: '🧲', color: 'var(--c-physics)' }, { id: 'chemistry', icon: '⚗️', color: 'var(--c-chemistry)' }, { id: 'programming', icon: '💻', color: 'var(--c-programming)' }, { id: 'science', icon: '🔬', color: 'var(--c-science)' }, { id: 'logic', icon: '🧩', color: 'var(--c-logic)' }, ]; /* 小学只显示这些分类(含两个特殊快捷项) */ const PRIMARY_CATS = new Set(['all', 'recent', 'newest', 'math', 'programming', 'science', 'logic']); const CAT_IDS = new Set(CATS.map((c) => c.id)); const ACCENTS = [ { id: 'candy', sw: '#ff5d8f' }, { id: 'ocean', sw: '#2f9df4' }, { id: 'forest', sw: '#2fbf71' }, { id: 'tangerine', sw: '#ff8a00' }, ]; const CHIP_INK = { logic: '#4a3200' }; // 浅色底用深字 /* ---------- 文案 ---------- */ const I18N = { zh: { siteName: 'KidsLab', siteSub: 'K12 Playground', docTitle: 'KidsLab · 互动课件学习乐园', secLevel: '学段', secGrade: '年级', secCats: '分类', searchPh: '搜索课件:分数、单摆、编程……', heroTitle: { primary: '嗨,小小探索家!', junior: '来实验室,拧旋钮看变化', senior: '调参数,让规律自己长出来', }, tipTheme: '切换深浅主题', tipStarmap: '知识星图 · 换个方式探索', tipLang: 'Switch to English', tipCollapse: '收起 / 展开侧栏', emptyTitle: '没有找到匹配的课件', emptyDesc: '换个关键词,或者去别的分类逛逛吧~', emptyReset: '清除筛选', footMade: '用 ❤ 为好奇心而建', langBtn: 'EN', count: (n) => `共 ${n} 个课件`, total: (n) => `· ${n} 个课件`, pinned: '精选', played: '玩过', completed: '已完成', offline: '可离线', clearProgress: '清除本机进度', clearProgressConfirm: '清除全部本机课件进度?语言和主题设置会保留。', gradeAll: '全部年级', loadErr: '课件清单加载失败,请先运行 npm run build 并通过 npm run preview 访问。', levels: { primary: '小学', junior: '初中', senior: '高中' }, lvShort: { primary: '小', junior: '初', senior: '高' }, grades: { g1: '一年级', g2: '二年级', g3: '三年级', g4: '四年级', g5: '五年级', g6: '六年级', g7: '七年级', g8: '八年级', g9: '九年级', g10: '高一', g11: '高二', g12: '高三', }, cats: { all: '全部', recent: '最近探索的', newest: '最新创建的', math: '数学', physics: '物理', chemistry: '化学', programming: '编程', science: '科学', logic: '逻辑', featured: '精选', }, }, en: { siteName: 'KidsLab', siteSub: 'K12 Playground', docTitle: 'KidsLab · Interactive Courseware Playground', secLevel: 'Stage', secGrade: 'Grade', secCats: 'Subjects', searchPh: 'Search: fractions, pendulum, coding…', heroTitle: { primary: 'Hey, little explorer!', junior: 'Open a lab. Twist a knob.', senior: 'Tune the dials. Watch the law appear.', }, tipTheme: 'Toggle light / dark theme', tipStarmap: 'Knowledge star map · explore differently', tipLang: '切换到中文', tipCollapse: 'Collapse / expand sidebar', emptyTitle: 'No courseware found', emptyDesc: 'Try another keyword, or explore other subjects~', emptyReset: 'Clear filters', footMade: 'Built with ❤ for curiosity', langBtn: '中', count: (n) => `${n} courseware${n === 1 ? '' : 's'}`, total: (n) => `· ${n} items`, pinned: 'Featured', played: 'Played', completed: 'Completed', offline: 'Offline OK', clearProgress: 'Clear local progress', clearProgressConfirm: 'Clear all local course progress? Language and theme settings will stay unchanged.', gradeAll: 'All grades', loadErr: 'Failed to load the manifest. Run `npm run build` then `npm run preview`.', levels: { primary: 'Primary', junior: 'Middle', senior: 'High' }, lvShort: { primary: 'P', junior: 'M', senior: 'H' }, grades: { g1: 'Grade 1', g2: 'Grade 2', g3: 'Grade 3', g4: 'Grade 4', g5: 'Grade 5', g6: 'Grade 6', g7: 'Grade 7', g8: 'Grade 8', g9: 'Grade 9', g10: 'Grade 10', g11: 'Grade 11', g12: 'Grade 12', }, cats: { all: 'All', recent: 'Recently explored', newest: 'Newly created', math: 'Math', physics: 'Physics', chemistry: 'Chemistry', programming: 'Coding', science: 'Science', logic: 'Logic', featured: 'Featured', }, }, }; /* ---------- 状态 ---------- */ const params = new URLSearchParams(location.search); const state = { lang: params.get('lang') || local.get(LS.lang) || (navigator.language?.startsWith('zh') ? 'zh' : 'en'), theme: local.get(LS.theme) || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'), accent: local.get(LS.accent) || 'candy', level: params.get('level') || local.get(LS.level) || 'primary', grade: params.get('grade') || local.get(LS.grade) || 'all', cat: params.get('cat') || session.get('kidslab.cat') || 'all', q: params.get('q') ?? session.get('kidslab.q') ?? '', courses: [], }; if (!LEVELS.includes(state.level)) state.level = 'primary'; if (state.grade !== 'all' && !GRADE_IDS.has(state.grade)) state.grade = 'all'; if (state.grade !== 'all' && !LEVEL_GRADE_IDS[state.level]?.has(state.grade)) state.grade = 'all'; if (!['zh', 'en'].includes(state.lang)) state.lang = 'zh'; if (!CAT_IDS.has(state.cat)) state.cat = 'all'; if (!ACCENTS.some((a) => a.id === state.accent)) state.accent = 'candy'; const t = () => I18N[state.lang]; const $ = (s) => document.querySelector(s); const el = { app: $('#app'), sidebar: $('#sidebar'), backdrop: $('#backdrop'), levelSeg: $('#levelSeg'), catList: $('#catList'), accentRow: $('#accentRow'), gradeSec: $('#gradeSec'), gradeList: $('#gradeList'), grid: $('#grid'), empty: $('#emptyBox'), search: $('#searchInput'), heroTitle: $('#heroTitle'), crumb: $('#crumb'), countText: $('#countText'), courseTotal: $('#courseTotal'), clearProgress: $('#clearProgressBtn'), }; /* ---------- 主题 / 强调色 / 语言 ---------- */ function applyChrome() { const root = document.documentElement; root.dataset.theme = state.theme; root.dataset.accent = state.accent; root.lang = state.lang === 'zh' ? 'zh-CN' : 'en'; document.title = t().docTitle; document.querySelectorAll('[data-i18n]').forEach((n) => { n.textContent = t()[n.dataset.i18n] ?? n.textContent; }); document.querySelectorAll('[data-i18n-title]').forEach((n) => { n.title = t()[n.dataset.i18nTitle] ?? n.title; }); document.querySelectorAll('[data-i18n-placeholder]').forEach((n) => { n.placeholder = t()[n.dataset.i18nPlaceholder] ?? ''; }); $('#langBtn').textContent = t().langBtn; const hero = t().heroTitle[state.level] || t().heroTitle.primary; el.heroTitle.innerHTML = `${hero}👋`; } function persist() { local.set(LS.lang, state.lang); local.set(LS.theme, state.theme); local.set(LS.accent, state.accent); local.set(LS.level, state.level); local.set(LS.grade, state.grade); session.set('kidslab.cat', state.cat); session.set('kidslab.q', state.q); const p = new URLSearchParams(); if (state.level !== 'primary') p.set('level', state.level); if (state.grade !== 'all') p.set('grade', state.grade); if (state.cat !== 'all') p.set('cat', state.cat); if (state.q) p.set('q', state.q); history.replaceState(null, '', p.size ? `?${p}` : location.pathname); } /* ---------- 侧栏渲染 ---------- */ function renderLevels() { el.levelSeg.innerHTML = ''; for (const lv of LEVELS) { const b = document.createElement('button'); b.type = 'button'; b.role = 'radio'; b.dataset.level = lv; b.textContent = t().levels[lv]; b.setAttribute('aria-checked', String(state.level === lv)); b.addEventListener('click', () => { state.level = lv; if (state.grade !== 'all' && !LEVEL_GRADE_IDS[lv]?.has(state.grade)) state.grade = 'all'; if (lv === 'primary' && !PRIMARY_CATS.has(state.cat)) state.cat = 'all'; update(); }); el.levelSeg.appendChild(b); } } const inLevel = (c) => c.levels?.includes(state.level); const gradeInLevel = (grade, level = state.level) => grade === 'all' || LEVEL_GRADE_IDS[level]?.has(grade); function normalizeGrade(value) { const raw = String(value ?? '').trim(); const lower = raw.toLowerCase(); if (GRADE_IDS.has(lower)) return lower; const compact = lower.replace(/\s+/g, ''); const en = compact.match(/^(?:grade|g)([1-9]|1[0-2])$/); if (en) return `g${en[1]}`; const ordinal = lower.match(/\b([1-9]|1[0-2])(?:st|nd|rd|th)?\s*grade\b/); if (ordinal) return `g${ordinal[1]}`; return CN_GRADES.get(raw) || null; } function courseGrades(c) { if (c._gradeSet) return c._gradeSet; const grades = new Set(); const scan = (value) => { const direct = normalizeGrade(value); if (direct) grades.add(direct); const text = String(value ?? '').trim(); for (const [label, id] of CN_GRADES) { if (text.includes(label)) grades.add(id); } const lower = text.toLowerCase(); const match = lower.match(/\b(?:grade|g)\s*[-:]?\s*([1-9]|1[0-2])\b/); if (match) grades.add(`g${match[1]}`); }; (Array.isArray(c.grades) ? c.grades : []).forEach(scan); (Array.isArray(c.tags) ? c.tags : []).forEach(scan); c._gradeSet = grades; return grades; } const inGrade = (c) => state.grade === 'all' || courseGrades(c).has(state.grade); function availableGrades() { return GRADES.filter((g) => g.level === state.level && state.courses.some((c) => !c.pinned && inLevel(c) && courseGrades(c).has(g.id))); } function renderGrades() { const grades = availableGrades(); if (!grades.length) { state.grade = 'all'; el.gradeList.innerHTML = ''; el.gradeSec.hidden = true; return; } if (!gradeInLevel(state.grade) || (state.grade !== 'all' && !grades.some((g) => g.id === state.grade))) state.grade = 'all'; el.gradeSec.hidden = false; el.gradeList.innerHTML = ''; const inCurrentScope = (c) => !c.pinned && inLevel(c) && matchesCat(c, state.cat); const options = [ { id: 'all', label: t().gradeAll, count: state.courses.filter(inCurrentScope).length }, ...grades.map((g) => ({ id: g.id, label: t().grades[g.id], count: state.courses.filter((c) => inCurrentScope(c) && courseGrades(c).has(g.id)).length, })), ]; for (const opt of options) { const b = document.createElement('button'); b.type = 'button'; b.role = 'radio'; b.setAttribute('aria-checked', String(state.grade === opt.id)); b.innerHTML = ''; b.querySelector('.grade__name').textContent = opt.label; b.querySelector('.grade__count').textContent = opt.count; b.addEventListener('click', () => { state.grade = opt.id; closeDrawer(); update(); }); el.gradeList.appendChild(b); } } function renderCats() { el.catList.innerHTML = ''; const visible = state.level === 'primary' ? CATS.filter((c) => PRIMARY_CATS.has(c.id)) : CATS; for (const cat of visible) { const n = state.courses.filter((c) => !c.pinned && inLevel(c) && inGrade(c) && matchesCat(c, cat.id)).length; const li = document.createElement('li'); if (cat.special) li.className = 'cat-list__special'; const b = document.createElement('button'); b.type = 'button'; b.dataset.cat = cat.id; b.style.setProperty('--cat-c', cat.color); b.classList.toggle('is-active', state.cat === cat.id); b.innerHTML = `${cat.icon}${n}`; b.querySelector('.cat__name').textContent = t().cats[cat.id]; b.addEventListener('click', () => { state.cat = cat.id; closeDrawer(); update(); }); li.appendChild(b); el.catList.appendChild(li); } } function renderAccents() { el.accentRow.innerHTML = ''; for (const a of ACCENTS) { const b = document.createElement('button'); b.type = 'button'; b.style.setProperty('--sw', a.sw); b.title = a.id; b.classList.toggle('is-active', state.accent === a.id); b.addEventListener('click', () => { state.accent = a.id; update(); }); el.accentRow.appendChild(b); } } /* ---------- 过滤 + 卡片 ---------- */ function matchQuery(c, q) { if (!q) return true; const hay = [ c.title?.zh, c.title?.en, c.description?.zh, c.description?.en, ...(c.tags || []), ...(c.keywords || []), t().cats[c.category] || c.category, ...Array.from(courseGrades(c)).flatMap((g) => [I18N.zh.grades[g], I18N.en.grades[g], g]), ].join(' ').toLowerCase(); return q.toLowerCase().trim().split(/\s+/).every((w) => hay.includes(w)); } function matchesCat(c, catId = state.cat) { if (catId === 'all') return true; if (catId === 'recent') return !!courseProgressMeta(c.id); if (catId === 'newest') return c.badge === 'new'; return c.category === catId; } function filtered() { const q = state.q; const list = state.courses.filter((c) => { if (!matchQuery(c, q)) return false; if (c.pinned) return true; /* 置顶课件跳过侧栏筛选 */ if (!inLevel(c)) return false; if (!inGrade(c)) return false; return matchesCat(c, state.cat); }); if (state.cat === 'recent') { return sortPinnedFirst(list, (a, b) => { const diff = courseProgressMeta(b.id).updatedAt - courseProgressMeta(a.id).updatedAt; return diff || a.id.localeCompare(b.id); }); } if (state.cat === 'newest') { return sortPinnedFirst(list, (a, b) => { const diff = (b.order ?? 0) - (a.order ?? 0); return diff || a.id.localeCompare(b.id); }); } return list; } function sortPinnedFirst(list, compare) { const pinned = []; const rest = []; for (const c of list) (c.pinned ? pinned : rest).push(c); rest.sort(compare); return pinned.concat(rest); } function catColor(id) { return (CATS.find((c) => c.id === id) || {}).color || 'var(--c-featured)'; } function courseProgressMeta(id) { try { const value = JSON.parse(local.get(`${PROGRESS_PREFIX}${id}`) || 'null'); if (!value || !['played', 'completed'].includes(value.status)) return null; const updatedAt = Number(value.updatedAt); return { status: value.status, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0, }; } catch { return null; } } function courseProgress(id) { return courseProgressMeta(id)?.status || ''; } function card(c, i) { const a = document.createElement('a'); a.className = 'card'; a.href = c.path; a.style.setProperty('--i', i); a.style.setProperty('--cat-c', c.pinned ? 'var(--c-featured)' : catColor(c.category)); const cover = document.createElement('div'); cover.className = 'card__cover'; cover.innerHTML = `${c.icon || '📘'}`; if (c.pinned) cover.insertAdjacentHTML('beforeend', `⭐ ${t().pinned}`); if (c.badge === 'new') cover.insertAdjacentHTML('beforeend', `✨`); const progress = courseProgress(c.id); if (progress) { a.dataset.progress = progress; const status = document.createElement('span'); status.className = `card__progress card__progress--${progress}`; status.textContent = t()[progress]; cover.appendChild(status); } if (offlineIds.has(c.id)) { const off = document.createElement('span'); off.className = 'card__offline'; off.textContent = `📥 ${t().offline}`; off.title = state.lang === 'zh' ? '玩过的课件已缓存,断网也能玩' : 'Cached on this device — playable offline'; cover.appendChild(off); } const body = document.createElement('div'); body.className = 'card__body'; const h = document.createElement('h3'); h.className = 'card__title'; h.textContent = c.title[state.lang]; const p = document.createElement('p'); p.className = 'card__desc'; p.textContent = c.description[state.lang]; body.append(h, p); const meta = document.createElement('div'); meta.className = 'card__meta'; const chip = document.createElement('span'); chip.className = 'chip chip--cat'; chip.textContent = t().cats[c.category] || c.category; if (CHIP_INK[c.category]) chip.style.color = CHIP_INK[c.category]; const lvs = document.createElement('span'); lvs.className = 'card__levels'; for (const lv of LEVELS) { if (c.levels.includes(lv)) lvs.insertAdjacentHTML('beforeend', `${t().lvShort[lv]}`); } meta.append(chip, lvs); a.append(cover, body, meta); return a; } function renderGrid() { const list = filtered(); el.grid.innerHTML = ''; list.forEach((c, i) => el.grid.appendChild(card(c, i))); el.empty.hidden = list.length > 0; el.countText.textContent = t().count(list.length); el.crumb.innerHTML = `${t().levels[state.level]}${state.grade !== 'all' ? ` · ${t().grades[state.grade]}` : ''} · ${t().cats[state.cat]}${state.q ? ` · “${esc(state.q)}”` : ''}`; el.courseTotal.textContent = t().total(state.courses.length); el.clearProgress.hidden = !local.keys().some((key) => key.startsWith(PROGRESS_PREFIX)); } const esc = (s) => s.replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); function update() { applyChrome(); renderLevels(); renderGrades(); renderCats(); renderAccents(); renderGrid(); persist(); } /* ---------- 侧栏收拢 / 移动抽屉 ---------- */ const isMobile = () => matchMedia('(max-width: 860px)').matches; function closeDrawer() { if (!isMobile()) return; el.app.classList.remove('menu-open'); el.backdrop.hidden = true; } $('#collapseBtn').addEventListener('click', () => { const on = el.app.classList.toggle('is-collapsed'); local.set(LS.collapsed, on ? '1' : ''); }); $('#menuBtn').addEventListener('click', () => { el.app.classList.add('menu-open'); el.backdrop.hidden = false; }); el.backdrop.addEventListener('click', closeDrawer); addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDrawer(); }); if (local.get(LS.collapsed) === '1') el.app.classList.add('is-collapsed'); /* ---------- 顶栏交互 ---------- */ $('#themeBtn').addEventListener('click', () => { state.theme = state.theme === 'dark' ? 'light' : 'dark'; update(); }); $('#langBtn').addEventListener('click', () => { state.lang = state.lang === 'zh' ? 'en' : 'zh'; update(); }); $('#resetBtn').addEventListener('click', () => { state.q = ''; state.grade = 'all'; state.cat = 'all'; el.search.value = ''; update(); el.search.focus(); }); el.clearProgress.addEventListener('click', () => { if (!confirm(t().clearProgressConfirm)) return; local.keys() .filter((key) => key.startsWith(PROGRESS_PREFIX)) .forEach((key) => local.remove(key)); renderGrid(); closeDrawer(); }); let debounce = 0; el.search.addEventListener('input', () => { clearTimeout(debounce); debounce = setTimeout(() => { state.q = el.search.value; update(); }, 120); }); addEventListener('keydown', (e) => { if (e.key === '/' && !/^(input|textarea)$/i.test(document.activeElement?.tagName || '')) { e.preventDefault(); el.search.focus(); } }); /* ---------- PWA:Service Worker + 「可离线」角标 ---------- */ const offlineIds = new Set(); /* 读运行时缓存(cache-on-visit),标记断网可玩的课件 */ async function refreshOfflineBadges() { if (!('caches' in window)) return; try { const cache = await caches.open('kidslab-courseware'); const keys = await cache.keys(); const next = new Set(); for (const req of keys) { const m = new URL(req.url).pathname.match(/\/courseware\/([^/]+)\/(?:index\.html)?$/); if (m) next.add(decodeURIComponent(m[1])); } const changed = next.size !== offlineIds.size || [...next].some((id) => !offlineIds.has(id)); offlineIds.clear(); next.forEach((id) => offlineIds.add(id)); if (changed && state.courses.length) renderGrid(); } catch { /* Cache API 不可用(如隐私模式)时静默降级 */ } } if ('serviceWorker' in navigator) { addEventListener('load', () => { /* 更新策略保守:不 skipWaiting,新版本等所有页面关闭后接管 */ navigator.serviceWorker.register('sw.js').then(refreshOfflineBadges).catch(() => {}); }); } /* ---------- 启动 ---------- */ el.search.value = state.q; applyChrome(); fetch('courseware/index.json', { cache: 'no-cache' }) .then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); }) .then((m) => { state.courses = m.courses || []; update(); return refreshOfflineBadges(); }) .catch(() => { el.grid.innerHTML = ''; el.empty.hidden = false; el.empty.querySelector('.empty__scope').textContent = '⚠️'; el.empty.querySelector('h3').removeAttribute('data-i18n'); el.empty.querySelector('h3').textContent = t().loadErr; el.empty.querySelector('p').textContent = ''; el.empty.querySelector('p').removeAttribute('data-i18n'); $('#resetBtn').hidden = true; }); addEventListener('pageshow', () => { if (state.courses.length) renderGrid(); refreshOfflineBadges(); }); })();