mcpField
S
static-spa-interactions

static-spa-interactions

github.com/kevintsai1202开发5264 次安装24 周前更新
代码仓库

关于 static-spa-interactions

static-spa-interactions 为原生 SPA 添加交互模式,包括进度保存、导航、主题、测验和素材预览。它也强调针对不同视口的行为处理,以及实现后的验证。

主要功能

  1. 01保存任务与测验状态
  2. 02响应式侧边栏导航
  3. 03ScrollSpy 高亮当前章节
  4. 04深色与浅色主题
  5. 05测验和素材预览模式

适用场景

  1. 01保存学员跨访问的学习进度
  2. 02为课程添加响应式导航
  3. 03引导学员复习答错的测验内容
  4. 04在页面内预览课程素材

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kevintsai1202/teaching-site-skills --skill static-spa-interactions

安装后在 Claude Code 中输入/static-spa-interactions确认已加载。

详细介绍

静态 SPA 交互

Schema 权威来源:Task 原语(以 id 作为 localStorage 键的规则)和 QuizItem 原语(所选选项的存储)来自 _shared/domain-primitives.md §8 和 §10。localStorage Schema 见 §8。

参考实现:d:/GitHub/ai-workshop/index.html:2900-3030 中的 renderSidebar / setupScrollSpy / applyTheme / setupFadeIn 模式。

此技能为原生 SPA 添加交互层:状态、导航、主题、响应式设计和无障碍性。以下每种模式都是可独立添加的自包含模块。

之所以记录这些模式,是因为它们都隐藏着微妙的 bug,在示例工作坊中花了大量时间才找到。请仔细阅读 “原因” 注释——它们不是可有可无的背景信息。

模式 1:localStorage 进度(任务复选框 + 测验答案)

const STORAGE_KEY = 'your-app-progress-v1';   // version suffix lets you breaking-change later

const store = {
  load() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { tasks: {}, quiz: {}, theme: 'light' };
    } catch { return { tasks: {}, quiz: {}, theme: 'light' }; }
  },
  save(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); },
  reset() { localStorage.removeItem(STORAGE_KEY); location.reload(); }
};

为什么键名要带版本后缀:之后发现 Schema 需要更改时,可以递增后缀(-v2)。旧键会继续保留,不会造成影响;新访客则会获得全新状态。重命名键会清除所有人的数据——只有在确有意图时才这样做。

为什么要在 JSON.parse 外使用 try/catch:损坏的条目(例如用户打开开发者工具并编辑了数据)会导致 init() 崩溃,并使整个页面无法运行。

模式 2:侧边栏——两种模式,不要合并

侧边栏在桌面端和移动端的默认状态相反。试图用一个 CSS 类来表达这两种状态,是最常见的 bug。

:root { --sidebar-w: 280px; }

/* Desktop: sidebar visible by default */
.sidebar { position: fixed; left: 0; top: 0; width: var(--sidebar-w); height: 100vh; }
.main { margin-left: var(--sidebar-w); transition: margin-left .2s ease; }
.app.sidebar-closed .sidebar { transform: translateX(-100%); }
.app.sidebar-closed .main { margin-left: 0; }

@media (max-width: 768px) {
  /* Mobile: sidebar hidden by default */
  .sidebar { transform: translateX(-100%); }
  .main { margin-left: 0; }
  .app.sidebar-open .sidebar { transform: translateX(0); }
  .sidebar-backdrop { /* overlay shown only when sidebar-open */ }
}
const mql = matchMedia('(max-width: 768px)');
function toggleSidebar() {
  if (mql.matches) document.querySelector('.app').classList.toggle('sidebar-open');
  else document.querySelector('.app').classList.toggle('sidebar-closed');
}
// On viewport crossing 768px, clear both classes so the new viewport's default takes over:
mql.addEventListener('change', () => {
  document.querySelector('.app').classList.remove('sidebar-open', 'sidebar-closed');
});

为什么要使用两个类:在桌面端,“关闭”表示移出视野;在移动端,“打开”表示移入视野。同一个类在不同视口下需要应用相反的 CSS 规则,难以维护。

为什么用 position: fixed 而不是 sticky:任何带有 overflow: hidden 的祖先元素都会破坏 sticky。fixed 不受影响。(额外提示:overflow-x: hidden 会隐式强制 overflow-y: auto,导致窗口无法滚动——参见模式 9。)

模式 3:ScrollSpy(自动高亮侧边栏中的当前章节)

function setupScrollSpy() {
  const sections = document.querySelectorAll('[data-section-id]');
  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const id = entry.target.dataset.sectionId;
        document.querySelectorAll('.sidebar-link').forEach(a => a.classList.toggle('active', a.dataset.target === id));
      }
    });
  }, { rootMargin: '-30% 0px -60% 0px', threshold: 0 });
  sections.forEach(s => observer.observe(s));
}

为什么使用 rootMargin: '-30% 0px -60% 0px':它会在视口上方三分之一区域创建一个高度为 10% 的“激活区域”。位于该区域内的章节会被视为“当前章节”。仅使用 threshold 会导致结果不断抖动。

模式 4:主题切换(深色 / 浅色)

function applyTheme(theme) {
  document.documentElement.dataset.theme = theme;
  const s = store.load(); s.theme = theme; store.save(s);
}
applyTheme(store.load().theme);  // on load

CSS 使用 :root 上的 [data-theme="dark"] 属性选择器。除非用户明确要求,否则不要提供 prefers-color-scheme 回退——切换开关才是唯一依据。

模式 5:内容缩放(不要应用于侧边栏!)

:root { --content-zoom: 1; }
.content { zoom: var(--content-zoom); }
/* Sidebar is OUTSIDE .content — never inherits zoom */
function applyZoom(value) {
  if (mql.matches) {
    // On mobile, kill the CSS variable so @media rules govern
    document.documentElement.style.removeProperty('--content-zoom');
  } else {
    document.documentElement.style.setProperty('--content-zoom', value);
  }
}

为什么绝不能使用 html { zoom: X }:CSS zoom 会在后代元素之间叠乘。如果 html 为 1.35×,而 .content 为 1.25×,实际内容就会放大到 1.6875×。缩放只能限制在一个层级。

为什么要在移动端取消缩放:手机不需要放大,而桌面端的 1.35 会让文字大得离谱。@media (max-width: 768px) :root { --content-zoom: 1 } 只有在没有内联样式覆盖时才会生效——因此必须使用 removeProperty(),不能只把值设回 1。

模式 6:手风琴 + 淡入效果

function setupFadeIn() {
  const observer = new IntersectionObserver(entries => {
    entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
  }, { threshold: 0.15 });
  document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
}
.fade-in { opacity: 0; transform: translateY(20px); transition: opacity .5s, transform .5s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }

注意事项:如果章节使用了 zoom,且 IntersectionObserver 对可见状态的判断出现混乱,那么 opacity: 0 的元素会永远保持不可见。启用 zoom 时,应使用 @keyframes,而不是使用 opacity: 0 → 1 过渡(示例工作坊曾遇到此问题,因此将 Day 的主视觉数字改为使用关键帧)。

模式 7:复制到剪贴板按钮

async function copyPrompt(text, btn) {
  try {
    await navigator.clipboard.writeText(text);
    showToast('已複製');
  } catch {
    // Fallback for non-secure context (e.g. file://)
    const ta = document.createElement('textarea');
    ta.value = text; document.body.appendChild(ta);
    ta.select(); document.execCommand('copy');
    ta.remove();
    showToast('已複製');
  }
}

回退方案很重要,因为在某些场景中,SPA 可能从 file:// 打开(例如通过离线 zip 分发),此时 navigator.clipboard 会受到限制。

模式 8:Toast(最简版)

function showToast(msg) {
  const t = document.createElement('div');
  t.className = 'toast'; t.textContent = msg;
  document.body.appendChild(t);
  setTimeout(() => t.classList.add('show'), 10);
  setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 1800);
}

模式 9:带有源章节回链的测验

每道测验题都有一个来自 course-content-authoring 的 sourceUnit 字段。提交后,答错题目的行中会显示“去复习 →”链接,点击后滚动到对应单元:

function gradeQuiz() {
  const state = store.load();
  const results = window.COURSE.quiz.map(q => ({
    q, given: state.quiz[q.id], correct: q.answer, isWrong: state.quiz[q.id] !== q.answer
  }));
  // Render with back-links for wrong ones; scroll behaviour:
  // document.querySelector(`[data-section-id="${q.sourceUnit}"]`).scrollIntoView({ behavior: 'smooth' });
}

硬编码 N 处的陷阱:题目总数会出现在 (1) 小节标题 (2) 引导段落 (3) 分数显示“— / N” (4)“你答对 N 题”提示 (5) 及格阈值 s >= K 中。更改测验题目数量时,要检查全部五处。可以考虑改为从 window.COURSE.quiz.length 计算——大多数地方都能据此推导。

模式 10:用于材料预览的 iframe 模态框

与其在新标签页中打开材料(会丢失进度上下文),不如将其嵌入模态框:

function openViewer(url) {
  const modal = document.createElement('div'); modal.className = 'viewer-modal';
  modal.innerHTML = `<div class="viewer-backdrop"></div><div class="viewer-frame">
    <button class="viewer-close">×</button>
    <iframe src="${url}" loading="lazy"></iframe>
  </div>`;
  document.body.appendChild(modal);
  modal.querySelector('.viewer-close').onclick = () => modal.remove();
  modal.querySelector('.viewer-backdrop').onclick = () => modal.remove();
  document.addEventListener('keydown', function esc(e) {
    if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', esc); }
  });
}

对于需要下载的 PDF,跳过模态框:直接渲染 <a href="..." download>,让浏览器触发保存对话框。Markdown / HTML / TXT → 模态框;PDF → 下载。此分支逻辑应放在 getMaterialUrl() 的调用方中。

模式 11:模态框打开时锁定页面滚动

function openViewer(url) { /* ... */ document.body.style.overflow = 'hidden'; }
function closeViewer() { document.body.style.overflow = ''; }

否则,在 macOS Safari 上滚动模态框时,底层页面也会随之滚动。

验证

不要将验证脚本写进此技能——它们有各自专用的技能。接入上述任意交互模式后,请调用 web-visual-verification 生成相应的验证脚本(verify-rwd / verify-progress / verify-quiz / verify-modal 等)。该技能介绍了四种脚本用途(verify / capture / diagnose / probe)、可复用断言和多视口模式。

简而言之:此技能中的每种交互模式都有对应的验证脚本。没有验证脚本,就不要发布交互功能。

交接

告诉用户:“交互层已完成。请在浏览器中打开并点击体验,然后运行验证脚本。下一阶段(web-visual-assets)将补上图像资源——你的页面现在可能还缺少图片,或使用了占位图。”

评论

支持 Markdown