Explorar o código

Merge branch 'beta' of unrelated histories: resolve conflicts, integrate English Speaking module

lsc hai 2 días
pai
achega
4095ce3599
Modificáronse 60 ficheiros con 8446 adicións e 107 borrados
  1. 12 1
      package.json
  2. 250 0
      scripts/test-article-auto-format-flow.mjs
  3. 70 0
      scripts/test-article-auto-format.mjs
  4. 238 0
      scripts/test-article-class-summary-auto.mjs
  5. 106 0
      scripts/test-article-errors.mjs
  6. 26 0
      scripts/test-article-reading-data.mjs
  7. 900 0
      scripts/test-article-reading-engine.mjs
  8. 79 0
      scripts/test-article-reading-i18n.mjs
  9. 52 0
      scripts/test-article-reading-model.mjs
  10. 137 0
      scripts/test-article-reading-routing.mjs
  11. 114 0
      scripts/test-article-reading-stream.mjs
  12. 116 0
      scripts/test-article-recorder-sample-rate.mjs
  13. 15 0
      scripts/test-speaking-api-config.mjs
  14. 2 0
      src/components/CollapsibleToolbar/index.vue
  15. 21 7
      src/components/CollapsibleToolbar/index2.vue
  16. 2 0
      src/components/CollapsibleToolbar/index22.vue
  17. 17 0
      src/configs/englishSpeakingTools.ts
  18. 21 15
      src/services/speaking.ts
  19. 60 0
      src/store/articleReading.ts
  20. 112 0
      src/types/articleReading.ts
  21. 7 1
      src/types/englishSpeaking.ts
  22. 7 5
      src/views/Editor/Canvas/Operate/index.vue
  23. 3 3
      src/views/Editor/Canvas/index.vue
  24. 23 4
      src/views/Editor/CanvasTool/index2.vue
  25. 17 3
      src/views/Editor/EnglishSpeaking/SpeakingPanel.vue
  26. 346 0
      src/views/Editor/EnglishSpeaking/components/ArticleFormatDialog.vue
  27. 17 4
      src/views/Editor/EnglishSpeaking/components/RecommendCard.vue
  28. 45 0
      src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts
  29. 117 0
      src/views/Editor/EnglishSpeaking/composables/useArticleAutoFormat.ts
  30. 827 0
      src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts
  31. 65 20
      src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts
  32. 72 4
      src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts
  33. 505 0
      src/views/Editor/EnglishSpeaking/configs/ArticlePracticeConfig.vue
  34. 16 0
      src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json
  35. 4 0
      src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json
  36. 70 18
      src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue
  37. 186 0
      src/views/Editor/EnglishSpeaking/preview/ArticleDetailedReport.vue
  38. 357 0
      src/views/Editor/EnglishSpeaking/preview/ArticleOverallReport.vue
  39. 409 0
      src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue
  40. 408 0
      src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue
  41. 829 0
      src/views/Editor/EnglishSpeaking/preview/ArticleReadingView.vue
  42. 60 0
      src/views/Editor/EnglishSpeaking/preview/ArticleStarRating.vue
  43. 2 2
      src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
  44. 56 0
      src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts
  45. 93 0
      src/views/Editor/EnglishSpeaking/services/articleErrors.ts
  46. 162 0
      src/views/Editor/EnglishSpeaking/services/articleReading.ts
  47. 111 0
      src/views/Editor/EnglishSpeaking/services/articleReadingStream.ts
  48. 6 0
      src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts
  49. 146 0
      src/views/Student/components/ArticleReadingClassPanel/ClassSummary.vue
  50. 105 0
      src/views/Student/components/ArticleReadingClassPanel/StudentGrid.vue
  51. 222 0
      src/views/Student/components/ArticleReadingClassPanel/StudentReportModal.vue
  52. 233 0
      src/views/Student/components/ArticleReadingClassPanel/index.vue
  53. 9 0
      src/views/Student/components/ArticleReadingClassPanel/types.ts
  54. 171 0
      src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts
  55. 34 8
      src/views/Student/index.vue
  56. 14 1
      src/views/components/element/FrameElement/BaseFrameElement.vue
  57. 24 11
      src/views/components/element/FrameElement/index.vue
  58. 106 0
      src/views/lang/cn.json
  59. 106 0
      src/views/lang/en.json
  60. 106 0
      src/views/lang/hk.json

+ 12 - 1
package.json

@@ -12,7 +12,18 @@
     "build-only": "vite build",
     "type-check": "vue-tsc --build --force",
     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
-    "prepare": "husky install"
+    "prepare": "husky install",
+    "test:article-reading-data": "node scripts/test-article-reading-data.mjs",
+    "test:article-reading-model": "node scripts/test-article-reading-model.mjs",
+    "test:article-reading-stream": "node scripts/test-article-reading-stream.mjs",
+    "test:article-reading-engine": "node scripts/test-article-reading-engine.mjs",
+    "test:article-recorder-sample-rate": "node scripts/test-article-recorder-sample-rate.mjs",
+    "test:article-reading-routing": "node scripts/test-article-reading-routing.mjs",
+    "test:article-reading-i18n": "node scripts/test-article-reading-i18n.mjs",
+    "test:article-auto-format": "node scripts/test-article-auto-format.mjs",
+    "test:article-auto-format-flow": "node scripts/test-article-auto-format-flow.mjs",
+    "test:article-errors": "node scripts/test-article-errors.mjs",
+    "test:article-class-summary-auto": "node scripts/test-article-class-summary-auto.mjs"
   },
   "author": "pipipi_pikachu@163.com",
   "homepage": "https://github.com/pipipi-pikachu/PPTist",

+ 250 - 0
scripts/test-article-auto-format-flow.mjs

@@ -0,0 +1,250 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+const transpile = source => ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+const base = 'src/views/Editor/EnglishSpeaking/composables/'
+
+// 纯规则模块先转译成 data URL,供 composable 原样 import(相对路径在 data: 里无法解析)
+const rulesUrl = `data:text/javascript,${encodeURIComponent(
+  transpile(await read(`${base}articleAutoFormatRules.ts`)),
+)}`
+
+let source = await read(`${base}useArticleAutoFormat.ts`)
+source = source
+  .replace(
+    "import { onScopeDispose, ref, type Ref } from 'vue'",
+    // onScopeDispose 桩:把清理回调存到 globalThis,供测试手动模拟"组件已卸载"
+    'const ref = value => ({ value }); const onScopeDispose = fn => { globalThis.__onDispose = fn }',
+  )
+  .replace(
+    "import { lang } from '@/main'",
+    "const lang = { ssArticleSegmentFailed: 'segment-failed' }",
+  )
+  .replace(
+    "import message from '@/utils/message'",
+    'const message = { error: text => globalThis.__errors.push(text) }',
+  )
+  .replace(
+    "import { segmentArticle } from '../services/articleReading'",
+    'const segmentArticle = (...args) => globalThis.__segment(...args)',
+  )
+  // 双引号做外层定界符:rulesUrl 是 encodeURIComponent 输出,单引号/反引号在其字符集里不转义,
+  // 若仍用单引号包裹会被规则文件注释里的反引号/`.join(' ')` 里的单引号提前截断字符串。
+  .replace("from './articleAutoFormatRules'", `from "${rulesUrl}"`)
+
+const { useArticleAutoFormat } = await import(
+  `data:text/javascript,${encodeURIComponent(transpile(source))}`
+)
+
+const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ')
+
+function setup({ segment } = {}) {
+  globalThis.__errors = []
+  globalThis.__segment = segment ?? (async () => ({ content: 'P1.\n\nP2.' }))
+  const content = { value: '' }
+  return { content, api: useArticleAutoFormat(content), errors: globalThis.__errors }
+}
+
+// 失败用例是故意的,composable 会如实 console.error。但它的栈里带着整个 data: URL
+// (被转译进去的源码),一条就是好几 KB,会把真正的测试输出淹掉。捕获掉,让通过时的
+// 输出保持干净;断言仍然验证 message.error 收到了什么。
+async function withSilencedConsoleError(fn) {
+  const original = console.error
+  console.error = () => {}
+  try {
+    await fn()
+  }
+  finally {
+    console.error = original
+  }
+}
+
+// ---------------- 粘贴触发 ----------------
+{
+  const { content, api } = setup()
+  api.onContentInput(words(60))
+  assert.equal(content.value, words(60), '内容恒写入(取消时保留原始粘贴)')
+  assert.equal(api.dialogVisible.value, true)
+  assert.equal(api.dialogMode.value, 'paste')
+}
+{
+  const { api } = setup()
+  api.onContentInput(words(40))
+  assert.equal(api.dialogVisible.value, false, '短文不弹')
+}
+{
+  // 已有长文再敲一个字符 —— 绝不能弹(prev 取自 content.value,不是陈旧变量)
+  const { content, api } = setup()
+  content.value = words(300)
+  api.onContentInput(`${words(300)} x`)
+  assert.equal(api.dialogVisible.value, false)
+}
+
+// ---------------- 粘贴 → 取消 → 内容不变(spec §3.2 step1「取消 → 保留原始粘贴」)----------------
+{
+  const { content, api } = setup()
+  api.onContentInput(words(60))
+  assert.equal(api.dialogVisible.value, true)
+  api.cancelDialog()
+  assert.equal(api.dialogVisible.value, false, '取消关框')
+  assert.equal(content.value, words(60), '取消不清空/不改写老师刚粘贴的内容')
+}
+
+// ---------------- 按钮触发 ----------------
+{
+  const { content, api } = setup()
+  content.value = words(80)
+  api.openOverwrite()
+  assert.equal(api.dialogMode.value, 'overwrite')
+  assert.equal(api.dialogVisible.value, true)
+}
+{
+  const { api } = setup() // 空文本
+  api.openOverwrite()
+  assert.equal(api.dialogVisible.value, false, '空文本不开框')
+}
+
+// ---------------- 应用配置守卫 ----------------
+{
+  const { content, api } = setup()
+  content.value = 'A one.\nB   two.\n\n\nC three.'
+  assert.equal(api.guardBeforeApply(), true, '无超长段 → 放行')
+  assert.equal(content.value, 'A one.\n\nB two.\n\nC three.', '归一化回写 textarea')
+  assert.equal(api.dialogVisible.value, false)
+}
+{
+  // 拦截路径也要归一化回写(脏空白不能留在 textarea)——通行路径已断言,这里补上拦截路径
+  const { content, api } = setup()
+  const messyOverlong = words(301).replace(/ /g, '   ') // 词间三倍空格
+  content.value = `A   one.\n\n\n\n${messyOverlong}`
+  assert.equal(api.guardBeforeApply(), false, '超长段 → 拦截')
+  assert.equal(content.value, `A one.\n\n${words(301)}`, '拦截也归一化回写')
+  assert.equal(api.dialogMode.value, 'guard')
+  assert.equal(api.dialogVisible.value, true)
+}
+{
+  // 请求进行中点「应用配置」:guardBeforeApply 必须早退,程序化调用也绕不过去(不止按钮 disabled)
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Done.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog() // isFormatting → true
+  assert.equal(api.guardBeforeApply(), false, '格式化进行中,guardBeforeApply 必须早退')
+  assert.equal(content.value, words(60), '早退不应触碰内容(不归一化、不误判超长)')
+  assert.equal(api.dialogMode.value, 'overwrite', 'dialogMode 不应被早退的 guardBeforeApply 改成 guard')
+  release()
+  await confirming
+}
+
+// ---------------- 确认键:文本清空后点确认 ----------------
+{
+  // 可达路径(非 UI-unreachable 早退):老师清空文本框再点确认,弹框不能卡住
+  const { content, api } = setup()
+  content.value = words(60)
+  api.openOverwrite()
+  content.value = '   '
+  await api.confirmDialog()
+  assert.equal(api.dialogVisible.value, false, '空文本点确认 —— 关框而非静默无反应')
+  assert.equal(api.isFormatting.value, false)
+}
+
+// ---------------- 分段成功 ----------------
+{
+  const calls = []
+  const { content, api } = setup({
+    segment: async text => { calls.push(text); return { content: 'One.\n\n\nTwo.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  await api.confirmDialog()
+  assert.deepEqual(calls, [words(60)], '整篇送后端')
+  assert.equal(content.value, 'One.\n\nTwo.', '回填并归一化')
+  assert.equal(api.dialogVisible.value, false)
+  assert.equal(api.isFormatting.value, false)
+}
+
+// ---------------- 分段失败:非守卫框关闭、守卫框保持打开 ----------------
+{
+  const { content, api, errors } = setup({ segment: async () => { throw new Error('down') } })
+  content.value = words(60)
+  api.openOverwrite()
+  await withSilencedConsoleError(() => api.confirmDialog())
+  assert.equal(content.value, words(60), '失败保留原文本')
+  assert.equal(api.dialogVisible.value, false)
+  assert.deepEqual(errors, ['segment-failed'])
+}
+{
+  const { content, api } = setup({ segment: async () => { throw new Error('down') } })
+  content.value = words(400)
+  api.guardBeforeApply()
+  await withSilencedConsoleError(() => api.confirmDialog())
+  assert.equal(api.dialogVisible.value, true, '守卫框失败后保持打开,可原地重试')
+  assert.equal(api.isFormatting.value, false)
+  assert.equal(content.value, words(400), '守卫框失败同样保留内容(非守卫失败已断言,这里补上守卫路径)')
+}
+
+// ---------------- 防重复点击 ----------------
+{
+  let calls = 0
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { calls += 1; await pending; return { content: 'Done.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const first = api.confirmDialog()
+  await api.confirmDialog()          // 进行中再点 —— 必须早退
+  assert.equal(calls, 1)
+  api.cancelDialog()                  // 进行中取消 —— 必须无效
+  assert.equal(api.dialogVisible.value, true)
+  release()
+  await first
+  assert.equal(calls, 1)
+  assert.equal(api.dialogVisible.value, false)
+}
+
+// ---------------- 陈旧性守卫:等待期间内容被改,响应作废不回写 ----------------
+{
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Stale.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog()
+  content.value = 'teacher typed something newer' // 30 秒等待期间老师又改了文本
+  release()
+  await confirming
+  assert.equal(content.value, 'teacher typed something newer', '内容中途被改 → 响应作废,绝不覆盖')
+  assert.equal(api.dialogVisible.value, false, '仍然关框,不让弹框卡住')
+}
+
+// ---------------- 组件卸载(effect scope 销毁)后到达的响应是 no-op ----------------
+{
+  let release
+  const pending = new Promise(resolve => { release = resolve })
+  const { content, api } = setup({
+    segment: async () => { await pending; return { content: 'Late.' } },
+  })
+  content.value = words(60)
+  api.openOverwrite()
+  const confirming = api.confirmDialog()
+  globalThis.__onDispose() // 模拟宿主组件在响应落地前已卸载
+  release()
+  await confirming
+  assert.equal(content.value, words(60), '销毁后到达的响应不得写 store')
+  assert.equal(api.dialogVisible.value, true, '销毁后不再触碰任何本地状态')
+}
+
+console.log('article auto-format flow OK')

+ 70 - 0
scripts/test-article-auto-format.mjs

@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL(
+  '../src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts',
+  import.meta.url,
+)
+const compiled = ts.transpileModule(await readFile(sourceUrl, 'utf8'), {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const {
+  countWords,
+  normalizeParagraphs,
+  hasOverlongParagraph,
+  shouldPromptOnPaste,
+  MAX_PARAGRAPH_WORDS,
+  PASTE_PROMPT_MIN_WORDS,
+} = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+/** 生成 n 个单词的文本 */
+const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ')
+
+// ---------------- countWords ----------------
+assert.equal(countWords(''), 0)
+assert.equal(countWords('   \n\n  '), 0)
+assert.equal(countWords('one two   three'), 3)
+assert.equal(countWords('  leading and trailing  '), 3)
+assert.equal(countWords(words(300)), 300)
+
+// ---------------- normalizeParagraphs ----------------
+// 与后端 split_paragraphs 同规则:单 \n 与空行皆为段界
+assert.equal(normalizeParagraphs('A one.\n\nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('A one.\nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('A one.\n\n\n\n   \nB two.'), 'A one.\n\nB two.')
+assert.equal(normalizeParagraphs('  A   one    here.  '), 'A one here.')
+assert.equal(normalizeParagraphs(''), '')
+assert.equal(normalizeParagraphs('   \n\n  '), '')
+// 幂等:归一化结果再归一化不变(会回写 textarea,必须稳定)
+assert.equal(normalizeParagraphs(normalizeParagraphs('A.\nB.\n\n\nC.')), 'A.\n\nB.\n\nC.')
+
+// ---------------- hasOverlongParagraph(299 / 300 / 301 边界)----------------
+assert.equal(MAX_PARAGRAPH_WORDS, 300)
+assert.equal(hasOverlongParagraph(''), false)
+assert.equal(hasOverlongParagraph(words(299)), false)
+assert.equal(hasOverlongParagraph(words(300)), false)
+assert.equal(hasOverlongParagraph(words(301)), true)
+// 多段:只要有一段超标就 true
+assert.equal(hasOverlongParagraph(`${words(10)}\n\n${words(301)}`), true)
+assert.equal(hasOverlongParagraph(`${words(200)}\n\n${words(200)}`), false)
+// 单 \n 也是段界,故 200+200 不算一段 400
+assert.equal(hasOverlongParagraph(`${words(200)}\n${words(200)}`), false)
+
+// ---------------- shouldPromptOnPaste ----------------
+assert.equal(PASTE_PROMPT_MIN_WORDS, 50)
+// spec §3.2 step 1 只有两条:原内容 trim 后为空,且新内容 > 50 词。
+assert.equal(shouldPromptOnPaste('', words(60)), true)       // 空框粘贴长文 → 弹
+assert.equal(shouldPromptOnPaste('   ', words(51)), true)    // 纯空白算空框;51 词过阈值
+assert.equal(shouldPromptOnPaste('\n\n  \n', words(60)), true) // 只剩换行的框同样算空
+assert.equal(shouldPromptOnPaste('', words(50)), false)      // 恰好 50 词不弹(严格 >)
+assert.equal(shouldPromptOnPaste('', words(40)), false)      // 短文不弹
+assert.equal(shouldPromptOnPaste(words(300), words(400)), false) // 原文非空 → 不打断编辑
+assert.equal(shouldPromptOnPaste('x', words(400)), false)    // 哪怕只有一个字符也不算空框
+// 粘贴内容自带空行照样提示:带 \n\n ≠ 分得适合朗读(三段各 400 词就是反例)
+assert.equal(shouldPromptOnPaste('', `${words(30)}\n\n${words(30)}`), true)
+assert.equal(shouldPromptOnPaste('', `${words(400)}\n\n${words(400)}`), true)
+// enspeak 的 sentenceCount>=2 条件已弃用:无标点长文照样提示
+assert.equal(shouldPromptOnPaste('', words(60).replace(/\./g, '')), true)
+
+console.log('article auto-format rules OK')

+ 238 - 0
scripts/test-article-class-summary-auto.mjs

@@ -0,0 +1,238 @@
+/**
+ * 老师端班级总结的自动刷新(spec §8.2)
+ *
+ * 覆盖:班级状态指纹的口径;定时 tick 的四道闸门(重入 / 分页隐藏 / 无人完成 /
+ * 指纹未变);失败不记指纹于是下次 tick 重试;手动刷新同样更新指纹;计时器
+ * 间隔与卸载清理。手法承 test-article-auto-format-flow.mjs —— 转译 composable,
+ * 把 vue 与 service 换成桩。
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+const transpile = source => ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+let source = await read(
+  'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts',
+)
+source = source
+  .replace(
+    "import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'",
+    [
+      'const ref = value => ({ value })',
+      // watch 的 immediate 由测试自己触发(__watch()),好精确控制每一次 tick
+      'const watch = (_src, cb) => { globalThis.__watch = cb }',
+      'const onMounted = fn => { globalThis.__mounted = fn }',
+      'const onUnmounted = fn => { globalThis.__unmounted = fn }',
+    ].join('; '),
+  )
+  .replace(
+    /import \{\s*listArticleSessionsByConfig,\s*generateArticleClassSummary,\s*\} from '@\/views\/Editor\/EnglishSpeaking\/services\/articleReading'/,
+    [
+      'const listArticleSessionsByConfig = (...a) => globalThis.__list(...a)',
+      'const generateArticleClassSummary = (...a) => globalThis.__summary(...a)',
+    ].join('; '),
+  )
+
+const { useArticleClassSummary, classStateKey } = await import(
+  `data:text/javascript,${encodeURIComponent(transpile(source))}`
+)
+
+const flush = () => new Promise(resolve => setTimeout(resolve, 0))
+const row = (userId, status, overallScore = null) => ({
+  userId, name: userId, status, overallScore, dims: null, sessionId: 's-' + userId, completedAt: null,
+})
+
+/** 组件挂载:驱动 watch 的 immediate 那一次,并跑 onMounted */
+function setup({ rows = [], summary } = {}) {
+  globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {} }
+  globalThis.__summaryCalls = 0
+  globalThis.__list = async () => ({ summaries: current.rows })
+  globalThis.__summary = async () => {
+    globalThis.__summaryCalls += 1
+    if (summary) return summary()
+    return { summary: 'text-' + globalThis.__summaryCalls, generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+  }
+  const current = { rows }
+  const api = useArticleClassSummary({
+    configId: { value: 'cfg-1' },
+    studentArray: { value: [{ userid: 'u1', name: 'u1' }, { userid: 'u2', name: 'u2' }] },
+  })
+  globalThis.__mounted()
+  return { api, current, tick: async () => { globalThis.__watch(); await flush(); await flush() } }
+}
+
+function teardown() {
+  globalThis.__unmounted()
+}
+
+// ---------------- 指纹口径 ----------------
+{
+  const a = [row('u1', 'completed', 80), row('u2', 'reading')]
+  const b = [row('u2', 'reading'), row('u1', 'completed', 80)]
+  assert.equal(classStateKey('c', a), classStateKey('c', b), '与数组顺序无关')
+
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'reading')]),
+    classStateKey('c', [row('u1', 'completed')]),
+    '状态变化要进指纹',
+  )
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'completed', 80)]),
+    classStateKey('c', [row('u1', 'completed', 81)]),
+    '分数变化要进指纹(分数晚于 status 落地)',
+  )
+  assert.notEqual(
+    classStateKey('c', [row('u1', 'completed', null)]),
+    classStateKey('c', [row('u1', 'completed', 0)]),
+    'null 与 0 不可混同',
+  )
+  assert.notEqual(
+    classStateKey('cfg-1', a),
+    classStateKey('cfg-2', a),
+    'configId 进指纹 —— 换配置自动失效',
+  )
+}
+
+// ---------------- 闸门:全班都没开始 ----------------
+{
+  const { current, tick } = setup({ rows: [row('u1', 'not_started'), row('u2', 'not_started')] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 0, '一人都没开始 → 不生成(只能写「未开始 N 人」)')
+
+  current.rows = [row('u1', 'reading'), row('u2', 'not_started')]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '有人开始朗读就生成,不必等到有人完成')
+
+  current.rows = [row('u1', 'completed', 70), row('u2', 'not_started')]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '同一人从进行中变完成 → 再刷新一次')
+  teardown()
+}
+
+// ---------------- 闸门:指纹未变 ----------------
+{
+  const { api, current, tick } = setup({ rows: [row('u1', 'completed', 70)] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1)
+  assert.equal(api.aiSummary.value, 'text-1')
+
+  await tick()
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '班级状态没变,定时 tick 不烧 LLM')
+  assert.equal(api.aiSummary.value, 'text-1', '措辞不会无故换掉')
+
+  current.rows = [row('u1', 'completed', 70), row('u2', 'completed', 90)]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '有新完成者就刷新')
+  teardown()
+}
+
+// 分数晚于 status 落地:同一批人、只有分数补上,也算新东西
+{
+  const { current, tick } = setup({ rows: [row('u1', 'completed', null)] })
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1)
+  current.rows = [row('u1', 'completed', 76)]
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 2, '分数回填后重算')
+  teardown()
+}
+
+// ---------------- 失败不记指纹 → 下次 tick 自动重试 ----------------
+{
+  let attempt = 0
+  const { api, tick } = setup({
+    rows: [row('u1', 'completed', 70)],
+    summary: () => {
+      attempt += 1
+      if (attempt === 1) throw new Error('boom')
+      return { summary: 'recovered', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+    },
+  })
+  const originalError = console.error
+  console.error = () => {}
+  try {
+    await tick()
+    assert.equal(api.aiError.value, 'SUMMARY_FAILED')
+    assert.equal(api.aiSummary.value, '', '从未成功 → 正文仍为空,由弹框显示错误块')
+    await tick()
+  }
+  finally {
+    console.error = originalError
+  }
+  assert.equal(globalThis.__summaryCalls, 2, '失败不记指纹,下次 tick 重试')
+  assert.equal(api.aiSummary.value, 'recovered')
+  assert.equal(api.aiError.value, null)
+  teardown()
+}
+
+// ---------------- 手动刷新也更新指纹 ----------------
+{
+  const { api, tick } = setup({ rows: [row('u1', 'completed', 70)] })
+  await api.fetchClassSummary()
+  await api.refreshAISummary()
+  assert.equal(globalThis.__summaryCalls, 1, '手动点按钮照常生成')
+  await tick()
+  assert.equal(globalThis.__summaryCalls, 1, '紧接着的 tick 是 no-op,不必重置计时器')
+  teardown()
+}
+
+// ---------------- 分页隐藏时跳过,切回来立刻补 ----------------
+{
+  const listeners = {}
+  globalThis.document = {
+    hidden: true,
+    addEventListener: (type, fn) => { listeners[type] = fn },
+    removeEventListener: (type) => { delete listeners[type] },
+  }
+  globalThis.__summaryCalls = 0
+  globalThis.__list = async () => ({ summaries: [row('u1', 'completed', 70)] })
+  globalThis.__summary = async () => {
+    globalThis.__summaryCalls += 1
+    return { summary: 'x', generatedAt: 'now', fromCache: false, llmStatus: 'ok' }
+  }
+  useArticleClassSummary({
+    configId: { value: 'cfg-1' },
+    studentArray: { value: [{ userid: 'u1', name: 'u1' }] },
+  })
+  globalThis.__mounted()
+  globalThis.__watch()
+  await flush(); await flush()
+  assert.equal(globalThis.__summaryCalls, 0, '老师切走的分页不烧 LLM')
+
+  globalThis.document.hidden = false
+  listeners.visibilitychange()
+  await flush(); await flush()
+  assert.equal(globalThis.__summaryCalls, 1, '切回来立刻补一次,不必再等 60 秒')
+
+  globalThis.__unmounted()
+  assert.equal(listeners.visibilitychange, undefined, '卸载摘掉监听')
+}
+
+// ---------------- 计时器:间隔与清理 ----------------
+{
+  const originalSet = globalThis.setInterval
+  const originalClear = globalThis.clearInterval
+  const created = []
+  const cleared = []
+  globalThis.setInterval = (_fn, ms) => { created.push(ms); return { id: created.length } }
+  globalThis.clearInterval = handle => { cleared.push(handle) }
+  try {
+    const { api } = setup({ rows: [] })
+    assert.deepEqual(created, [60_000], '定时间隔 60 秒')
+    assert.ok(api.scheduleRefetch, 'WS 去抖刷新仍然对外暴露')
+    teardown()
+    assert.deepEqual(cleared, [{ id: 1 }], '卸载清掉定时器')
+  }
+  finally {
+    globalThis.setInterval = originalSet
+    globalThis.clearInterval = originalClear
+  }
+}
+
+console.log('✓ test-article-class-summary-auto: all assertions passed')

+ 106 - 0
scripts/test-article-errors.mjs

@@ -0,0 +1,106 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleErrors.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+// `lang` 是 PPT 运行时的响应式对象;测试里换成 identity 桩,直接断言「返回的是哪个 key」,
+// 而不是断言译文 —— 译文归 test-article-reading-i18n.mjs 管,这里只管映射对不对。
+source = source
+  // 桩返回 key 本身,于是断言的是「映射到哪个 key」而不是译文。带占位符的那条例外:
+  // 它多挂一个 `{n}`,好让「段号有没有被填进去」也能被断言到。
+  .replace(
+    "import { lang } from '@/main'",
+    "const lang = new Proxy({}, { get: (_, k) => k === 'ssArticleErrDemoAudio' ? 'ssArticleErrDemoAudio:{n}' : String(k) })",
+  )
+  .replace(/import \{[^}]*\} from '\.\/articleReading'/, 'class ArticleApiError extends Error { constructor(m, s) { super(m); this.status = s } }')
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const { friendlyArticleError, articleActiveCountFromError } = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+const cases = [
+  ['Config not found', 'ssArticleErrConfigNotFound'],
+  ['Config is not an article config', 'ssArticleErrNotArticleConfig'],
+  ['Article content is empty', 'ssArticleErrContentEmpty'],
+  ['Session not found', 'ssArticleErrSessionNotFound'],
+  ['Paragraph not found', 'ssArticleErrParagraphNotFound'],
+  ['Session is abandoned', 'ssArticleSessionExpired'],
+  ['Session has ended', 'ssArticleErrSessionEnded'],
+  ['Report already generated', 'ssArticleErrReportGenerated'],
+  ['Segmentation failed', 'ssArticleSegmentFailed'],
+  ['Segmentation returned empty output', 'ssArticleSegmentFailed'],
+  ['content is required', 'ssArticleErrBadRequest'],
+  ['configId is required', 'ssArticleErrBadRequest'],
+  ['userId is required', 'ssArticleErrBadRequest'],
+  ['users is required', 'ssArticleErrBadRequest'],
+  ['users capped at 100', 'ssArticleErrBadRequest'],
+]
+
+for (const [detail, key] of cases) {
+  assert.equal(friendlyArticleError(new Error(detail)), key, `bare detail: ${detail}`)
+}
+
+// request() 万一没拆干净、或者别的 service 抛了带包装的 message,也要吃得下
+assert.equal(
+  friendlyArticleError(new Error('{"detail":"Session not found"}')),
+  'ssArticleErrSessionNotFound',
+  'raw JSON body',
+)
+assert.equal(
+  friendlyArticleError(new Error('[404] {"detail":"Config not found"}')),
+  'ssArticleErrConfigNotFound',
+  '@/services/speaking parse() 的 `[404] {...}` 形状',
+)
+
+// 409「有人在练」:人数照旧要能摘出来(弹框靠它),但它**不该**被当成错误文案 ——
+// 2026-07-24 起那是问句:handleApply 截住它去弹确认框(spec §4.3 B)。真漏到 toast 只能是
+// 别处误用,那就走通用兜底,绝不出现「暂时无法修改」这种已经不成立的话。
+assert.equal(
+  friendlyArticleError(new Error('Students are practising: 3')),
+  'ssArticleErrGeneric',
+  '409 不再映射成一句「无法修改」',
+)
+assert.equal(articleActiveCountFromError(new Error('Students are practising: 3')), 3)
+assert.equal(articleActiveCountFromError(new Error('Students are practising: 0')), 0)
+assert.equal(articleActiveCountFromError(new Error('Session not found')), null)
+assert.equal(articleActiveCountFromError(null), null)
+
+// 保存配置时整篇示范音频同步合成,失败的 detail 带着段号(`[2]` / `[1, 3]`)。
+// 段号是老师唯一能据以行动的信息,必须填进文案,不能只回一句笼统的失败。
+assert.equal(
+  friendlyArticleError(new Error('Demo audio synthesis failed: [2]')),
+  'ssArticleErrDemoAudio:2',
+  'single failing paragraph',
+)
+assert.equal(
+  friendlyArticleError(new Error('Demo audio synthesis failed: [1, 3]')),
+  'ssArticleErrDemoAudio:1、3',
+  'several failing paragraphs',
+)
+assert.equal(
+  friendlyArticleError(new Error('{"detail":"Demo audio synthesis failed: [4]"}')),
+  'ssArticleErrDemoAudio:4',
+  'wrapped in a JSON body',
+)
+
+// 兜底:绝不把原始内容漏出去
+const leaks = [
+  new Error('{"detail":"some brand new backend error"}'),
+  new Error('TypeError: Failed to fetch'),
+  new Error(''),
+  'a bare string',
+  null,
+  undefined,
+  { nope: true },
+]
+for (const cause of leaks) {
+  const out = friendlyArticleError(cause)
+  assert.equal(out, 'ssArticleErrGeneric', `fallback for ${JSON.stringify(String(cause))}`)
+  assert.ok(!out.includes('detail'), 'never leak the response body')
+  assert.ok(!out.includes('{'), 'never leak JSON')
+}
+
+console.log(`article errors: ${cases.length + 2} mappings + ${leaks.length} fallbacks OK`)

+ 26 - 0
scripts/test-article-reading-data.mjs

@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const unitId = 'SHEP-5-上-Unit2'
+const topicUrl = new URL('../src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json', import.meta.url)
+const articleUrl = new URL('../src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json', import.meta.url)
+const topics = JSON.parse(await readFile(topicUrl, 'utf8'))
+const articles = JSON.parse(await readFile(articleUrl, 'utf8'))
+
+assert.equal(topics[unitId].length, 4)
+assert.ok(topics[unitId].every(task => task.taskType === 'topic-discussion'))
+assert.equal(articles[unitId].length, 1)
+
+const article = articles[unitId][0]
+assert.equal(article.id, 'ar-unit2-zoo')
+assert.equal(article.taskType, 'article-reading')
+assert.equal(article.titleEn, 'A Day at the Zoo')
+assert.equal(article.durationMinutes, 10)
+// 标题只住在 titleEn 里,**不再重复进正文**(2026-07-26 起)——
+// 正文第一段就是第一段朗读内容,否则学生第一次录音录的是标题。
+assert.ok(!article.content.startsWith(article.titleEn), 'title must not be duplicated into content')
+assert.ok(article.content.startsWith('Last Sunday'))
+assert.ok(article.content.endsWith('I learned many things about animals.'))
+// 首尾由上面两条断言守住;这条只兜住"中段被删",故用宽松下限而非精确值——
+// 正文允许正常修订,但不该缩水到只剩几段。(enspeak 原文实测 829 字符。)
+assert.ok(article.content.length > 700, 'full demo article must not be truncated')

+ 900 - 0
scripts/test-article-reading-engine.mjs

@@ -0,0 +1,900 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+// Vue 运行时替换为最小 shim;watch 只需支持 getter + 立即回调语义
+source = source.replace(
+  "import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'",
+  `
+const __watchers = []
+const ref = value => {
+  const r = { _v: value, get value() { return this._v }, set value(next) { this._v = next; __runWatchers() } }
+  return r
+}
+const computed = getter => ({ get value() { return getter() } })
+const watch = (getter, cb) => { __watchers.push({ getter, cb, last: getter() }) }
+const onUnmounted = () => {}
+const toValue = v => (typeof v === 'function' ? v() : (v && typeof v === 'object' && 'value' in v ? v.value : v))
+globalThis.__runWatchers = () => {
+  for (const w of __watchers) {
+    const next = w.getter()
+    if (next !== w.last) { const prev = w.last; w.last = next; w.cb(next, prev) }
+  }
+}
+`,
+)
+
+// 运行时依赖全部替换为注入桩;测试永远传完整 overrides,这些默认值不会被执行
+source = source
+  .replace("import { useAudioRecorder } from './useAudioRecorder'", 'const useAudioRecorder = () => globalThis.__deps.recorder')
+  .replace("import { useAudioPlayer } from './useAudioPlayer'", 'const useAudioPlayer = () => globalThis.__deps.player')
+  .replace(
+    /import \{[\s\S]*?\} from '\.\.\/services\/articleReading'/,
+    `
+const createArticleSession = (...a) => globalThis.__deps.createSession(...a)
+const getArticleReport = (...a) => globalThis.__deps.getReport(...a)
+const completeArticleSession = (...a) => globalThis.__deps.completeSession(...a)
+const getArticleDemoAudio = (...a) => globalThis.__deps.getDemoAudio(...a)
+const abandonArticleSession = (...a) => globalThis.__deps.abandonSession(...a)
+`,
+  )
+  .replace(
+    /import \{[\s\S]*?\} from '\.\.\/services\/articleErrors'/,
+    `
+const friendlyArticleError = cause => {
+  const raw = cause instanceof Error ? cause.message : String(cause ?? '')
+  return raw === 'Session not found' ? '__localized_session_not_found__' : '__localized_generic__'
+}
+const isArticleSessionGone = cause => cause?.status === 409
+`,
+  )
+  .replace(
+    "import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'",
+    'const openArticleParagraphStream = (...a) => globalThis.__deps.createStream(...a)',
+  )
+  .replace(
+    "import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'",
+    `
+const isFinalReportReady = r => !r.paragraphs.some(p => p.status === 'generating')
+  && (r.overallStatus === 'completed' || r.overallStatus === 'failed' || r.overallStatus === null)
+const pollDelayMs = attempt => Math.min(8000, 1000 * 2 ** Math.max(0, attempt))
+`,
+  )
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---------------- fakes ----------------
+
+function makeRecorder(sampleRate = 48000) {
+  const rec = {
+    recordingDuration: { _v: 0, get value() { return this._v }, set value(v) { this._v = v; globalThis.__runWatchers() } },
+    sampleRate: { value: sampleRate },
+    onChunk: { value: null },
+    started: 0,
+    cleaned: 0,
+    async startRecording() { this.started += 1 },
+    async stopRecording() { return { wav: true, size: 1 } },
+    // 真实实现在这一步停 mediaStream track、关 AudioContext、断开 worklet、清时长 interval。
+    // 不拆的后果不是泄漏一点内存:孤儿 worklet 会继续把音频灌进下一段录音。
+    cleanup() { this.cleaned += 1 },
+  }
+  return rec
+}
+
+function makePlayer() {
+  return {
+    playingId: { value: null },
+    loadingId: { value: null },
+    errorId: { value: null },
+    plays: [],
+    // lazy-url 的 URL 解析发生在 play() 内部(与真 player 一致):示范音频那条
+    // 失败路径要经过这里,引擎的 error.value 才立得起来
+    async play(id, source) {
+      this.plays.push({ id, source })
+      this.loadingId.value = id
+      if (source.kind === 'lazy-url') {
+        try { await source.resolve(new AbortController().signal) }
+        catch {
+          this.loadingId.value = null
+          this.errorId.value = id
+          return
+        }
+      }
+      this.loadingId.value = null
+      this.playingId.value = id
+    },
+    stop() { this.playingId.value = null; this.loadingId.value = null },
+  }
+}
+
+function makeStream() {
+  const stream = {
+    chunks: [],
+    stopped: false,
+    aborted: null,
+    resolveAck: null,
+    pushChunk(c) { this.chunks.push(c) },
+    stop() { this.stopped = true; return new Promise(res => { this.resolveAck = res }) },
+    abort(reason) { this.aborted = reason },
+  }
+  return stream
+}
+
+/** 手控时钟:测试自己决定何时推进 timeout/interval。 */
+function makeClock() {
+  let nextId = 1
+  let nowMs = Date.UTC(2026, 0, 1, 0, 0, 0)
+  const timeouts = new Map()
+  const intervals = new Map()
+  return {
+    timeouts,
+    intervals,
+    now() { return nowMs },
+    /** 推进墙上时钟(秒)。不自动触发 interval —— 用 tickInterval 显式驱动。 */
+    advance(seconds) { nowMs += seconds * 1000 },
+    setTimeout(fn, delay) { const id = nextId++; timeouts.set(id, { fn, delay }); return id },
+    clearTimeout(id) { timeouts.delete(id) },
+    setInterval(fn, delay) { const id = nextId++; intervals.set(id, { fn, delay }); return id },
+    clearInterval(id) { intervals.delete(id) },
+    async runTimeouts() {
+      const pending = [...timeouts.entries()]
+      timeouts.clear()
+      for (const [, t] of pending) { t.fn(); await flush() }
+    },
+    async tickInterval(times = 1) {
+      for (let i = 0; i < times; i++) {
+        for (const [, t] of [...intervals.entries()]) t.fn()
+        await flush()
+      }
+    },
+  }
+}
+
+function makeDoc(hidden = false) {
+  return {
+    hidden,
+    listeners: {},
+    addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn) },
+    removeEventListener(type, fn) {
+      this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn)
+    },
+    fire(type) { for (const fn of [...(this.listeners[type] || [])]) fn() },
+  }
+}
+
+const flush = () => new Promise(resolve => setImmediate(resolve))
+
+function paragraphs(statuses) {
+  return statuses.map((status, idx) => ({
+    idx, text: `p${idx}`, status, audioUrl: null, overallScore: null, dims: null, words: [],
+  }))
+}
+
+function reportOf(statuses, overallStatus = null, extra = {}) {
+  return {
+    sessionId: 'sess-1', title: 'T', status: 'reading', totalDurationSeconds: null,
+    // §4.4:绝对截止时刻。默认 null = 不限时,既有用例的行为因此不变。
+    expiresAt: null,
+    paragraphs: paragraphs(statuses), overall: null, overallStatus, ...extra,
+  }
+}
+
+function setup({
+  statuses = ['pending', 'pending'], reports = [],
+  showReport = true, durationMinutes = 1, expiresAt = null, userId = 'u1', abandonSession, completeSession,
+  getReport, createSession, getDemoAudio, onSessionAbandoned, doc, clock,
+} = {}) {
+  const recorder = makeRecorder()
+  const player = makePlayer()
+  const streams = []
+  const calls = { complete: 0, getReport: 0, demo: 0, abandon: 0 }
+  let reportQueue = [...reports]
+
+  const deps = {
+    recorder,
+    player,
+    document: doc ?? makeDoc(),
+    setTimeout: (...a) => clock.setTimeout(...a),
+    clearTimeout: id => clock.clearTimeout(id),
+    setInterval: (...a) => clock.setInterval(...a),
+    clearInterval: id => clock.clearInterval(id),
+    now: () => clock.now(),
+    async createSession(...a) {
+      if (createSession) return createSession(...a)
+      return reportOf(statuses, null, { expiresAt })
+    },
+    async getReport() {
+      calls.getReport += 1
+      if (getReport) return getReport()
+      return reportQueue.length > 1 ? reportQueue.shift() : (reportQueue[0] ?? reportOf(statuses, null, { expiresAt }))
+    },
+    completeSession: completeSession ?? (async () => { calls.complete += 1 }),
+    async getDemoAudio(...a) {
+      calls.demo += 1
+      if (getDemoAudio) return getDemoAudio(...a)
+      return { url: 'https://s3/demo.mp3' }
+    },
+    abandonSession: abandonSession ?? (async id => { calls.abandon += 1; return { sessionId: id, status: 'abandoned' } }),
+    createStream(opts) { const s = makeStream(); s.opts = opts; streams.push(s); return s },
+  }
+  globalThis.__deps = deps
+
+  const completed = []
+  const engine = mod.useArticleReadingEngine(
+    {
+      configId: 'c1', userId, durationMinutes, showReport,
+      onSessionCompleted: id => completed.push(id),
+      onSessionAbandoned,
+    },
+    deps,
+  )
+  return { engine, recorder, player, streams, calls, completed, deps, setReports: r => { reportQueue = [...r] } }
+}
+
+// ---- 1. stream 建立前的 PCM 不丢;handshake 用 recorder 真实 sampleRate ----
+{
+  const clock = makeClock()
+  const { engine, recorder, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  const startPromise = engine.startRecording()
+  const early = new Uint8Array([7, 7]).buffer
+  recorder.onChunk.value?.(early)
+  await startPromise
+
+  assert.equal(streams.length, 1)
+  assert.equal(streams[0].opts.sampleRate, 48000, 'handshake must use the recorder sample rate')
+  assert.equal(streams[0].chunks[0], early, 'PCM produced before the stream existed must still be first')
+}
+
+// ---- 2. finishRecording:先取 WAV 再 stop;ack 后立刻前进,不等分数 ----
+{
+  const clock = makeClock()
+  const { engine, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  const finishing = engine.finishRecording()
+  await flush()
+  assert.equal(streams[0].stopped, true, 'stop must be sent after the WAV blob is taken')
+  assert.equal(engine.recordingState.value, 'processing')
+
+  streams[0].resolveAck({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
+  await finishing
+
+  assert.equal(engine.currentParagraphIdx.value, 1, 'must advance on ack without waiting for scores')
+  assert.ok(engine.localAudioByParagraph.value.get(0), 'own recording must be replayable immediately')
+}
+
+// ---- 3. 单段 180s 只停当前段;练习计时归零才 complete ----
+{
+  const clock = makeClock()
+  const { engine, recorder, streams, calls } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  recorder.recordingDuration.value = 180
+  await flush()
+  streams[0].resolveAck?.({ type: 'ack', status: 'generating', attempt: 1, truncated: false })
+  await flush()
+  assert.equal(calls.complete, 0, '180s paragraph cap must not complete the whole practice')
+}
+// ---- 3a. 计时器按时间戳算:后台标签页节流也不会少算(spec §9.2.3)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()  // 10 分钟后
+  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.elapsedPracticeSeconds.value, 0, 'count-up timer starts at zero')
+
+  // 墙上时钟走了 300 秒,但 interval 只被浏览器放行了一次
+  clock.advance(300)
+  await clock.tickInterval(1)
+  assert.equal(engine.elapsedPracticeSeconds.value, 300,
+    'tick accumulation would say 1; only a timestamp reading says 300')
+}
+
+// ---- 3b. 到点:冻结在上限,且绝不发 complete ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, calls } = setup({ clock, expiresAt, durationMinutes: 10 })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)               // 越过截止时刻
+  await clock.tickInterval(1)
+  assert.equal(engine.elapsedPracticeSeconds.value, 600, 'must freeze at the cap, not overshoot')
+  assert.equal(calls.complete, 0, 'the deadline belongs to the backend; the FE never completes')
+}
+
+// ---- 3c. 到点只问一次后端,不重复 ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, calls } = setup({
+    clock, expiresAt, durationMinutes: 10,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  const before = calls.getReport
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, before + 1, 'exactly one query at the local deadline')
+
+  await clock.tickInterval(3)
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, before + 1, 'and only one — the interval is already stopped')
+}
+
+// ---- 3d. 没有截止时刻(不限时):不起 interval,不显示计时 ----
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, expiresAt: null })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.elapsedPracticeSeconds.value, null, 'no deadline means no timer to show')
+  assert.equal(clock.intervals.size, 0, 'no interval may be started without a deadline')
+}
+
+// ---- 3e. 后端单方面结束,showReport 开:转结果页流程(spec §9.2.3 第 2 条)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, completed } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: true,
+    getReport: async () => reportOf(['completed', 'completed'], 'generating', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.equal(engine.stage.value, 'report-loading', 'a server-side end must open the report gate')
+  assert.deepEqual(completed, ['sess-1'], 'the teacher must still get the broadcast')
+}
+
+// ---- 3f. 后端单方面结束,showReport 关:留在练习页 + 合上只读锁(§9.2.1)----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: false,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.equal(engine.stage.value, 'reading', 'hidden reports keep the student on the practice page')
+  assert.equal(engine.sessionEnded.value, true, 'the read-only lock must engage')
+  assert.equal(engine.error.value, null, 'a server-side end is not an error')
+}
+
+// ---- 3g. 本地已 complete 过:后端的结束信号不得再广播一次 ----
+{
+  const clock = makeClock()
+  const expiresAt = new Date(clock.now() + 600_000).toISOString()
+  const { engine, completed } = setup({
+    clock, expiresAt, durationMinutes: 10, showReport: true,
+    getReport: async () => reportOf(['completed', 'completed'], 'completed', { expiresAt, status: 'completed' }),
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+
+  clock.advance(700)
+  await clock.tickInterval(1)
+  await clock.runTimeouts()
+
+  assert.deepEqual(completed, ['sess-1'], 'exactly one completion broadcast')
+}
+
+// ---- 3h. resume 重启显示计时器,算出的是真实已用时长 ----
+{
+  const clock = makeClock()
+  // 截止时刻在 4 分钟后,配置 10 分钟 → 已经过了 6 分钟
+  const expiresAt = new Date(clock.now() + 240_000).toISOString()
+  const { engine } = setup({ clock, expiresAt, durationMinutes: 10 })
+
+  engine.resumeFromReport(reportOf(['pending', 'pending'], null, { expiresAt }))
+  assert.equal(engine.elapsedPracticeSeconds.value, 360,
+    'resume must show real elapsed time, not reset to zero')
+}
+
+// ---- 4. 后台标签页不发 GET;回到前台继续;退避不因隐藏而推进 ----
+{
+  const clock = makeClock()
+  const doc = makeDoc(true)
+  const { engine, calls } = setup({ clock, doc, statuses: ['generating'], reports: [reportOf(['generating'])] })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(calls.getReport, 0, 'hidden tab must not issue GET')
+
+  doc.hidden = false
+  doc.fire('visibilitychange')
+  await clock.runTimeouts()
+  assert.ok(calls.getReport >= 1, 'returning to foreground must resume polling')
+}
+{
+  // 退避序列 1s/2s/4s/8s 封顶
+  assert.equal(Math.min(8000, 1000 * 2 ** 0), 1000)
+  assert.equal(Math.min(8000, 1000 * 2 ** 5), 8000)
+}
+
+// ---- 5. overallStatus=null 且无 generating 段可进 report;ready 前保持 report-loading ----
+{
+  const clock = makeClock()
+  const { engine } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['generating'], null), reportOf(['completed'], null)],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report-loading', 'must not reveal a partial report while generating')
+
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report')
+}
+
+// ---- 5b. 段落全 completed 但 overallStatus='generating'(真实 complete 窗口):
+//         必须持续轮询直到 overall 落定,不得卡在 report-loading ----
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [
+      reportOf(['completed'], 'generating'),
+      reportOf(['completed'], 'generating'),
+      reportOf(['completed'], 'completed'),
+    ],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report-loading', 'overallStatus=generating must keep the loading gate closed')
+
+  const midGets = calls.getReport
+  await clock.runTimeouts()
+  assert.ok(calls.getReport > midGets, 'must keep polling while overallStatus is still generating')
+
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report', 'report must appear once overallStatus settles to completed')
+}
+
+// ---- 6. 全段评完静默 complete 一次,stage 仍是 reading;随后看报告不再 POST ----
+{
+  const clock = makeClock()
+  const { engine, calls, completed } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['completed'], 'completed')],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+
+  const afterFirst = calls.complete
+  assert.equal(afterFirst, 1, 'completion must be issued exactly once')
+  assert.equal(completed.length, 1, 'onSessionCompleted must fire once')
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(calls.complete, 1, 'viewing the report again must not re-POST complete')
+}
+
+// ---- 7. POST 之前抓到的 report 不得满足最终 gate ----
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['completed'],
+    reports: [reportOf(['completed'], null)],
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  // 没有 generating 段就不启动轮询,所以此刻静默预热还没有机会跑
+  assert.equal(calls.complete, 0)
+  assert.equal(calls.getReport, 0, 'a report fetched before the POST cannot satisfy the final gate')
+
+  await engine.completeAndShowReport()
+  await clock.runTimeouts()
+  assert.equal(engine.stage.value, 'report')
+  assert.ok(calls.getReport >= 1, 'at least one GET must be issued after the completion POST')
+  assert.equal(calls.complete, 1, 'completion is issued exactly once')
+}
+
+// ---- 8. 取消录音:abort 流、回到 idle、不产生评分任务 ----
+{
+  const clock = makeClock()
+  const { engine, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+
+  await engine.cancelRecording()
+
+  assert.equal(streams[0].stopped, false, 'cancel must not send stop, so no scoring task is created')
+  assert.match(streams[0].aborted, /cancel/i)
+  assert.equal(engine.recordingState.value, 'idle')
+  assert.equal(engine.currentParagraphIdx.value, 0, 'cancel must not advance to the next paragraph')
+  assert.equal(engine.localAudioByParagraph.value.size, 0, 'a cancelled attempt leaves no replayable audio')
+}
+
+// ---- showReport=false:completed 的 session 不进结果页,留在练习页 ----
+{
+  const { engine } = setup({ showReport: false })
+  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
+  assert.equal(engine.stage.value, 'reading', 'showReport 关闭时 completed 也留在 reading')
+  assert.equal(engine.sessionEnded.value, true, 'sessionEnded 仍为 true —— 只读锁靠它')
+}
+
+// ---- showReport=true:completed 的 session 照旧进结果页 ----
+{
+  const { engine } = setup({ showReport: true })
+  engine.resumeFromReport({ ...reportOf(['completed'], 'completed'), status: 'completed' })
+  assert.equal(engine.stage.value, 'report', 'showReport 打开时行为不变')
+}
+
+// ---- restartSession:先 abandon 再回起始页 ----
+{
+  const abandoned = []
+  const { engine } = setup({
+    abandonSession: async id => { abandoned.push(id); return { sessionId: id, status: 'abandoned' } },
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  assert.equal(engine.stage.value, 'reading')
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, true)
+  assert.deepEqual(abandoned, ['sess-1'], '把旧 session 送去 abandon')
+  assert.equal(engine.stage.value, 'ready', '回起始页')
+  assert.equal(engine.report.value, null, 'report 已清空')
+}
+
+// ---- restartSession 失败:不重置,返回 false,旧 session 仍然有效 ----
+{
+  const { engine } = setup({
+    abandonSession: async () => { throw new Error('network down') },
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, false)
+  assert.equal(engine.stage.value, 'reading', '失败不能把学生踢回起始页')
+  assert.equal(engine.report.value?.sessionId, 'sess-1', '旧 session 仍在')
+  assert.equal(engine.error.value, null, '失败只回 false,文案由调用方决定')
+}
+
+// ---- 录音中「重新开始」:录音器必须真的拆掉 ----
+// 只摘 onChunk 不够:mediaStream track、AudioContext、worklet、时长 interval 都还活着,
+// 孤儿 worklet 会继续把音频灌进下一段录音(上传的 WAV 与 WS 流都成了两套采集图交织)。
+{
+  const clock = makeClock()
+  const { engine, recorder, streams } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.startRecording()
+  assert.equal(recorder.cleaned, 0, '录音进行中,还没到拆的时候')
+
+  const ok = await engine.restartSession()
+  assert.equal(ok, true)
+  assert.equal(recorder.cleaned, 1, '重新开始必须拆掉录音器,不能只摘 onChunk')
+  assert.equal(recorder.onChunk.value, null, 'onChunk 也要摘')
+  assert.ok(streams[0].aborted, 'WS 流一并中止')
+  assert.equal(engine.stage.value, 'ready')
+}
+
+// ---- 「重新开始」成功后广播作废,老师端才会把「已完成」刷回「未开始」----
+{
+  const abandonedBroadcasts = []
+  const { engine } = setup({
+    onSessionAbandoned: id => abandonedBroadcasts.push(id),
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  await engine.restartSession()
+  assert.deepEqual(abandonedBroadcasts, ['sess-1'], '成功作废要通知调用方去广播')
+}
+
+// ---- 作废失败不广播 ----
+{
+  const abandonedBroadcasts = []
+  const { engine } = setup({
+    abandonSession: async () => { throw new Error('network down') },
+    onSessionAbandoned: id => abandonedBroadcasts.push(id),
+  })
+  engine.resumeFromReport(reportOf(['pending', 'pending']))
+  await engine.restartSession()
+  assert.deepEqual(abandonedBroadcasts, [], '没作废成功就不能报「这一轮不算了」')
+}
+
+console.log('article reading engine: all cases passed')
+
+// ---- 错误本地化:每个出口都是本地化文案,绝不是响应体(spec §9.2.5 A1)----
+
+const LEAK = 'Session not found'
+
+// 1. startSession
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, createSession: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  assert.equal(engine.error.value, '__localized_session_not_found__',
+    'a failed start must not show the backend detail')
+}
+
+// 5. pollOnce
+{
+  const clock = makeClock()
+  const { engine } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error(LEAK) },
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 6. playDemoAudio
+{
+  const clock = makeClock()
+  // 必须经 setup 注入:引擎把 overrides 展开进一个**新对象**,
+  // 构造之后再改外面那个 deps 是改不到它的。
+  const { engine } = setup({ clock, getDemoAudio: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.playDemoAudio(0)
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 4. completeAndShowReport
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, completeSession: async () => { throw new Error(LEAK) } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  assert.equal(engine.error.value, '__localized_session_not_found__')
+}
+
+// 2. startRecording(麦克风;不是后端 detail → 走兜底)
+{
+  const clock = makeClock()
+  const { engine, recorder } = setup({ clock })
+  await engine.startSession()
+  await clock.runTimeouts()
+  recorder.startRecording = async () => { throw new Error('NotAllowedError') }
+  await engine.startRecording()
+  assert.equal(engine.error.value, '__localized_generic__',
+    'an unmapped cause must fall back, never surface raw')
+}
+
+// 409 的分支逻辑保留(spec §9.2.5 第 4 条),同时文案也本地化
+{
+  const clock = makeClock()
+  const gone = Object.assign(new Error('Session is abandoned'), { status: 409 })
+  const { engine } = setup({ clock, completeSession: async () => { throw gone } })
+  await engine.startSession()
+  await clock.runTimeouts()
+  await engine.completeAndShowReport()
+  assert.equal(engine.sessionInvalid.value, true, '409 must still flag sessionInvalid')
+  assert.ok(!String(engine.error.value).includes('{'), 'never any JSON in user-facing copy')
+}
+
+console.log('article reading engine: error localization cases passed')
+
+// ---- 轮询失败上限(spec §9.2.5 待办 Unsure-1)----
+
+// 后端持续不应答 → 敲满上限就停手,不再每 8 秒一次敲到学生关页面
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error('boom') },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+
+  assert.equal(calls.getReport, 8, 'must give up after MAX_POLL_FAILURES consecutive failures')
+  assert.equal(clock.timeouts.size, 0, 'no timer may be left armed after giving up')
+  assert.ok(engine.error.value, 'the banner must stay so the student knows why it stopped')
+}
+
+// 中途成功一次 → 失败计数归零,健康的长轮询不会被误掐
+{
+  const clock = makeClock()
+  let n = 0
+  const clockRef = clock
+  const { engine, calls } = setup({
+    clock: clockRef,
+    statuses: ['generating'],
+    getReport: async () => {
+      n += 1
+      // 前 5 次失败,第 6 次成功(仍 generating),之后继续失败
+      if (n === 6) return reportOf(['generating'])
+      throw new Error('boom')
+    },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 40; i++) await clock.runTimeouts()
+
+  // 5 次失败 + 1 次成功(归零)+ 8 次失败 = 14
+  assert.equal(calls.getReport, 14, 'a successful response must reset the failure budget')
+}
+
+// 停手之后,新一轮(schedulePoll(0))要拿到新的失败预算
+{
+  const clock = makeClock()
+  const { engine, calls } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => { throw new Error('boom') },
+  })
+  await engine.startSession()
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+  assert.equal(calls.getReport, 8)
+
+  // 学生做了点什么(录完一段 / 点重试)→ 重新起一轮
+  engine.resumeFromReport(reportOf(['generating']))
+  for (let i = 0; i < 30; i++) await clock.runTimeouts()
+  assert.equal(calls.getReport, 16, 'a fresh chain must get a fresh budget, not stop on failure #1')
+}
+
+console.log('article reading engine: poll failure cap cases passed')
+
+// ---- 轮询横幅不滞留(spec §9.2.5 待办 Unsure-3)----
+
+// 失败一次 → 横幅出现;下一次成功 → 横幅收走,不留在学生眼前
+{
+  const clock = makeClock()
+  let n = 0
+  const { engine } = setup({
+    clock,
+    statuses: ['generating'],
+    getReport: async () => {
+      n += 1
+      if (n === 1) throw new Error('boom')
+      return reportOf(['generating'])
+    },
+  })
+  await engine.startSession()
+  await clock.runTimeouts()
+  assert.ok(engine.error.value, 'a transient poll failure must raise the banner')
+
+  await clock.runTimeouts()
+  assert.equal(engine.error.value, null, 'a later successful poll must take its own banner down')
+}
+
+// 但**只**收自己竖的那面:录音被拒的解释必须活过随后那次成功轮询。
+// finishRecording 的 catch 设完 error 就 schedulePoll(0)(§4.4 到期收口),
+// 无差别清 error 会让学生在唯读锁合上的同一瞬间失去「为什么录不了」的唯一说明。
+{
+  const clock = makeClock()
+  const { engine } = setup({ clock, getReport: async () => reportOf(['pending', 'pending']) })
+  await engine.startSession()
+  await clock.runTimeouts()
+
+  engine.error.value = '__recording_rejected__'
+  await clock.runTimeouts()   // 成功轮询跑一轮
+  assert.equal(engine.error.value, '__recording_rejected__',
+    'a non-poll error must survive a successful poll')
+}
+
+console.log('article reading engine: stale banner cases passed')
+
+// ---- 编辑态预览:userId 原样透传给 createSession,null 不得被改写成空串(spec §4.5)----
+{
+  const clock = makeClock()
+  const seen = []
+  const { engine } = setup({
+    clock,
+    userId: null,
+    createSession: async (configId, uid) => {
+      seen.push([configId, uid])
+      return reportOf(['pending', 'pending'])
+    },
+  })
+  await engine.startSession()
+
+  assert.deepEqual(seen, [['c1', null]],
+    'editor preview must reach the backend as null, not "" — an empty string is a student named ""')
+}
+
+{
+  const clock = makeClock()
+  const seen = []
+  const { engine } = setup({
+    clock,
+    userId: 'u9',
+    createSession: async (configId, uid) => {
+      seen.push([configId, uid])
+      return reportOf(['pending', 'pending'])
+    },
+  })
+  await engine.startSession()
+
+  assert.deepEqual(seen, [['c1', 'u9']], 'a real student id must still be forwarded verbatim')
+}
+
+// ---- 双击「开始朗读」只发一个 POST /session ----
+// 后端 get-or-create 无锁、无唯一约束:两个并发请求会双双查不到既有 session 然后各插一行。
+// 编辑态预览(userId=null)必然如此 —— `_latest_session` 对空 user 早退返回 None(spec §4.5)。
+// 多出来那一行会算进 §4.3 B 的「N 名学生正在练习中」直到过期,还会多写一笔作业记录(§9.2.4)。
+{
+  const clock = makeClock()
+  let calls = 0
+  let release
+  const gate = new Promise(resolve => { release = resolve })
+  const { engine } = setup({
+    clock,
+    userId: null,
+    createSession: async () => {
+      calls += 1
+      await gate
+      return reportOf(['pending', 'pending'])
+    },
+  })
+
+  const first = engine.startSession()
+  await flush()
+  assert.equal(engine.starting.value, true, 'POST 在途时 starting 必须为真,视图据此置灰')
+
+  const second = engine.startSession()   // 第二次点击:必须被闩早退吃掉
+  release()
+  await Promise.all([first, second])
+
+  assert.equal(calls, 1, 'double click must not create a second session')
+  assert.equal(engine.starting.value, false, '结束后闩必须放开,失败重试才点得动')
+}
+
+// ---- 开始失败后闩要放开:学生还能再点一次(承 §9.2.5 A2 的 readyError 出口)----
+{
+  const clock = makeClock()
+  let calls = 0
+  const { engine } = setup({
+    clock,
+    createSession: async () => {
+      calls += 1
+      if (calls === 1) throw new Error('Config not found')
+      return reportOf(['pending', 'pending'])
+    },
+  })
+
+  await engine.startSession()
+  assert.equal(engine.stage.value, 'ready', '失败停在起始页')
+  assert.ok(engine.error.value, '失败必须有文案')
+  assert.equal(engine.starting.value, false, 'finally 放闩,不是只在成功路径上放')
+
+  await engine.startSession()
+  assert.equal(calls, 2, '第二次点击必须真的发出去')
+  assert.equal(engine.stage.value, 'reading')
+}

+ 79 - 0
scripts/test-article-reading-i18n.mjs

@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict'
+import { readFile, readdir } from 'node:fs/promises'
+import path from 'node:path'
+
+const root = new URL('../', import.meta.url)
+const read = p => readFile(new URL(p, root), 'utf8')
+
+const locales = {}
+for (const loc of ['cn', 'en', 'hk']) {
+  locales[loc] = JSON.parse(await read(`src/views/lang/${loc}.json`))
+}
+
+// 扫描源码中真正引用到的 ssArticle* 键,而不是维护一张会过期的手写清单
+const scanRoots = [
+  'src/views/Editor/EnglishSpeaking',
+  'src/views/Student/components/ArticleReadingClassPanel',
+  'src/views/components/element/FrameElement',
+  'src/views/Editor/CanvasTool',
+  'src/components/CollapsibleToolbar',
+  'src/views/Student',
+]
+
+async function collectFiles(dir) {
+  const out = []
+  let entries
+  try {
+    entries = await readdir(new URL(dir + '/', root), { withFileTypes: true })
+  } catch {
+    return out
+  }
+  for (const entry of entries) {
+    const child = path.posix.join(dir, entry.name)
+    if (entry.isDirectory()) out.push(...await collectFiles(child))
+    else if (/\.(vue|ts)$/.test(entry.name)) out.push(child)
+  }
+  return out
+}
+
+const files = (await Promise.all(scanRoots.map(collectFiles))).flat()
+const used = new Set()
+for (const file of files) {
+  const source = await read(file)
+  for (const match of source.matchAll(/\blang\.(ssArticle[A-Za-z0-9]*)/g)) used.add(match[1])
+}
+
+assert.ok(used.size > 20, `expected many article keys in use, found ${used.size}`)
+
+// 每个被引用的键在三个语言包里都必须存在、非空、无首尾空白
+const missing = []
+for (const key of [...used].sort()) {
+  for (const loc of ['cn', 'en', 'hk']) {
+    const value = locales[loc][key]
+    if (typeof value !== 'string' || value.length === 0) {
+      missing.push(`${loc}.json missing ${key}`)
+      continue
+    }
+    assert.equal(value, value.trim(), `${loc}.json ${key} has surrounding whitespace`)
+  }
+}
+assert.deepEqual(missing, [], `unresolved article i18n keys:\n${missing.join('\n')}`)
+
+// 三个语言包的 ssArticle* 键集合必须完全一致,防止某个语言漏翻
+const keySets = Object.fromEntries(
+  Object.entries(locales).map(([loc, pack]) => [
+    loc,
+    Object.keys(pack).filter(key => key.startsWith('ssArticle')).sort(),
+  ]),
+)
+assert.deepEqual(keySets.en, keySets.cn, 'en.json article keys differ from cn.json')
+assert.deepEqual(keySets.hk, keySets.cn, 'hk.json article keys differ from cn.json')
+
+// en 与 cn 的文案不应逐字相同(除少数本就一致的符号型文案)
+const identical = keySets.cn.filter(key => locales.en[key] === locales.cn[key])
+assert.ok(
+  identical.length <= 2,
+  `en.json looks untranslated for: ${identical.join(', ')}`,
+)
+
+console.log(`article reading i18n: ${used.size} keys used, ${keySets.cn.length} defined, all three locales consistent`)

+ 52 - 0
scripts/test-article-reading-model.mjs

@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const unitId = 'SHEP-5-上-Unit2'
+const topics = JSON.parse(
+  await readFile(new URL('../src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json', import.meta.url), 'utf8'),
+)
+const articles = JSON.parse(
+  await readFile(new URL('../src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json', import.meta.url), 'utf8'),
+)
+const article = articles[unitId][0]
+
+// 模型的所有导入都是 `import type`,转译后被抹除,因此模块自足、可直接在 Node 里跑。
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts', import.meta.url)
+const source = await readFile(sourceUrl, 'utf8')
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
+}).outputText
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}#${Math.random()}`)
+
+const empty = mod.buildEmptyArticleConfig('五年级')
+assert.deepEqual(empty, {
+  type: 'article',
+  grade: '五年级',
+  title: '',
+  content: '',
+  practice: { mode: 'time', duration: 10 },
+  evaluation: { showReport: true },
+})
+
+const fromTask = mod.buildArticleConfig(article, '五年级')
+assert.equal(fromTask.type, 'article')
+assert.equal(fromTask.grade, '五年级')
+assert.equal(fromTask.title, 'A Day at the Zoo')
+assert.equal(fromTask.content, article.content)
+
+assert.equal(mod.scoreToStars(0), 0)
+assert.equal(mod.scoreToStars(79), 3.95)
+assert.equal(mod.scoreToStars(100), 5)
+
+const allTasks = [...topics[unitId], article]
+assert.equal(mod.filterSpeakingTasks(allTasks, 'all').length, 5)
+assert.equal(mod.filterSpeakingTasks(allTasks, 'topic-discussion').length, 4)
+assert.equal(mod.filterSpeakingTasks(allTasks, 'article-reading').length, 1)
+
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'generating' }], overallStatus: null }), false)
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'completed' }], overallStatus: null }), true)
+assert.equal(mod.isFinalReportReady({ paragraphs: [{ status: 'failed' }], overallStatus: 'failed' }), true)
+
+assert.equal(mod.pollDelayMs(0), 1000)
+assert.equal(mod.pollDelayMs(5), 8000)

+ 137 - 0
scripts/test-article-reading-routing.mjs

@@ -0,0 +1,137 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+
+const read = path => readFile(new URL(`../${path}`, import.meta.url), 'utf8')
+
+const frameEditor = await read('src/views/components/element/FrameElement/index.vue')
+const frameBase = await read('src/views/components/element/FrameElement/BaseFrameElement.vue')
+const operate = await read('src/views/Editor/Canvas/Operate/index.vue')
+const canvasTool = await read('src/views/Editor/CanvasTool/index2.vue')
+const canvas = await read('src/views/Editor/Canvas/index.vue')
+const toolbars = await Promise.all([
+  read('src/components/CollapsibleToolbar/index.vue'),
+  read('src/components/CollapsibleToolbar/index2.vue'),
+  read('src/components/CollapsibleToolbar/index22.vue'),
+])
+
+// 编辑态与只读/学生态都必须把 772 路由到文章预览
+assert.match(frameEditor, /ArticleReadingPreview/)
+assert.match(frameEditor, /isArticleReadingTool/)
+assert.match(frameBase, /ArticleReadingPreview/)
+assert.match(frameBase, /isArticleReadingTool/)
+assert.match(frameBase, /ARTICLE_READING_TOOL_TYPE/)
+
+// 锁定、重置、滚轮放行
+assert.match(operate, /isArticleReadingTool/)
+assert.match(canvasTool, /ARTICLE_READING_TOOL_TYPE/)
+assert.match(canvasTool, /articleStore\.requestResetPreview/)
+assert.match(canvas, /article-reading-preview/)
+
+// 三个工具栏名称映射
+for (const toolbar of toolbars) {
+  assert.match(toolbar, /ARTICLE_READING_TOOL_TYPE/)
+  assert.match(toolbar, /ssArticleReadingTool/)
+}
+
+// 编号只能来自命名常量:代码里不得出现 772 字面量(注释中说明编号是允许的)
+function stripComments(source) {
+  return source
+    .replace(/<!--[\s\S]*?-->/g, '')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+    .replace(/(^|[^:])\/\/.*$/gm, '$1')
+}
+
+const codeFiles = {
+  frameEditor, frameBase, operate, canvasTool, canvas,
+  toolbar0: toolbars[0], toolbar1: toolbars[1], toolbar2: toolbars[2],
+}
+for (const [name, source] of Object.entries(codeFiles)) {
+  assert.doesNotMatch(
+    stripComments(source),
+    /\b772\b/,
+    `${name} must reference ARTICLE_READING_TOOL_TYPE, not the literal 772`,
+  )
+}
+
+// 话题讨论与句子跟读的既有编号不得被改写
+assert.match(frameBase, /77: 'ssEnglishSpeakingTool'/)
+assert.match(frameBase, /81: 'ssH5Page'/)
+
+// 文章分支不得回落到话题讨论组件
+const articleBranch = frameEditor.slice(frameEditor.indexOf('ArticleReadingPreview'))
+assert.doesNotMatch(articleBranch.slice(0, 200), /TopicDiscussionPreview/)
+
+// 老师端 Student runtime 按工具类型分流
+const studentView = await read('src/views/Student/index.vue')
+const articleClassComposable = await read(
+  'src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts',
+)
+
+assert.match(studentView, /ArticleReadingClassPanel/)
+assert.match(studentView, /ARTICLE_READING_TOOL_TYPE/)
+assert.match(studentView, /TOPIC_DISCUSSION_TOOL_TYPE/)
+assert.match(articleClassComposable, /listArticleSessionsByConfig/)
+assert.match(articleClassComposable, /generateArticleClassSummary/)
+
+// 作业记录必须带真实工具号,不能写死 77
+assert.match(studentView, /atool: String\(activityToolType\)/)
+assert.doesNotMatch(studentView, /atool: '77'/)
+
+// 通用结果弹窗/提交按钮对文章与话题一视同仁地抑制
+assert.doesNotMatch(studentView, /currentSlideToolType !== 77/)
+assert.match(studentView, /articleReadingPanelRef\.value\?\.scheduleRefetch/)
+
+// 三个语言包都要有工具名与班级面板文案
+for (const loc of ['cn', 'en', 'hk']) {
+  const pack = JSON.parse(await read(`src/views/lang/${loc}.json`))
+  for (const key of [
+    'ssArticleReadingTool', 'ssArticleReading', 'ssArticleClassSummary',
+    'ssArticleFilterCompleted', 'ssArticleFilterReading',
+  ]) {
+    assert.ok(pack[key], `${loc}.json is missing ${key}`)
+  }
+}
+
+// ---- 编辑态老师预览不进统计(spec §4.5):三道门都要在 ArticleReadingPreview 里 ----
+const articlePreview = await read('src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue')
+
+// 门 1:runtime 判定不能只看 userid —— 编辑器 URL 里也有 userid。
+// 身份判据用项目既有的 type(Student/index.vue:625:老师端 1 / 学生端 2)。
+assert.match(articlePreview, /isStudentRuntime/)
+assert.match(articlePreview, /params\.get\('type'\)/)
+assert.match(articlePreview, /role === '2'/)
+
+// 门 2:编辑态把 null 传给引擎,不是空串
+assert.match(articlePreview, /sessionUserId/)
+
+// 门 3:resume 探针与进度广播只在学生态跑
+assert.match(articlePreview, /if \(!isStudentRuntime\.value\) return/)
+
+// 反向:不得再出现「只要有 userid 就当学生」的旧判据
+assert.doesNotMatch(articlePreview, /startDisabled = computed\(\(\) => !hasArticle\.value \|\| !runtimeUserId\.value\)/)
+
+// 反向:不得拿 mode 当身份 —— mode=student 是视图,老师上课时也在这个视图里,
+// 用它会把上课页的老师当成学生(编辑态那个 bug 换个地方复发),还得再去和
+// App.vue 的 localStorage viewMode 对账。type 是身份,与渲染哪个视图无关。
+assert.doesNotMatch(articlePreview, /'student'/)
+assert.doesNotMatch(articlePreview, /currentViewMode/)
+assert.doesNotMatch(await read('src/App.vue'), /currentViewMode/)
+
+// ---- 报告的词级形状必须跟住后端契约(spec §6):段落下面是**扁平的 words**。
+// 后端 c62d35a「flatten scoring/report to paragraph→words」把句层删了,前端的类型和
+// 取值路径没跟着改,于是任何一张已完成的段落卡片一展开就 `paragraph.sentences` 是
+// undefined、`.flatMap` 抛异常。类型说了谎,所以 type-check 也发现不了 —— 只能在这里钉。
+const articleTypes = await read('src/types/articleReading.ts')
+assert.match(articleTypes, /words: ArticleWordResult\[\]/)
+assert.doesNotMatch(articleTypes, /sentences/)
+assert.doesNotMatch(articleTypes, /ArticleSentenceResult/)
+
+const paragraphCard = await read('src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue')
+assert.match(paragraphCard, /props\.paragraph\.words/)
+assert.doesNotMatch(paragraphCard, /flattenParagraphWords/)
+assert.doesNotMatch(
+  await read('src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts'),
+  /sentences/,
+)
+
+console.log('article reading routing: all assertions passed')

+ 114 - 0
scripts/test-article-reading-stream.mjs

@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleReadingStream.ts', import.meta.url)
+let source = await readFile(sourceUrl, 'utf8')
+
+source = source.replace(
+  "import { buildArticleWsUrl } from './speakingApiConfig'",
+  "const buildArticleWsUrl = path => `wss://example.test/api/speaking/article${path}`",
+)
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+class FakeWebSocket {
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+  static instances = []
+
+  readyState = FakeWebSocket.CONNECTING
+  binaryType = ''
+  sent = []
+  onopen = null
+  onmessage = null
+  onerror = null
+  onclose = null
+
+  constructor(url) {
+    this.url = url
+    FakeWebSocket.instances.push(this)
+  }
+
+  send(payload) {
+    this.sent.push(payload)
+  }
+
+  close() {
+    this.readyState = FakeWebSocket.CLOSED
+    this.onclose?.({})
+  }
+}
+
+globalThis.WebSocket = FakeWebSocket
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---- 1. 正常时序:start → PCM → stop → ack ----
+// chunk 先于 open 抵达,必须排队而不是丢弃(录音器早于 socket 就绪是常态)
+const stream = mod.openArticleParagraphStream({
+  sessionId: 's1',
+  paragraphIdx: 2,
+  sampleRate: 48000,
+})
+const firstChunk = new Uint8Array([1, 2, 3, 4]).buffer
+stream.pushChunk(firstChunk)
+const ackPromise = stream.stop()
+
+const ws = FakeWebSocket.instances[0]
+assert.equal(ws.url, 'wss://example.test/api/speaking/article/session/s1/paragraph/2/stream')
+ws.readyState = FakeWebSocket.OPEN
+ws.onopen()
+
+assert.deepEqual(JSON.parse(ws.sent[0]), {
+  type: 'start', sampleRate: 48000, bits: 16, channels: 1,
+})
+assert.equal(ws.sent[1], firstChunk)
+assert.deepEqual(JSON.parse(ws.sent[2]), { type: 'stop' })
+
+ws.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
+assert.deepEqual(await ackPromise, {
+  type: 'ack', status: 'generating', attempt: 1, truncated: false,
+})
+
+// ---- 2. error frame 以其 message reject ----
+const errStream = mod.openArticleParagraphStream({ sessionId: 's2', paragraphIdx: 0, sampleRate: 16000 })
+const errAck = errStream.stop()
+const errWs = FakeWebSocket.instances[1]
+errWs.readyState = FakeWebSocket.OPEN
+errWs.onopen()
+errWs.onmessage({ data: JSON.stringify({ type: 'error', message: 'assessment failed' }) })
+await assert.rejects(errAck, /assessment failed/)
+
+// ---- 3. abort() 关闭连接并 reject 未决的 stop ----
+const abortStream = mod.openArticleParagraphStream({ sessionId: 's3', paragraphIdx: 1, sampleRate: 16000 })
+const abortWs = FakeWebSocket.instances[2]
+abortWs.readyState = FakeWebSocket.OPEN
+abortWs.onopen()
+const abortAck = abortStream.stop()
+abortStream.abort('user left the paragraph')
+assert.equal(abortWs.readyState, FakeWebSocket.CLOSED)
+await assert.rejects(abortAck, /user left the paragraph/)
+
+// ---- 4. 连接在 ack 之前关闭必须 reject,不能静默挂起 ----
+const deadStream = mod.openArticleParagraphStream({ sessionId: 's4', paragraphIdx: 0, sampleRate: 16000 })
+const deadWs = FakeWebSocket.instances[3]
+deadWs.readyState = FakeWebSocket.OPEN
+deadWs.onopen()
+const deadAck = deadStream.stop()
+deadWs.close()
+await assert.rejects(deadAck, /closed before acknowledgement/)
+
+// ---- 5. stop 之后再推 PCM 是编程错误 ----
+const lateStream = mod.openArticleParagraphStream({ sessionId: 's5', paragraphIdx: 0, sampleRate: 16000 })
+const lateWs = FakeWebSocket.instances[4]
+lateWs.readyState = FakeWebSocket.OPEN
+lateWs.onopen()
+const lateAck = lateStream.stop()
+assert.throws(() => lateStream.pushChunk(new Uint8Array([9]).buffer), /Cannot push PCM after stop/)
+lateWs.onmessage({ data: JSON.stringify({ type: 'ack', status: 'generating', attempt: 1, truncated: false }) })
+await lateAck

+ 116 - 0
scripts/test-article-recorder-sample-rate.mjs

@@ -0,0 +1,116 @@
+/**
+ * useAudioRecorder 的采样率协商。
+ *
+ * 钉的是一件事:**上报的永远是实际拿到的采样率,不是我们要的那个**。
+ * 这条不变量一破,后端就会按 16kHz 去解 48kHz 的数据 —— 回放慢三倍、评分全错,
+ * 而整条链路没有任何一层会报错。三种浏览器行为都要走一遍。
+ */
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import ts from 'typescript'
+
+const sourceUrl = new URL(
+  '../src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts',
+  import.meta.url,
+)
+let source = await readFile(sourceUrl, 'utf8')
+
+// Vue runtime → 本地 shim(同 test-article-reading-engine.mjs 的做法)
+source = source.replace(
+  "import { ref, onUnmounted } from 'vue'",
+  `const ref = v => ({ value: v })
+   const onUnmounted = () => {}`,
+)
+
+const compiled = ts.transpileModule(source, {
+  compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
+}).outputText
+
+const mod = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
+
+// ---- 浏览器环境替身 ----
+
+const HARDWARE_RATE = 48000
+
+function installEnvironment({ audioContextFactory }) {
+  const track = {
+    label: 'fake mic', enabled: true, muted: false, readyState: 'live',
+    getSettings: () => ({}), stop() { this.readyState = 'ended' },
+  }
+  // Node 22 的 globalThis.navigator 只有 getter,赋值会抛 —— 用 defineProperty 盖掉
+  Object.defineProperty(globalThis, 'navigator', {
+    configurable: true,
+    value: {
+      mediaDevices: { getUserMedia: async () => ({ getTracks: () => [track], getAudioTracks: () => [track] }) },
+      permissions: { query: async () => { throw new Error('unsupported') } },
+    },
+  })
+  globalThis.AudioContext = audioContextFactory
+  globalThis.AudioWorkletNode = class {
+    constructor() { this.port = { onmessage: null, postMessage() {} } }
+    connect() {}
+    disconnect() {}
+  }
+  globalThis.DOMException = globalThis.DOMException ?? class extends Error {}
+}
+
+/** 造一个 AudioContext 类,`resolveRate` 决定它对 sampleRate 提示的反应。 */
+function makeAudioContextClass(resolveRate) {
+  return class FakeAudioContext {
+    static requested = []
+    constructor(options) {
+      FakeAudioContext.requested.push(options?.sampleRate ?? null)
+      this.sampleRate = resolveRate(options?.sampleRate)   // 可以抛
+      this.state = 'running'
+      this.destination = {}
+      this.audioWorklet = { addModule: async () => {} }
+    }
+    createMediaStreamSource() { return { connect() {} } }
+    createAnalyser() {
+      return { fftSize: 0, frequencyBinCount: 256, connect() {}, getByteFrequencyData() {} }
+    }
+    async resume() { this.state = 'running' }
+    async close() { this.state = 'closed' }
+  }
+}
+
+async function startAndReadRate(AudioContextClass) {
+  installEnvironment({ audioContextFactory: AudioContextClass })
+  const recorder = mod.useAudioRecorder()
+  await recorder.startRecording()
+  const rate = recorder.sampleRate.value
+  recorder.cleanup()
+  return rate
+}
+
+// ---- 1. 浏览器支持:拿到 16kHz ----
+{
+  const Ctx = makeAudioContextClass(hint => hint ?? HARDWARE_RATE)
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, 16000, '支持自定义采样率时应该拿到 16kHz')
+  assert.deepEqual(Ctx.requested, [16000], '应该只构造一次,并且带着 16000 的提示')
+}
+
+// ---- 2. 浏览器拒绝(旧 iOS Safari):退回硬件默认,录音照常 ----
+{
+  const Ctx = makeAudioContextClass(hint => {
+    if (hint) throw new Error('NotSupportedError: sample rate not supported')
+    return HARDWARE_RATE
+  })
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, HARDWARE_RATE, '构造抛出时必须退回硬件默认,而不是整个录音失败')
+  assert.deepEqual(Ctx.requested, [16000, null], '先试 16000,抛了再试不带提示的')
+}
+
+// ---- 3. 浏览器**默默无视**提示:上报真值,不是我们要的值 ----
+// 1 是「照办」、2 是「明着拒绝」,这条是第三种行为:收下提示、返回硬件值、不抛。
+// 前两条都覆盖不到它,而它正是最坏的那种失败 —— 没有异常、没有日志,只是后端
+// 从此按 16kHz 去解 48kHz 的数据。
+{
+  const Ctx = makeAudioContextClass(() => HARDWARE_RATE)   // 提示照收,返回值不变
+  const rate = await startAndReadRate(Ctx)
+  assert.equal(rate, HARDWARE_RATE, '浏览器无视提示时必须上报实际采样率')
+  assert.notEqual(rate, 16000)
+}
+
+console.log('✓ test-article-recorder-sample-rate: 3 cases passed')

+ 15 - 0
scripts/test-speaking-api-config.mjs

@@ -62,3 +62,18 @@ assert.equal(
   custom.SPEAKING_TRANSPORT,
   'http',
 )
+
+assert.equal(
+  defaults.SPEAKING_ARTICLE_API_BASE_URL,
+  'https://ppt-english-speaking-api.cocorobo.cn/api/speaking/article',
+)
+
+assert.equal(
+  defaults.buildArticleWsUrl('/session/s1/paragraph/0/stream'),
+  'wss://ppt-english-speaking-api.cocorobo.cn/api/speaking/article/session/s1/paragraph/0/stream',
+)
+
+assert.equal(
+  custom.SPEAKING_ARTICLE_API_BASE_URL,
+  'https://example.com/api/speaking/article',
+)

+ 2 - 0
src/components/CollapsibleToolbar/index.vue

@@ -131,6 +131,7 @@ import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import { useSlidesStore } from '@/store'
 import { lang } from '@/main'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
 
 interface ContentItem {
   tool?: number
@@ -288,6 +289,7 @@ const getTypeLabel = (type?: number) => {
     75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
     76: lang.ssCreative,
     77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
   }

+ 21 - 7
src/components/CollapsibleToolbar/index2.vue

@@ -719,11 +719,13 @@ import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import { useSlidesStore, useMainStore } from '@/store'
 import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
 import FileInput from '@/components/FileInput.vue'
 import AiChat from './componets/aiChat.vue'
 import AiWeb from './componets/aiWeb.vue'
 import SpeakingPanel from '@/views/Editor/EnglishSpeaking/SpeakingPanel.vue'
 import { lang } from '@/main'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
 import toolChoice from '@/assets/img/tool_choice.jpeg'
 import toolAnswer from '@/assets/img/tool_answer.png'
 import toolVote from '@/assets/img/tool_vote.png'
@@ -1002,14 +1004,25 @@ const toggleSubmenu = (menu: string) => {
 
 // 监听"打开口语配置面板"信号 → 自动展开 english 子菜单
 const speakingStore = useSpeakingStore()
-watch(() => speakingStore.openConfigSignal, (val, old) => {
-  if (val !== old) {
-    if (isCollapsed.value) {
-      isCollapsed.value = false
-      emit('toggle', false)
-    }
-    activeSubmenu.value = 'english'
+const articleStore = useArticleReadingStore()
+
+function openEnglishSubmenu() {
+  if (isCollapsed.value) {
+    isCollapsed.value = false
+    emit('toggle', false)
   }
+  activeSubmenu.value = 'english'
+}
+
+watch(() => speakingStore.openConfigSignal, (val, old) => {
+  if (val !== old) openEnglishSubmenu()
+})
+
+// 文章朗读(772 型)有自己的 store 与信号,为的是两条配置页互不干扰 —— 但**抽屉是共用的**。
+// 少了这一条,点画布上的文章朗读预览只切了 SpeakingPanel 内部的 pageMode,侧栏还收着或停在
+// 别的子菜单,老师眼里就是「点了没反应」。
+watch(() => articleStore.openConfigSignal, (val, old) => {
+  if (val !== old) openEnglishSubmenu()
 })
 
 import titlePage from './page/TitlePage.json'
@@ -1175,6 +1188,7 @@ const getTypeLabel = (type?: number) => {
     75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
     76: lang.ssCreative,
     77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
   }

+ 2 - 0
src/components/CollapsibleToolbar/index22.vue

@@ -561,6 +561,7 @@ import FileInput from '@/components/FileInput.vue'
 import AiChat from './componets/aiChat.vue'
 import SpeakingPanel from '@/views/Editor/EnglishSpeaking/SpeakingPanel.vue'
 import { lang } from '@/main'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
 import toolChoice from '@/assets/img/tool_choice.jpeg'
 import toolAnswer from '@/assets/img/tool_answer.png'
 import toolVote from '@/assets/img/tool_vote.png'
@@ -978,6 +979,7 @@ const getTypeLabel = (type?: number) => {
     75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
     76: lang.ssCreative,
     77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
   }

+ 17 - 0
src/configs/englishSpeakingTools.ts

@@ -0,0 +1,17 @@
+/**
+ * 口语活动的画布工具编号(唯一来源)。
+ *
+ * 772 刻意跳出 72–81 这段顺序区间:82 是该区间的下一个空位,可能被并行开发的
+ * 其他功能占用。所有判定必须走这里的常量与函数,不得写字面量或范围比较。
+ */
+export const TOPIC_DISCUSSION_TOOL_TYPE = 77 as const
+export const SENTENCE_READING_TOOL_TYPE = 80 as const
+export const ARTICLE_READING_TOOL_TYPE = 772 as const
+
+export function isArticleReadingTool(type: unknown): boolean {
+  return Number(type) === ARTICLE_READING_TOOL_TYPE
+}
+
+export function isTopicDiscussionTool(type: unknown): boolean {
+  return Number(type) === TOPIC_DISCUSSION_TOOL_TYPE
+}

+ 21 - 15
src/services/speaking.ts

@@ -1,11 +1,15 @@
+import type { ArticleReadingConfig } from '@/types/articleReading'
 import type { TopicDiscussionConfig } from '@/types/englishSpeaking'
 import { SPEAKING_CONFIG_API_BASE_URL, SPEAKING_DIALOGUE_API_BASE_URL } from '@/views/Editor/EnglishSpeaking/services/speakingApiConfig'
 
 const API_BASE = SPEAKING_CONFIG_API_BASE_URL
 
-export interface SpeakingConfigRecord {
+/** 话题讨论与文章朗读共用同一套 config endpoint,靠 config.type 判别。 */
+export type SpeakingActivityConfig = TopicDiscussionConfig | ArticleReadingConfig
+
+export interface SpeakingConfigRecord<T extends SpeakingActivityConfig = SpeakingActivityConfig> {
   id: string
-  config: TopicDiscussionConfig
+  config: T
   ownerUid?: string | null
 }
 
@@ -17,36 +21,38 @@ async function parse<T>(res: Response): Promise<T> {
   return res.json() as Promise<T>
 }
 
-/** 新建话题讨论配置 */
-export async function createSpeakingConfig(
-  config: TopicDiscussionConfig,
+/** 新建口语配置(话题讨论或文章朗读) */
+export async function createSpeakingConfig<T extends SpeakingActivityConfig>(
+  config: T,
   ownerUid?: string | null,
-): Promise<SpeakingConfigRecord> {
+): Promise<SpeakingConfigRecord<T>> {
   const res = await fetch(API_BASE, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ config, ownerUid: ownerUid ?? null }),
   })
-  return parse<SpeakingConfigRecord>(res)
+  return parse<SpeakingConfigRecord<T>>(res)
 }
 
-/** 读取话题讨论配置 */
-export async function getSpeakingConfig(id: string): Promise<SpeakingConfigRecord> {
+/** 读取口语配置 */
+export async function getSpeakingConfig<T extends SpeakingActivityConfig = SpeakingActivityConfig>(
+  id: string,
+): Promise<SpeakingConfigRecord<T>> {
   const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`)
-  return parse<SpeakingConfigRecord>(res)
+  return parse<SpeakingConfigRecord<T>>(res)
 }
 
-/** 更新话题讨论配置 */
-export async function updateSpeakingConfig(
+/** 更新口语配置 */
+export async function updateSpeakingConfig<T extends SpeakingActivityConfig>(
   id: string,
-  config: TopicDiscussionConfig,
-): Promise<SpeakingConfigRecord> {
+  config: T,
+): Promise<SpeakingConfigRecord<T>> {
   const res = await fetch(`${API_BASE}/${encodeURIComponent(id)}`, {
     method: 'PUT',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ config }),
   })
-  return parse<SpeakingConfigRecord>(res)
+  return parse<SpeakingConfigRecord<T>>(res)
 }
 
 const DIALOGUE_BASE = SPEAKING_DIALOGUE_API_BASE_URL

+ 60 - 0
src/store/articleReading.ts

@@ -0,0 +1,60 @@
+import { defineStore } from 'pinia'
+import { reactive, ref } from 'vue'
+import type { ArticleReadingConfig, ArticleReadingTask } from '@/types/articleReading'
+import type { ArticlePreviewStage } from '@/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine'
+import { buildArticleConfig, buildEmptyArticleConfig } from '@/views/Editor/EnglishSpeaking/preview/articleReadingModel'
+
+/** 只持有文章朗读的表单状态,绝不混入话题讨论字段。 */
+export const useArticleReadingStore = defineStore('articleReading', () => {
+  const config = reactive<ArticleReadingConfig>(buildEmptyArticleConfig('五年级'))
+  const activeConfigId = ref('')
+  const openConfigSignal = ref(0)
+  const resetSignal = ref(0)
+  // 预览当前阶段(由 ArticleReadingPreview 同步)。CanvasTool 据此决定「重置预览」显不显示
+  // —— 与话题讨论的 speakingStore.previewState 同一套做法:停在起始页时没什么可重置的。
+  const previewStage = ref<ArticlePreviewStage>('ready')
+
+  function replaceConfig(next: ArticleReadingConfig) {
+    Object.assign(config, next)
+  }
+
+  function prefillFromTask(task: ArticleReadingTask, grade: string) {
+    replaceConfig(buildArticleConfig(task, grade))
+  }
+
+  function resetConfigEmpty(grade: string) {
+    replaceConfig(buildEmptyArticleConfig(grade))
+  }
+
+  function setActiveConfigId(configId: string) {
+    activeConfigId.value = configId
+  }
+
+  function setPreviewStage(stage: ArticlePreviewStage) {
+    previewStage.value = stage
+  }
+
+  function openConfigPanel(configId: string) {
+    activeConfigId.value = configId
+    openConfigSignal.value += 1
+  }
+
+  function requestResetPreview() {
+    resetSignal.value += 1
+  }
+
+  return {
+    config,
+    activeConfigId,
+    openConfigSignal,
+    resetSignal,
+    previewStage,
+    replaceConfig,
+    prefillFromTask,
+    resetConfigEmpty,
+    setActiveConfigId,
+    setPreviewStage,
+    openConfigPanel,
+    requestResetPreview,
+  }
+})

+ 112 - 0
src/types/articleReading.ts

@@ -0,0 +1,112 @@
+/**
+ * 文章朗读契约(对齐后端 spec §6)。
+ *
+ * 班级两个 DTO 的可空字段是 required-nullable 而非 optional:后端
+ * _collect_class_rows 对每个学生都发全部键,not_started 行发 null。
+ */
+export type ArticlePracticeMode = 'time' | 'unlimited'
+/**
+ * `abandoned` 是「重新开始」的标记(后端 spec §4.2)。「哪个 session」类查询一律排除它,
+ * 所以 resume 探针与 get-or-create 都不会返回它 —— 但 `GET /report` 原样回 `session.status`,
+ * 拿着旧 sessionId 直接查就会拿到,契约里必须写全三个值。
+ */
+export type ArticleSessionStatus = 'reading' | 'completed' | 'abandoned'
+export type ArticleParagraphStatus = 'pending' | 'generating' | 'completed' | 'failed'
+export type ArticleOverallStatus = 'generating' | 'completed' | 'failed' | null
+export type ArticleWordStatus = 'good' | 'warn' | 'bad' | 'misread' | 'omission' | 'insertion'
+
+export interface ArticleReadingConfig {
+  type: 'article'
+  grade: string
+  title: string
+  content: string
+  practice: { mode: ArticlePracticeMode; duration: number }
+  evaluation: { showReport: boolean }
+}
+
+export interface ArticleReadingTask {
+  id: string
+  taskType: 'article-reading'
+  titleZh: string
+  titleEn: string
+  subtitle: string
+  difficulty: number
+  count: 1
+  unit: '篇'
+  durationMinutes: number
+  content: string
+}
+
+export interface ArticleScoreDimensions {
+  accuracy: number
+  fluency: number
+  completeness: number
+  prosody: number
+}
+
+export interface ArticleWordResult {
+  word: string
+  status: ArticleWordStatus
+  accuracy: number | null
+}
+
+export interface ArticleParagraphResult {
+  idx: number
+  text: string
+  status: ArticleParagraphStatus
+  audioUrl: string | null
+  overallScore: number | null
+  dims: ArticleScoreDimensions | null
+  /** 段内扁平词级,按对齐顺序(spec §6)。句层已在后端 c62d35a 删除,别再加回来。 */
+  words: ArticleWordResult[]
+}
+
+export interface ArticleOverallResult {
+  aiComment: string
+  highlights: string[]
+  suggestions: string[]
+  overallScore: number
+  dims: ArticleScoreDimensions
+}
+
+export interface ArticleReadingReport {
+  sessionId: string
+  title: string
+  status: ArticleSessionStatus
+  totalDurationSeconds: number | null
+  /**
+   * 绝对截止时刻(ISO8601,后端 spec §4.4);null = 不限时。
+   * **只用来显示计时、以及决定本地时钟什么时候去问一次后端** —— 到了没由后端说了算。
+   */
+  expiresAt: string | null
+  paragraphs: ArticleParagraphResult[]
+  overall: ArticleOverallResult | null
+  overallStatus: ArticleOverallStatus
+}
+
+export interface ArticleStreamAck {
+  type: 'ack'
+  status: 'generating'
+  attempt: number
+  truncated: boolean
+}
+
+export interface ArticleClassUser {
+  userId: string
+  name: string
+}
+
+export interface ArticleClassSessionSummary extends ArticleClassUser {
+  status: 'completed' | 'reading' | 'not_started'
+  sessionId: string | null
+  overallScore: number | null
+  dims: ArticleScoreDimensions | null
+  completedAt: string | null
+}
+
+export interface ArticleClassSummaryResponse {
+  summary: string
+  generatedAt: string
+  fromCache: boolean
+  llmStatus: 'ok' | 'fallback'
+}

+ 7 - 1
src/types/englishSpeaking.ts

@@ -1,3 +1,5 @@
+import type { ArticleReadingTask } from '@/types/articleReading'
+
 // 练习方式
 export type PracticeMode = 'rounds' | 'time' | 'free'
 
@@ -60,6 +62,7 @@ export interface TopicDiscussionConfig {
 // 推荐任务卡片
 export interface TopicDiscussionTask {
   id: string
+  taskType: 'topic-discussion'
   titleZh: string
   titleEn: string
   subtitle: string
@@ -104,8 +107,11 @@ export interface CurriculumData {
 // 创建方式
 export type CreationMode = 'smart' | 'ai' | 'manual'
 
+// 推荐卡片的判别联合:按 taskType 区分,不把文章数据伪装成话题讨论
+export type SpeakingRecommendationTask = TopicDiscussionTask | ArticleReadingTask
+
 // 页面层级
-export type PageMode = 'layer1' | 'layer2' | 'config'
+export type PageMode = 'layer1' | 'layer2' | 'config' | 'config-article-reading'
 
 // 练习类型
 export type ExerciseType = 'speaking' | 'listening' | 'reading' | 'writing'

+ 7 - 5
src/views/Editor/Canvas/Operate/index.vue

@@ -51,6 +51,7 @@ import {
   type PPTChartElement,
 } from '@/types/slides'
 import type { OperateLineHandlers, OperateResizeHandlers } from '@/types/edit'
+import { isArticleReadingTool, isTopicDiscussionTool } from '@/configs/englishSpeakingTools'
 
 import ImageElementOperate from './ImageElementOperate.vue'
 import TextElementOperate from './TextElementOperate.vue'
@@ -104,12 +105,13 @@ const elementIndexListInAnimation = computed(() => {
 const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0)
 const height = computed(() => 'height' in props.elementInfo ? props.elementInfo.height : 0)
 
-// 英语口语预览(FRAME + toolType 77)产品设计上占满整页不可变形,
+// 英语口语预览(FRAME + toolType 77 / 772)产品设计上占满整页不可变形,
 // 强制隐藏 resize / rotate 把手,用户仍可选中+删除+开配置面板,但看不到任何"拖动感"。
-const isLockedInteractiveWidget = computed(() => (
-  props.elementInfo.type === ElementTypes.FRAME &&
-  Number((props.elementInfo as any).toolType) === 77
-))
+const isLockedInteractiveWidget = computed(() => {
+  if (props.elementInfo.type !== ElementTypes.FRAME) return false
+  const toolType = (props.elementInfo as any).toolType
+  return isTopicDiscussionTool(toolType) || isArticleReadingTool(toolType)
+})
 </script>
 
 <style lang="scss" scoped>

+ 3 - 3
src/views/Editor/Canvas/index.vue

@@ -393,11 +393,11 @@ const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing
 const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
 
 const handleMousewheelCanvas = (e: WheelEvent) => {
-  // type 77(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
-  // 报告的 .report-scroll、对话的 .chat-area),让内部 scroll 接管,canvas 不介入。
+// type 77 / 772(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
+  // 报告的 .report-scroll、对话的 .chat-area、文章的段落列表),让内部 scroll 接管,canvas 不介入。
   // 如果不这样做,下面的 preventDefault 会把浏览器默认滚动行为一并抑制,内部永远滚不动。
   const targetEl = e.target instanceof Element ? e.target : null
-  if (targetEl?.closest('.topic-discussion-preview')) return
+  if (targetEl?.closest('.topic-discussion-preview, .article-reading-preview')) return
 
   e.preventDefault()
 

+ 23 - 4
src/views/Editor/CanvasTool/index2.vue

@@ -239,6 +239,12 @@ import { ref, computed, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSnapshotStore, useSlidesStore } from '@/store'
 import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
+import {
+  ARTICLE_READING_TOOL_TYPE,
+  isArticleReadingTool,
+  isTopicDiscussionTool,
+} from '@/configs/englishSpeakingTools'
 import { getImageDataURL } from '@/utils/image'
 import useImport from '@/hooks/useImport'
 import type { ShapePoolItem } from '@/configs/shapes'
@@ -274,24 +280,36 @@ import ElementFlip from '@/views/Editor/Toolbar/common/ElementFlip2.vue'
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
 const { creatingElement, creatingCustomShape, showSelectPanel, showSearchPanel, showNotesPanel } = storeToRefs(mainStore)
 const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
 const { currentSlide } = storeToRefs(slidesStore)
 const { previewState } = storeToRefs(speakingStore)
+const { previewStage: articlePreviewStage } = storeToRefs(articleStore)
 
 // 当前 slide 是否包含英语口语工具(toolType=77)
 const hasEnglishSpeakingTool = computed(() => {
   const elements = currentSlide.value?.elements || []
-  return elements.some((el: any) => el.type === 'frame' && Number(el.toolType) === 77)
+  return elements.some((el: any) => el.type === 'frame' && isTopicDiscussionTool(el.toolType))
 })
 
-// 仅当存在 toolType=77 且预览不在 ready 阶段时,显示"重置预览"按钮
+// 文章朗读(toolType=772)
+const hasArticleReadingTool = computed(() => {
+  const elements = currentSlide.value?.elements || []
+  return elements.some((el: any) => el.type === 'frame' && isArticleReadingTool(el.toolType))
+})
+
+// 「重置预览」两种题型同一条规则:元素在当前页 **且** 预览已经离开起始页。
+// 文章朗读原来是「元素在就一直显示」,与话题讨论不一致 —— 停在起始页时那个按钮没有任何
+// 可重置的东西,按下去只是原地重载一次。两边的 ready 都由各自的预览组件同步进 store。
 const canResetSpeakingPreview = computed(
-  () => hasEnglishSpeakingTool.value && previewState.value !== 'ready'
+  () => (hasEnglishSpeakingTool.value && previewState.value !== 'ready')
+    || (hasArticleReadingTool.value && articlePreviewStage.value !== 'ready')
 )
 
 const handleResetSpeakingPreview = () => {
-  speakingStore.requestResetPreview()
+  if (hasArticleReadingTool.value) articleStore.requestResetPreview()
+  if (hasEnglishSpeakingTool.value) speakingStore.requestResetPreview()
 }
 
 const getInitialViewMode = () => {
@@ -335,6 +353,7 @@ const getTypeLabel = (type: number) => {
     75: lang.lang == 'cn' ? lang.ssBiliVideo : lang.ssYouTube,
     76: lang.ssCreative,
     77: lang.ssEnglishSpeakingTool,
+    [ARTICLE_READING_TOOL_TYPE]: lang.ssArticleReadingTool,
     78: lang.ssVote,
     79: lang.ssPhoto,
   }

+ 17 - 3
src/views/Editor/EnglishSpeaking/SpeakingPanel.vue

@@ -23,13 +23,15 @@
           <svg class="breadcrumb-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
             <path d="M9 18l6-6-6-6" />
           </svg>
-          <!-- config 层:显示完整三级面包屑 英语 > 口语 > 话题讨论 -->
-          <template v-if="pageMode === 'config'">
+          <!-- config 层:显示完整三级面包屑 英语 > 口语 > 话题讨论 / 文章朗读 -->
+          <template v-if="pageMode === 'config' || pageMode === 'config-article-reading'">
             <button class="breadcrumb-link" @click="goLayer2">{{ lang.ssSpeaking }}</button>
             <svg class="breadcrumb-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
               <path d="M9 18l6-6-6-6" />
             </svg>
-            <span class="breadcrumb-current">{{ lang.ssTopicDiscussion }}</span>
+            <span class="breadcrumb-current">
+              {{ pageMode === 'config-article-reading' ? lang.ssArticleReading : lang.ssTopicDiscussion }}
+            </span>
           </template>
           <!-- layer2:显示两级面包屑 英语 > 口语 -->
           <template v-else>
@@ -60,6 +62,10 @@
         v-else-if="pageMode === 'config'"
         @back="goLayer2"
       />
+      <ArticlePracticeConfig
+        v-else-if="pageMode === 'config-article-reading'"
+        @back="goLayer2"
+      />
     </div>
   </div>
 </template>
@@ -69,14 +75,17 @@ import { ref, computed, watch } from 'vue'
 import { lang } from '@/main'
 import type { PageMode, ExerciseType } from '@/types/englishSpeaking'
 import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
 import Layer1Home from './layers/Layer1Home.vue'
 import Layer2Speaking from './layers/Layer2Speaking.vue'
 import TopicDiscussionConfig from './configs/TopicDiscussionConfig.vue'
+import ArticlePracticeConfig from './configs/ArticlePracticeConfig.vue'
 
 /** 当前页面层级:layer1(首页) → layer2(口语列表) → config(配置页) */
 const pageMode = ref<PageMode>('layer1')
 
 const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
 
 // 课本筛选条件的默认值(上海英语·五年级上·Unit2),供开发/演示用
 const selectedTextbook = ref('shep')
@@ -112,6 +121,11 @@ const goLayer2 = () => {
 watch(() => speakingStore.openConfigSignal, (val, old) => {
   if (val !== old) pageMode.value = 'config'
 })
+
+// 772 型 frame 走各自的信号,两条配置页互不干扰
+watch(() => articleStore.openConfigSignal, (val, old) => {
+  if (val !== old) pageMode.value = 'config-article-reading'
+})
 </script>
 
 <style lang="scss" scoped>

+ 346 - 0
src/views/Editor/EnglishSpeaking/components/ArticleFormatDialog.vue

@@ -0,0 +1,346 @@
+<template>
+  <div v-if="visible" class="modal-mask">
+    <div
+      ref="dialogRef"
+      class="format-dialog scale-in"
+      role="dialog"
+      aria-modal="true"
+      aria-labelledby="article-format-dialog-title"
+      aria-describedby="article-format-dialog-desc"
+      tabindex="-1"
+      @keydown="handleKeydown"
+    >
+      <!-- 覆盖确认 / 应用守卫的进行中态:整块大转圈,按钮排消失(还原 enspeak :461-480) -->
+      <div v-if="loading && mode !== 'paste'" class="dialog-loading">
+        <svg class="spinner spinner-lg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
+          <path d="M12 3a9 9 0 1 0 9 9" />
+        </svg>
+        <div id="article-format-dialog-title" class="dialog-title">{{ lang.ssArticleAutoFormatting }}</div>
+        <div id="article-format-dialog-desc" class="dialog-hint">{{ lang.ssArticleAutoFormattingHint }}</div>
+      </div>
+
+      <template v-else>
+        <div class="dialog-body">
+          <div id="article-format-dialog-title" class="dialog-title">{{ title }}</div>
+          <div id="article-format-dialog-desc" class="dialog-message">{{ bodyText }}</div>
+        </div>
+
+        <div class="dialog-actions">
+          <button class="dialog-btn btn-secondary" :disabled="loading" @click="emit('cancel')">
+            {{ cancelLabel }}
+          </button>
+          <button class="dialog-btn btn-primary" :disabled="loading" @click="emit('confirm')">
+            <!-- 粘贴弹窗的进行中态:确认键内联小转圈(还原 enspeak :443-450) -->
+            <svg
+              v-if="loading"
+              class="spinner spinner-sm"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2.5"
+              stroke-linecap="round"
+            >
+              <path d="M12 3a9 9 0 1 0 9 9" />
+            </svg>
+            {{ loading ? lang.ssArticleAutoFormatting : confirmLabel }}
+          </button>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
+import { lang } from '@/main'
+
+const props = defineProps<{
+  visible: boolean
+  mode: 'paste' | 'overwrite' | 'guard'
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'confirm'): void
+  (e: 'cancel'): void
+}>()
+
+// 焦点陷阱 / 键盘行为:与同目录 TaskHintModal.vue 同一套实现,两个弹框统一风格
+const dialogRef = ref<HTMLElement | null>(null)
+const previouslyFocusedElement = ref<HTMLElement | null>(null)
+
+const focusableSelector = [
+  'a[href]',
+  'button:not([disabled])',
+  'textarea:not([disabled])',
+  'input:not([disabled])',
+  'select:not([disabled])',
+  '[tabindex]:not([tabindex="-1"])',
+].join(',')
+
+const getFocusableElements = () => {
+  const dialog = dialogRef.value
+  if (!dialog) return []
+
+  return Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter((el) => {
+    if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false
+    return el.offsetParent !== null || el === document.activeElement
+  })
+}
+
+const restorePreviousFocus = () => {
+  const element = previouslyFocusedElement.value
+  previouslyFocusedElement.value = null
+
+  if (element && document.contains(element)) {
+    element.focus()
+  }
+}
+
+const focusDialog = async () => {
+  previouslyFocusedElement.value = document.activeElement instanceof HTMLElement
+    ? document.activeElement
+    : null
+
+  await nextTick()
+  dialogRef.value?.focus()
+}
+
+const containTabFocus = (event: KeyboardEvent) => {
+  const dialog = dialogRef.value
+  if (!dialog) return
+
+  const focusableElements = getFocusableElements()
+  if (!focusableElements.length) {
+    event.preventDefault()
+    dialog.focus()
+    return
+  }
+
+  const firstElement = focusableElements[0]
+  const lastElement = focusableElements[focusableElements.length - 1]
+  const activeElement = document.activeElement
+
+  if (event.shiftKey) {
+    if (activeElement === dialog || activeElement === firstElement || !dialog.contains(activeElement)) {
+      event.preventDefault()
+      lastElement.focus()
+    }
+    return
+  }
+
+  if (activeElement === dialog) {
+    event.preventDefault()
+    firstElement.focus()
+    return
+  }
+
+  if (activeElement === lastElement) {
+    event.preventDefault()
+    firstElement.focus()
+  }
+}
+
+const handleKeydown = (event: KeyboardEvent) => {
+  if (event.key === 'Escape') {
+    if (props.loading) return // 进行中态 Escape 失效,与取消按钮 :disabled="loading" 一致
+    event.preventDefault()
+    emit('cancel')
+    return
+  }
+
+  if (event.key === 'Tab') {
+    containTabFocus(event)
+  }
+}
+
+watch(
+  () => props.visible,
+  (visible) => {
+    if (visible) {
+      void focusDialog()
+      return
+    }
+
+    restorePreviousFocus()
+  },
+  { flush: 'post', immediate: true },
+)
+
+onBeforeUnmount(() => {
+  restorePreviousFocus()
+})
+
+// 文案按 mode 分档。必须使用静态key访问(避免动态下标)
+// 扫描脚本依赖正则表达式检测源码中的key引用
+const title = computed(() => {
+  if (props.mode === 'paste') return lang.ssArticlePasteFormatTitle as string
+  if (props.mode === 'overwrite') return lang.ssArticleOverwriteFormatTitle as string
+  return lang.ssArticleGuardTitle as string
+})
+
+const bodyText = computed(() => {
+  if (props.mode === 'paste') return lang.ssArticlePasteFormatMessage as string
+  if (props.mode === 'overwrite') return lang.ssArticleOverwriteFormatMessage as string
+  return lang.ssArticleGuardMessage as string
+})
+
+const cancelLabel = computed(() =>
+  (props.mode === 'guard' ? lang.ssArticleGuardManual : lang.ssArticleFormatCancel) as string
+)
+
+const confirmLabel = computed(() =>
+  (props.mode === 'guard' ? lang.ssArticleAutoFormat : lang.ssArticleFormatConfirm) as string
+)
+</script>
+
+<style lang="scss" scoped>
+/* 值抄自同仓 TaskHintModal.vue(scoped 无法跨组件复用,故复制)= enspeak bg-black/30 + inset-0 + z-50 */
+.modal-mask {
+  position: fixed;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 16px;
+  background: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(2px);
+}
+
+/* enspeak: bg-white rounded-xl shadow-2xl w-[380px] p-6 */
+.format-dialog {
+  width: 380px;
+  max-width: 100%;
+  padding: 24px;
+  background: #fff;
+  border-radius: 16px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
+  box-sizing: border-box;
+}
+
+/* enspeak: animate-in fade-in zoom-in duration-200 */
+.scale-in {
+  animation: scale-in 0.18s ease-out;
+}
+
+@keyframes scale-in {
+  from {
+    opacity: 0;
+    transform: scale(0.96);
+  }
+
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+.dialog-body {
+  text-align: center;
+  margin-bottom: 24px;
+}
+
+/* enspeak: text-[15px] font-semibold text-gray-800 mb-2 */
+.dialog-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 8px;
+}
+
+/* enspeak: text-[13px] text-gray-600 */
+.dialog-message {
+  font-size: 13px;
+  line-height: 1.6;
+  color: #4b5563;
+}
+
+.dialog-loading {
+  text-align: center;
+  padding: 8px 0;
+}
+
+/* enspeak: text-[13px] text-gray-500 */
+.dialog-hint {
+  font-size: 13px;
+  color: #6b7280;
+}
+
+/* enspeak: flex gap-3 */
+.dialog-actions {
+  display: flex;
+  gap: 12px;
+}
+
+/* enspeak: flex-1 h-10 rounded-lg text-[13px] font-medium */
+.dialog-btn {
+  flex: 1;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:disabled {
+    cursor: not-allowed;
+  }
+}
+
+/* enspeak: border-gray-200 text-gray-700 hover:bg-gray-50 disabled:opacity-50 */
+.btn-secondary {
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #374151;
+
+  &:hover:not(:disabled) {
+    background: #f9fafb;
+  }
+
+  &:disabled {
+    opacity: 0.5;
+  }
+}
+
+/* enspeak: bg-orange-500 text-white hover:bg-orange-600 */
+.btn-primary {
+  border: none;
+  background: #f97316;
+  color: #fff;
+
+  &:hover:not(:disabled) {
+    background: #ea580c;
+  }
+}
+
+.spinner {
+  animation: spin 0.8s linear infinite;
+  flex-shrink: 0;
+}
+
+/* enspeak: LoaderIcon w-4 h-4 */
+.spinner-sm {
+  width: 16px;
+  height: 16px;
+}
+
+/* enspeak: LoaderIcon w-8 h-8 text-orange-500 mx-auto mb-4 */
+.spinner-lg {
+  width: 32px;
+  height: 32px;
+  color: #f97316;
+  display: block;
+  margin: 0 auto 16px;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 17 - 4
src/views/Editor/EnglishSpeaking/components/RecommendCard.vue

@@ -4,9 +4,9 @@
       <h4 class="card-title">{{ task.titleZh }}</h4>
       <p class="card-subtitle">{{ task.subtitle }}</p>
       <p class="card-meta">
-        <span>{{ lang.ssTopicDiscussion }}</span>
+        <span>{{ typeLabel }}</span>
         <span class="meta-dot">·</span>
-        <span>{{ task.rounds }}{{ lang.ssRoundsUnit }}</span>
+        <span>{{ countLabel }}</span>
         <span class="meta-dot">·</span>
         <span>{{ difficultyLabel }}</span>
         <span class="meta-dot">·</span>
@@ -22,16 +22,29 @@
 <script lang="ts" setup>
 import { computed } from 'vue'
 import { lang } from '@/main'
-import type { TopicDiscussionTask } from '@/types/englishSpeaking'
+import type { SpeakingRecommendationTask } from '@/types/englishSpeaking'
 
 const props = defineProps<{
-  task: TopicDiscussionTask
+  task: SpeakingRecommendationTask
 }>()
 
 defineEmits<{
   (e: 'select'): void
 }>()
 
+// 按 taskType 分支渲染:文章卡显示「1 篇」,话题卡显示「N 轮」,不把文章塞进话题形状
+const typeLabel = computed(() =>
+  props.task.taskType === 'article-reading'
+    ? (lang.ssArticleReading as string)
+    : (lang.ssTopicDiscussion as string),
+)
+
+const countLabel = computed(() =>
+  props.task.taskType === 'article-reading'
+    ? `${props.task.count} ${props.task.unit}`
+    : `${props.task.rounds}${lang.ssRoundsUnit}`,
+)
+
 const difficultyLabel = computed(() => {
   const labels: Record<number, string> = {
     1: lang.ssDifficultyBeginner as string,

+ 45 - 0
src/views/Editor/EnglishSpeaking/composables/articleAutoFormatRules.ts

@@ -0,0 +1,45 @@
+/**
+ * 配置端自动分段的纯规则(spec §3.2 / §3.2.1)。
+ *
+ * 本文件**零依赖**——不 import vue、不 import 服务,
+ * 这样 scripts/test-article-auto-format.mjs 可以直接转译执行,不需要任何 shim。
+ */
+
+/** 单段最大词数;超过则「应用配置」硬守卫拦截(spec §3.2 阈值汇总)。 */
+export const MAX_PARAGRAPH_WORDS = 300
+
+/** 粘贴触发弹窗的词数下限,严格大于才提示(spec §3.2 step 1)。 */
+export const PASTE_PROMPT_MIN_WORDS = 50
+
+export function countWords(text: string): number {
+  const trimmed = text.trim()
+  return trimmed ? trimmed.split(/\s+/).length : 0
+}
+
+/** 与后端 split_paragraphs 同规则:`\n+` 切段 + 段内空白归一 + 丢空段,再以 `\n\n` 接。 */
+export function normalizeParagraphs(text: string): string {
+  return text
+    .split(/\n+/)
+    .map(chunk => chunk.trim().split(/\s+/).join(' '))
+    .filter(chunk => chunk.length > 0)
+    .join('\n\n')
+}
+
+/** 是否存在超过上限的段落。提示文案不点名段号,故只回布尔(spec §3.2 step 4)。 */
+export function hasOverlongParagraph(text: string): boolean {
+  return normalizeParagraphs(text)
+    .split('\n\n')
+    .some(paragraph => countWords(paragraph) > MAX_PARAGRAPH_WORDS)
+}
+
+/**
+ * 空文本框粘贴长文时提示(spec §3.2 step 1)。条件就是 spec 写的两条,没有第三条:
+ * 原内容 trim 后为空,且新内容超过 50 词。
+ *
+ * 不再看新内容里有没有 `\n\n`。带空行 ≠ 分得适合朗读——一篇三段、每段 400 词的文章
+ * 完全带空行,而每段就是一个录音单位,正是本功能要拆的东西。
+ * 老师从网页/Word 复制的正文几乎必然带空行,加那条守卫等于让主要入口永不触发。
+ */
+export function shouldPromptOnPaste(prev: string, next: string): boolean {
+  return prev.trim() === '' && countWords(next) > PASTE_PROMPT_MIN_WORDS
+}

+ 117 - 0
src/views/Editor/EnglishSpeaking/composables/useArticleAutoFormat.ts

@@ -0,0 +1,117 @@
+/**
+ * 配置端自动分段的弹框状态机(spec §3.2 / §3.2.1)。
+ *
+ * 三种触发共用同一个 `POST /article/segment`(整篇,非单段):
+ * ① 空文本框粘贴长文 → mode='paste'
+ * ② 点「自动分段」按钮 → mode='overwrite'
+ * ③ 应用配置撞上 300 词硬守卫 → mode='guard'
+ */
+import { onScopeDispose, ref, type Ref } from 'vue'
+import { lang } from '@/main'
+import message from '@/utils/message'
+import { segmentArticle } from '../services/articleReading'
+import {
+  hasOverlongParagraph,
+  normalizeParagraphs,
+  shouldPromptOnPaste,
+} from './articleAutoFormatRules'
+
+export type FormatDialogMode = 'paste' | 'overwrite' | 'guard'
+
+export function useArticleAutoFormat(content: Ref<string>) {
+  const dialogVisible = ref(false)
+  const dialogMode = ref<FormatDialogMode>('paste')
+  const isFormatting = ref(false)
+
+  // ArticlePracticeConfig 是 v-else-if'd(真会卸载)。晚到的分段响应不能在作用域销毁后
+  // 还去写 content(它是外部传入、直连 pinia store 的 computed)。
+  let disposed = false
+  onScopeDispose(() => {
+    disposed = true
+  })
+
+  /**
+   * textarea 的 @input。恒写 store —— 取消弹窗即「保留原始粘贴」。
+   * 上一次的内容直接取 content.value:此刻还没写入,它就是输入前的值。
+   * 不缓存到局部变量,否则 store.replaceConfig 载入已有配置后会陈旧。
+   */
+  function onContentInput(next: string) {
+    const prev = content.value
+    content.value = next
+    if (shouldPromptOnPaste(prev, next)) {
+      dialogMode.value = 'paste'
+      dialogVisible.value = true
+    }
+  }
+
+  /** 「自动分段」按钮:先弹覆盖确认,确认才调后端。 */
+  function openOverwrite() {
+    if (!content.value.trim() || isFormatting.value) return
+    dialogMode.value = 'overwrite'
+    dialogVisible.value = true
+  }
+
+  /**
+   * 「应用配置」前置校验:归一化回写 + 300 词硬守卫。
+   * @returns true = 放行提交;false = 已开守卫弹框,调用方必须中止提交
+   */
+  function guardBeforeApply(): boolean {
+    if (isFormatting.value) return false // 请求进行中:不放行提交,也不误开/误改弹框状态
+    const normalized = normalizeParagraphs(content.value)
+    content.value = normalized
+    if (!hasOverlongParagraph(normalized)) return true
+    dialogMode.value = 'guard'
+    dialogVisible.value = true
+    return false
+  }
+
+  /** 三种弹框的确认键共用;整篇送后端,结果原样归一化后回填。 */
+  async function confirmDialog() {
+    if (isFormatting.value) return // 防重复(承 enspeak runAutoFormat 早退)
+    const source = content.value.trim()
+    if (!source) {
+      // 可达路径:老师清空文本框后点确认。不能静默无反应,必须关框(否则弹框卡住)。
+      dialogVisible.value = false
+      return
+    }
+
+    isFormatting.value = true
+    try {
+      const { content: segmented } = await segmentArticle(source)
+      if (disposed) return // 组件已卸载:作用域已销毁,响应作废,不再写 store
+      if (content.value.trim() !== source) {
+        // 30 秒等待期间老师又改了文本:新内容优先,只关框,绝不覆盖
+        dialogVisible.value = false
+        return
+      }
+      content.value = normalizeParagraphs(segmented)
+      dialogVisible.value = false
+    }
+    catch (err: unknown) {
+      if (disposed) return
+      console.error('[article-reading] auto format failed:', err)
+      message.error(lang.ssArticleSegmentFailed as string)
+      // 守卫框保持打开:老师可原地重试或改选「手动修改」。其余两种关掉即可。
+      if (dialogMode.value !== 'guard') dialogVisible.value = false
+    }
+    finally {
+      if (!disposed) isFormatting.value = false
+    }
+  }
+
+  function cancelDialog() {
+    if (isFormatting.value) return
+    dialogVisible.value = false
+  }
+
+  return {
+    dialogVisible,
+    dialogMode,
+    isFormatting,
+    onContentInput,
+    openOverwrite,
+    guardBeforeApply,
+    confirmDialog,
+    cancelDialog,
+  }
+}

+ 827 - 0
src/views/Editor/EnglishSpeaking/composables/useArticleReadingEngine.ts

@@ -0,0 +1,827 @@
+import { ref, computed, watch, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'
+import { useAudioRecorder } from './useAudioRecorder'
+import { useAudioPlayer } from './useAudioPlayer'
+import {
+  createArticleSession,
+  getArticleReport,
+  completeArticleSession,
+  getArticleDemoAudio,
+  abandonArticleSession,
+} from '../services/articleReading'
+import { friendlyArticleError, isArticleSessionGone } from '../services/articleErrors'
+import { openArticleParagraphStream, type ArticleParagraphStream } from '../services/articleReadingStream'
+import { isFinalReportReady, pollDelayMs } from '../preview/articleReadingModel'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+export type ArticlePreviewStage = 'ready' | 'reading' | 'report-loading' | 'report'
+/**
+ * 三态,**没有原型的 `done`**(enspeak `ArticleReadingView.tsx` 有第四态「完成 ✓」)。
+ *
+ * 原型的 `done` 是 mock 的产物:它录完当场出分,所以能停在「完成」上让学生看一眼。
+ * 真实管线是异步的(决策 A,spec §9)—— ack 一到 `advanceAfterAck` 就把 recordingState
+ * 打回 'idle' 并跳去下一段,分数稍后由轮询回填。`done` 因此没有任何可以停留的时刻。
+ * 别照着原型把它加回来:加回来也永远不会被赋值。
+ */
+export type ArticleRecordingState = 'idle' | 'recording' | 'processing'
+
+/** 单段录音硬上限(spec §5.1);到点只停当前段,不结束整篇。 */
+const MAX_PARAGRAPH_SECONDS = 180
+
+/**
+ * 轮询**连续失败**几次后放弃(对齐话题讨论有上限的做法,`useDialogueEngine.ts:269`)。
+ *
+ * 计的是连续失败而不是总轮询次数:段落还在 `generating` 时的健康轮询走的是同一个
+ * `pollAttempt`,拿总次数封顶会把正常等分数的学生一起掐掉。收到任何一次成功响应就归零 ——
+ * 响应本身就是后端还活着的证据。
+ *
+ * 退避封顶 8s,所以 8 次连续失败 ≈ 0+1+2+4+8+8+8 ≈ 31 秒后停手。停手不是无声的:
+ * error 横幅留在原地,report-loading 门另有手动重试按钮,学生有路可走。
+ */
+const MAX_POLL_FAILURES = 8
+
+export interface ArticleReadingEngineOptions {
+  configId: MaybeRefOrGetter<string>
+  /** null = 编辑态老师预览(spec §4.5)。engine 只负责原样转发,判定在 ArticleReadingPreview。 */
+  userId: MaybeRefOrGetter<string | null>
+  durationMinutes: MaybeRefOrGetter<number>
+  showReport: MaybeRefOrGetter<boolean>
+  onSessionStarted?: (sessionId: string) => void
+  onSessionCompleted?: (sessionId: string) => void
+  /** 「重新开始」成功作废旧 session(spec §4.2)。老师端据此把「已完成」刷回「未开始」。 */
+  onSessionAbandoned?: (sessionId: string) => void
+}
+
+export interface ArticleReadingEngineDeps {
+  createSession: typeof createArticleSession
+  getReport: typeof getArticleReport
+  completeSession: typeof completeArticleSession
+  getDemoAudio: typeof getArticleDemoAudio
+  abandonSession: typeof abandonArticleSession
+  createStream: typeof openArticleParagraphStream
+  recorder: ReturnType<typeof useAudioRecorder>
+  player: ReturnType<typeof useAudioPlayer>
+  document: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>
+  setTimeout: typeof window.setTimeout
+  clearTimeout: typeof window.clearTimeout
+  setInterval: typeof window.setInterval
+  clearInterval: typeof window.clearInterval
+  /** 墙上时钟。计时器改按时间戳算之后(spec §9.2.3),测试必须能控制「现在几点」。 */
+  now: () => number
+}
+
+export function useArticleReadingEngine(
+  options: ArticleReadingEngineOptions,
+  overrides: Partial<ArticleReadingEngineDeps> = {},
+) {
+  const deps: ArticleReadingEngineDeps = {
+    createSession: createArticleSession,
+    getReport: getArticleReport,
+    completeSession: completeArticleSession,
+    getDemoAudio: getArticleDemoAudio,
+    abandonSession: abandonArticleSession,
+    createStream: openArticleParagraphStream,
+    recorder: overrides.recorder ?? useAudioRecorder(),
+    player: overrides.player ?? useAudioPlayer(),
+    document: globalThis.document,
+    setTimeout: ((...args: Parameters<typeof setTimeout>) => setTimeout(...args)) as typeof window.setTimeout,
+    clearTimeout: ((id?: number) => clearTimeout(id)) as typeof window.clearTimeout,
+    setInterval: ((...args: Parameters<typeof setInterval>) => setInterval(...args)) as typeof window.setInterval,
+    clearInterval: ((id?: number) => clearInterval(id)) as typeof window.clearInterval,
+    now: () => Date.now(),
+    ...overrides,
+  }
+  const recorder = deps.recorder
+  const player = deps.player
+
+  const stage = ref<ArticlePreviewStage>('ready')
+  /** `POST /session` 在途。视图据此置灰「开始朗读」;闩本身在 startSession 里,按钮只是它的显示。 */
+  const starting = ref(false)
+  const report = ref<ArticleReadingReport | null>(null)
+  const currentParagraphIdx = ref(0)
+  const recordingState = ref<ArticleRecordingState>('idle')
+  /** 已用练习秒数:仅计时模式下计数,非计时模式恒为 null(没有计时器)。到上限后冻结,不再累加。 */
+  const elapsedPracticeSeconds = ref<number | null>(null)
+  const localAudioByParagraph = ref(new Map<number, Blob>())
+  const error = ref<string | null>(null)
+  /**
+   * 后端说这一轮已经作废(409,spec §4.2)。重试必然再失败,视图应改提示「请重新开始」。
+   * 与 error 分开:error 是给人看的文案,这面旗是给视图分支用的。
+   */
+  const sessionInvalid = ref(false)
+
+  const recordedParagraphs = new Set<number>()
+  const demoUrlCache = new Map<string, string>()
+
+  let activeStream: ArticleParagraphStream | null = null
+  let startingRecording = false
+  let finishingRecording = false
+  let pollTimer: number | null = null
+  let pollAttempt = 0
+  /** 连续失败次数(成功即归零)。到 MAX_POLL_FAILURES 就停手,不再无限敲后端。 */
+  let pollFailures = 0
+  /**
+   * 当前那面横幅是不是轮询自己竖起来的。
+   *
+   * 只有它该被下一次成功轮询收走 —— 麦克风、录音被拒、示范音频那些错误不能碰。
+   * 尤其是录音被拒(`Session has ended`):那条 catch 随后就 `schedulePoll(0)`,
+   * 若在 acceptReport 里无差别清 error,学生会在唯读锁合上的同一瞬间失去
+   * 「为什么我录不了」的唯一解释。
+   */
+  let pollErrorShown = false
+  let pollInFlight = false
+  let practiceTimer: number | null = null
+  let reportAbort: AbortController | null = null
+  let disposed = false
+  let waitingForVisible = false
+
+  let completionPromise: Promise<void> | null = null
+  let completionNotified = false
+  let completionSucceeded = false
+  let hasFetchedAfterComplete = false
+
+  /**
+   * 已录完的段落 = 后端已知有结果(generating/completed/failed),或本地刚 ack 过。
+   * 本地集合让最后一段 ack 后按钮立刻可用,不必等下一轮 GET。绝不本地伪造分数。
+   */
+  const canViewReport = computed(() => {
+    const current = report.value
+    if (!current || current.paragraphs.length === 0) return false
+    return current.paragraphs.every(
+      paragraph => paragraph.status !== 'pending' || recordedParagraphs.has(paragraph.idx),
+    )
+  })
+
+  /** 这一轮是否已结束。showReport 关闭时,视图据此把练习页转为只读(spec §9.2.1)。 */
+  const sessionEnded = computed(() => report.value?.status === 'completed')
+
+  /**
+   * 整篇总评已经开始生成(`overallStatus` 非 null = generating / completed / failed)。
+   *
+   * 后端 2026-07-27 起在这之后**拒绝重录**(409 `Report already generated`):放行不是多烧一次
+   * LLM,而是作废一份学生/老师可能已经看到的报告。视图据此提前锁掉录音,否则那颗按钮按下去
+   * 必然报错。判据是 `overallStatus` 而不是 `sessionEnded` —— 静默预热把 status 翻成
+   * completed 的那一刻总评还没开始,那时重录仍然允许。
+   */
+  const overallStarted = computed(() => (report.value?.overallStatus ?? null) !== null)
+
+  // ---------------- session ----------------
+
+  async function startSession() {
+    /**
+     * 双击「开始朗读」= 两个 `POST /session`,而后端的 get-or-create 既没有锁也没有唯一约束
+     * (`article_service.py` `get_or_create_session`):两个并发请求会双双查不到既有 session,
+     * 然后各插一行。编辑态老师预览(`userId=null`)更是**必然**两行 —— `_latest_session`
+     * 对空 user 早退返回 None 是设计如此(spec §4.5)。
+     *
+     * 多出来的那一行不是无害的垃圾:它一路算进 §4.3 B 的「N 名学生正在练习中」直到
+     * `expires_at` 把它扫掉,老师明明只有一个学生在读却被问「2 名学生正在练习中」;
+     * `onSessionStarted` 也会跑两次,宿主的作业表因此多一笔(§9.2.4)。
+     *
+     * 闩放在这里而不是只置灰按钮:按钮只挡得住鼠标,挡不住程序化调用(承 §3.2.1
+     * `guardBeforeApply` 早退同一条理由)。对齐话题讨论的 `sessionCreating`
+     * (`TopicDiscussionPreview.vue:33`)。
+     */
+    if (starting.value) return
+    starting.value = true
+    error.value = null
+    sessionInvalid.value = false
+    // 每一轮都从干净的完成态起步。reset() 也清这四个,但 startSession 不该依赖
+    // 「上一次一定 reset 过、且没有在途的 POST 在 reset 之后把它们设回来」
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    try {
+      const next = await deps.createSession(toValue(options.configId), toValue(options.userId))
+      report.value = next
+      stage.value = 'reading'
+      currentParagraphIdx.value = firstPendingIdx(next)
+      options.onSessionStarted?.(next.sessionId)
+      startPracticeTimer()
+      if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
+    } catch (cause) {
+      error.value = friendlyArticleError(cause)
+    } finally {
+      // 只在这里放闩。reset() **故意不清它** —— 请求还在飞的时候把闩打开,
+      // 第二次点击照样能发出第二个 POST,等于没修。
+      starting.value = false
+    }
+  }
+
+  function firstPendingIdx(current: ArticleReadingReport): number {
+    const pending = current.paragraphs.find(paragraph => paragraph.status === 'pending')
+    return pending ? pending.idx : 0
+  }
+
+  /**
+   * 刷新页面 resume:用后端最新 session 的 report 直接水合引擎,跳过 ready 起始页。
+   * reading → 回到朗读视图;completed → 回到结果页(overall 未就绪则走一次性 loading 门轮询)。
+   * 不触发 onSessionStarted(那是真正开始时记作业用);presence 广播由调用方负责。
+   */
+  function resumeFromReport(next: ArticleReadingReport) {
+    error.value = null
+    sessionInvalid.value = false
+    report.value = next
+
+    if (next.status === 'completed') {
+      // 已完成:complete 不必再 POST;标记成已完成态,防止 preheat 重发或重复通知
+      completionPromise = Promise.resolve()
+      completionSucceeded = true
+      completionNotified = true
+      hasFetchedAfterComplete = false
+      if (!toValue(options.showReport)) {
+        // 关闭「展示学习报告」:完成也不进结果页,留在练习页(只读锁由 sessionEnded 触发,spec §9.2.1)
+        stage.value = 'reading'
+        currentParagraphIdx.value = firstPendingIdx(next)
+        return
+      }
+      if (isFinalReportReady(next)) {
+        stage.value = 'report'
+      } else {
+        // 段落已落定但整篇总评仍 generating → 走一次性 loading 门 + 退避轮询
+        stage.value = 'report-loading'
+        schedulePoll(0)
+      }
+      return
+    }
+
+    // reading:回到朗读视图。计时器改按时间戳算之后,resume 重启是安全的 ——
+    // 它从 expiresAt 反推起点,算出的是真实已用时长,不会「刷新即重置」。
+    stage.value = 'reading'
+    currentParagraphIdx.value = firstPendingIdx(next)
+    startPracticeTimer()
+    if (next.paragraphs.some(paragraph => paragraph.status === 'generating')) schedulePoll(0)
+  }
+
+  // ---------------- recording ----------------
+
+  async function startRecording() {
+    /**
+     * `recordingState` 要等 getUserMedia + addModule 落地才翻成 'recording',那期间
+     * 录音按钮是亮的、idle 那一栏也还在 —— 少了 startingRecording 这道闩,连点两下
+     * 就会**建起两套采集图**:两个 worklet 的 onmessage 往同一个 pcmChunks 里塞,
+     * 采样数翻倍且两路交错,本地回放听起来就是「慢一半 + 刺啦声」;`onChunk` 同样被
+     * 两路调用,上行 WS 与后端存的 WAV 一并被污染。第二次调用还会覆盖 activeStream,
+     * 把第一条 WS 连同它的 attempt 一起漏在半空。
+     */
+    if (!report.value || recordingState.value === 'recording' || startingRecording) return
+    startingRecording = true
+    try {
+      await beginRecording()
+    }
+    finally {
+      startingRecording = false
+    }
+  }
+
+  async function beginRecording() {
+    if (!report.value) return
+    error.value = null
+    // 录音器先于 socket 就绪吐 chunk 是常态,先缓在本地,建流后补推,开头不能丢
+    const beforeStream: ArrayBuffer[] = []
+    recorder.onChunk.value = chunk => {
+      if (activeStream) activeStream.pushChunk(chunk)
+      else beforeStream.push(chunk)
+    }
+
+    try {
+      await recorder.startRecording()
+    } catch (cause) {
+      recorder.onChunk.value = null
+      error.value = friendlyArticleError(cause)
+      return
+    }
+
+    activeStream = deps.createStream({
+      sessionId: report.value.sessionId,
+      paragraphIdx: currentParagraphIdx.value,
+      sampleRate: recorder.sampleRate.value,
+    })
+    for (const chunk of beforeStream) activeStream.pushChunk(chunk)
+    beforeStream.length = 0
+    recordingState.value = 'recording'
+  }
+
+  async function finishRecording() {
+    if (!activeStream || finishingRecording) return
+    finishingRecording = true
+    recordingState.value = 'processing'
+    const paragraphIdx = currentParagraphIdx.value
+    const stream = activeStream
+    const sessionId = report.value?.sessionId
+    /**
+     * 两个 await 期间「重新开始」都可能跑过 reset(),把引擎带到另一轮。
+     * 之后任何回写都是污染:blob 会落进刚清空的 Map(新一轮那段带着「已录制」徽章
+     * 和一个播放上一次录音的回放按钮),activeStream/recordingState 则会踩掉新一轮。
+     */
+    const stillCurrent = () => report.value?.sessionId === sessionId
+
+    try {
+      const wav = await recorder.stopRecording()
+      if (!stillCurrent()) {
+        stream.abort('Article session reset while finishing')
+        return
+      }
+      activeStream = null
+      await stream.stop()
+      if (!stillCurrent()) return
+      localAudioByParagraph.value.set(paragraphIdx, wav)
+      recordedParagraphs.add(paragraphIdx)
+      advanceAfterAck(paragraphIdx)
+      schedulePoll(0)
+    }
+    catch (cause) {
+      stream.abort('Article recording attempt failed')
+      if (!stillCurrent()) return
+      activeStream = null
+      if (isArticleSessionGone(cause)) sessionInvalid.value = true
+      error.value = friendlyArticleError(cause)
+      recordingState.value = 'idle'
+      // 失败可能是「后端说这一轮已到期」(409 Session has ended,§4.4)。补一次查询,
+      // 让 status: completed 经 acceptReport 落地 —— 否则学生要等到下一次主动动作
+      // 才会发现自己已经结束了。
+      schedulePoll(0)
+    }
+    finally {
+      if (stillCurrent()) recorder.onChunk.value = null
+      finishingRecording = false
+    }
+  }
+
+  /**
+   * 中途放弃本次录音:abort 掉流(后端 attempt 已 +1,旧分数保留、无任务写入),
+   * 回到 idle。不发 stop,因此不会产生评分任务。
+   */
+  async function cancelRecording() {
+    if (!activeStream || finishingRecording) return
+    const stream = activeStream
+    activeStream = null
+    stream.abort('Article recording cancelled by user')
+    try {
+      await recorder.stopRecording()
+    } catch {
+      // 录音器已经停了或从未真正启动,忽略
+    }
+    recorder.onChunk.value = null
+    recordingState.value = 'idle'
+    error.value = null
+  }
+
+  /** ack 到手即前进,不等分数(异步 UX 决策 A)。最后一段则留在原地。 */
+  function advanceAfterAck(paragraphIdx: number) {
+    recordingState.value = 'idle'
+    const current = report.value
+    if (!current) return
+    const next = current.paragraphs.find(
+      paragraph => paragraph.idx > paragraphIdx && paragraph.status === 'pending'
+        && !recordedParagraphs.has(paragraph.idx),
+    )
+    if (next) currentParagraphIdx.value = next.idx
+  }
+
+  function rerecordParagraph(idx: number) {
+    // 后端重录会重开 session 并作废整篇总评,本地完成态必须一并清掉
+    recordedParagraphs.delete(idx)
+    localAudioByParagraph.value.delete(idx)
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    currentParagraphIdx.value = idx
+    stage.value = 'reading'
+    error.value = null
+  }
+
+  function selectParagraph(idx: number) {
+    if (recordingState.value === 'recording') return
+    currentParagraphIdx.value = idx
+  }
+
+  // ---------------- timers ----------------
+
+  // 单段 180s 安全阀:只停当前段,不结束整篇
+  watch(() => recorder.recordingDuration.value, duration => {
+    if (duration >= MAX_PARAGRAPH_SECONDS && recordingState.value === 'recording') {
+      void finishRecording()
+    }
+  })
+
+  /** report 上的截止时刻,毫秒;无截止 / 解析不出来 → null。 */
+  function deadlineMs(current: ArticleReadingReport | null): number | null {
+    if (!current?.expiresAt) return null
+    const parsed = Date.parse(current.expiresAt)
+    return Number.isNaN(parsed) ? null : parsed
+  }
+
+  /**
+   * 练习计时器:**纯展示**(spec §9.2.3)。
+   *
+   * 值从 `expiresAt` 与配置时长反推起点、按墙上时钟算,不再逐 tick 累加 —— 后台标签页
+   * 的 setInterval 会被浏览器节流(可能一分钟才跑一次),累加法切回来时显示的数字是错的。
+   * 同样因为这个算法,刷新页面 resume 时可以直接重启计时器:它会自己算出真实已用时长。
+   *
+   * 到点**什么都不做**,只停自己 + 发一次 GET /report。到期由后端判定(§4.4);
+   * 本地时钟只是触发器,不是判据:算早了后端说还没到,算晚了后端早已标好,两种都不会错。
+   */
+  function startPracticeTimer() {
+    stopPracticeTimer()
+    const expiresAtMs = deadlineMs(report.value)
+    if (expiresAtMs === null) {
+      elapsedPracticeSeconds.value = null
+      return
+    }
+    const capSeconds = Math.max(0, Math.round(toValue(options.durationMinutes) * 60))
+    if (capSeconds <= 0) {
+      elapsedPracticeSeconds.value = null
+      return
+    }
+    const startedAtMs = expiresAtMs - capSeconds * 1000
+    const tick = () => {
+      const elapsed = Math.floor((deps.now() - startedAtMs) / 1000)
+      elapsedPracticeSeconds.value = Math.min(capSeconds, Math.max(0, elapsed))
+      if (elapsed < capSeconds) return
+      stopPracticeTimer()
+      // 到点问一次。schedulePoll 复用既有的后台标签页挂起与去重逻辑;
+      // 没有 generating 段时 acceptReport 会 stopPolling(),所以确实只有这一次。
+      schedulePoll(0)
+    }
+    tick()
+    practiceTimer = deps.setInterval(tick, 1000)
+  }
+
+  /** 清掉练习页上的错误横幅。重试无从谈起的那些错(麦克风、示范音频、轮询)只能这样收走。 */
+  function dismissError() {
+    error.value = null
+    pollErrorShown = false
+  }
+
+  function stopPracticeTimer() {
+    if (practiceTimer !== null) {
+      deps.clearInterval(practiceTimer)
+      practiceTimer = null
+    }
+  }
+
+  // ---------------- completion ----------------
+
+  function completeOnce(): Promise<void> {
+    if (!report.value) return Promise.reject(new Error('Article session is not ready'))
+    if (!completionPromise) {
+      const sessionId = report.value.sessionId
+      completionPromise = deps.completeSession(sessionId).then(() => {
+        // POST 在途时学生可能已经「重新开始」。reset() 清过这些标记,这里绝不能再设回去 ——
+        // 否则下一轮开局就带着 completionNotified=true,那一轮真正完成时 onSessionCompleted
+        // 不再触发,老师端整轮都收不到刷新(复合放大「重新开始不广播」那条)。
+        if (report.value?.sessionId !== sessionId) return
+        completionSucceeded = true
+        hasFetchedAfterComplete = false
+        if (!completionNotified) {
+          completionNotified = true
+          options.onSessionCompleted?.(sessionId)
+        }
+      })
+    }
+    return completionPromise
+  }
+
+  function allParagraphsRecorded(current: ArticleReadingReport): boolean {
+    return current.paragraphs.every(paragraph =>
+      paragraph.status === 'generating'
+        || paragraph.status === 'completed'
+        || paragraph.status === 'failed',
+    )
+  }
+
+  /** 全段评完就静默 complete,让老师端列表提前刷新;不改 stage。 */
+  async function preheatIfEligible(current: ArticleReadingReport) {
+    if (!allParagraphsRecorded(current)) return
+    if (current.paragraphs.some(paragraph => paragraph.status === 'generating')) return
+    try {
+      await completeOnce()
+    } catch {
+      completionPromise = null
+    }
+  }
+
+  async function completeAndShowReport() {
+    if (recordingState.value === 'recording') await finishRecording()
+    stopPracticeTimer()
+    stage.value = 'report-loading'
+    error.value = null
+    try {
+      await completeOnce()
+      schedulePoll(0)
+    }
+    catch (cause) {
+      completionPromise = null
+      if (isArticleSessionGone(cause)) sessionInvalid.value = true
+      error.value = friendlyArticleError(cause)
+    }
+  }
+
+  /**
+   * 「重新开始」(spec §4.2):把当前 session 标作废,回起始页。
+   * 不在这里建新 session —— 学生点「开始朗读」时由 startSession 的 get-or-create 建,
+   * 那样 created_at 才等于真正的开始时刻。
+   * @returns 成功与否;失败时不重置,调用方负责提示。
+   */
+  async function restartSession(): Promise<boolean> {
+    const sessionId = report.value?.sessionId
+    if (!sessionId) return false
+    try {
+      await deps.abandonSession(sessionId)
+    }
+    catch (cause) {
+      console.error('[article-reading] abandon failed:', cause)
+      return false
+    }
+    reset()
+    // 库里已经是「未开始」了(§4.2 各处查询排除 abandoned),但老师端只在收到学生广播时
+    // 才重取;不发这一条,他会一直看着一个不再作数的「已完成 + 分数」,点进去还是作废那轮的报告。
+    options.onSessionAbandoned?.(sessionId)
+    return true
+  }
+
+  async function acceptReport(next: ArticleReadingReport) {
+    const wasReading = report.value?.status === 'reading'
+    report.value = next
+
+    /**
+     * 后端单方面把这一轮结束了(到期,spec §4.4)。这是学生结束的**唯一信号**,
+     * 而它可能来自任何一次响应 —— 到点那次查询、恢复探针、或学生自己的动作触发的轮询,
+     * 所以反应必须写在这个统一入口,不能绑在某一次调用上。
+     *
+     * 下面那句 `if (!completionSucceeded) return` 原本假定「只有我能结束 session」;
+     * 判定权搬到后端后这个前提不再成立,必须先在这里补齐本地的完成态,否则这次响应
+     * 会被那句早退直接吞掉。
+     *
+     * 放在 preheatIfEligible **之前**:preheat 走的是 completeOnce(),而这里已经把
+     * completionPromise 填好,于是它自然成为 no-op。反过来先 preheat 的话,它会先
+     * 把 completionSucceeded 置真、让下面的条件失效,还白发一次没有意义的 POST,
+     * 并因为「POST 之后还没 GET 过」再多轮询一轮。
+     */
+    if (wasReading && next.status === 'completed' && !completionSucceeded) {
+      stopPracticeTimer()
+      completionPromise = Promise.resolve()
+      completionSucceeded = true
+      // 这个响应本身就是「结束之后拿到的」—— 后端在这次查询里完成的收口
+      hasFetchedAfterComplete = true
+      if (!completionNotified) {
+        completionNotified = true
+        options.onSessionCompleted?.(next.sessionId)
+      }
+      // showReport 关闭时不进 loading 门:留在练习页,由 sessionEnded 合上只读锁(§9.2.1)
+      if (toValue(options.showReport)) stage.value = 'report-loading'
+    }
+
+    await preheatIfEligible(next)
+
+    if (!completionSucceeded) {
+      if (!next.paragraphs.some(paragraph => paragraph.status === 'generating')) stopPolling()
+      return
+    }
+
+    // POST 之前抓到的 report 不算数:必须至少有一次 POST 之后发起的 GET
+    if (!hasFetchedAfterComplete) {
+      schedulePoll(0)
+      return
+    }
+
+    if (isFinalReportReady(next)) {
+      if (stage.value === 'report-loading') stage.value = 'report'
+      stopPolling()
+    } else {
+      // 段落已全部落定,但整篇 overallStatus 仍是 'generating'(complete 触发的后台总评
+      // LLM 未完成)。此时没有 generating 段,pollOnce 的兜底重排不会触发,必须在这里继续
+      // 退避轮询,否则 report-loading 门会永久卡住等一个再也不来的 GET。
+      schedulePoll(pollAttempt + 1)
+    }
+  }
+
+  // ---------------- polling ----------------
+
+  function schedulePoll(attempt: number) {
+    if (disposed) return
+    // attempt === 0 = 重新起一轮(录完一段、点重试、到点查一次、resume)。
+    // 这是用户动作或新事件带来的,给它一份新的失败预算 —— 否则上一次敲到上限之后,
+    // 之后每一条新链都会在第一次失败时立刻再次停手。
+    if (attempt === 0) pollFailures = 0
+    pollAttempt = attempt
+    if (pollTimer !== null) deps.clearTimeout(pollTimer)
+    pollTimer = deps.setTimeout(() => {
+      pollTimer = null
+      void pollOnce()
+    }, attempt === 0 ? 0 : pollDelayMs(attempt - 1))
+  }
+
+  function stopPolling() {
+    if (pollTimer !== null) {
+      deps.clearTimeout(pollTimer)
+      pollTimer = null
+    }
+    pollAttempt = 0
+    // 不清 pollFailures:停手那一刻它正好等于上限,清了就没意义了。
+    // 由重新起一轮轮询的入口负责归零 —— 见 schedulePoll(0)。
+  }
+
+  function onVisible() {
+    deps.document.removeEventListener('visibilitychange', onVisible)
+    waitingForVisible = false
+    // 后台期间不计入重试次数,回到前台立即补一次
+    schedulePoll(0)
+  }
+
+  async function pollOnce() {
+    if (disposed || pollInFlight || !report.value) return
+
+    // 后台标签页不发 GET,挂起等 visibilitychange,且不推进退避
+    if (deps.document.hidden) {
+      if (!waitingForVisible) {
+        waitingForVisible = true
+        deps.document.addEventListener('visibilitychange', onVisible)
+      }
+      return
+    }
+
+    pollInFlight = true
+    const requestStartedAfterComplete = completionSucceeded
+    reportAbort = new AbortController()
+    try {
+      const next = await deps.getReport(report.value.sessionId, reportAbort.signal)
+      if (disposed) return
+      // 拿到响应 = 后端还活着,连续失败计数归零
+      pollFailures = 0
+      // 上一次轮询失败留下的横幅已经不成立了,收走它(只收自己竖的那面)
+      if (pollErrorShown) {
+        error.value = null
+        pollErrorShown = false
+      }
+      if (requestStartedAfterComplete) hasFetchedAfterComplete = true
+      const wasPolling = pollTimer
+      await acceptReport(next)
+      // acceptReport 未自行安排下一轮,且仍有段落在生成 → 退避重试
+      if (pollTimer === wasPolling && next.paragraphs.some(p => p.status === 'generating')) {
+        schedulePoll(pollAttempt + 1)
+      }
+    } catch (cause) {
+      if (disposed || (cause instanceof Error && cause.name === 'AbortError')) return
+      // 瞬时失败不退出 loading 门,退避后再试
+      error.value = friendlyArticleError(cause)
+      pollErrorShown = true
+      pollFailures += 1
+      if (pollFailures >= MAX_POLL_FAILURES) {
+        // 后端连续不应答,别再每 8 秒敲一次敲到学生关页面。横幅留在原地,
+        // report-loading 门那个手动重试按钮是学生的出口。
+        stopPolling()
+        return
+      }
+      schedulePoll(pollAttempt + 1)
+    } finally {
+      reportAbort = null
+      pollInFlight = false
+    }
+  }
+
+  // ---------------- audio ----------------
+
+  /**
+   * 全篇(示范 + 我的录音)共用一个 player,所以「点别的按钮」天然会 stop 掉上一段。
+   * 这里只负责另一半:**同一颗按钮再点一次 = 停**。loading 也要算进来,否则取流/
+   * 合成的那 1-3 秒里按钮按不停,只会排队再放一遍(对齐话题讨论 DialogueChatView:898)。
+   */
+  function isBusyWith(id: string): boolean {
+    return player.playingId.value === id || player.loadingId.value === id
+  }
+
+  async function playOwnAudio(idx: number) {
+    const id = `own-${idx}`
+    if (isBusyWith(id)) {
+      player.stop()
+      return
+    }
+    const localBlob = localAudioByParagraph.value.get(idx)
+    if (localBlob) {
+      await player.play(id, { kind: 'blob', blob: localBlob })
+      return
+    }
+    const url = report.value?.paragraphs.find(paragraph => paragraph.idx === idx)?.audioUrl
+    if (!url) return
+    await player.play(id, { kind: 'url', url })
+  }
+
+  async function playDemoAudio(idx: number) {
+    const id = `demo-${idx}`
+    if (isBusyWith(id)) {
+      player.stop()
+      return
+    }
+    const sessionId = report.value?.sessionId
+    if (!sessionId) return
+    const cacheKey = `${sessionId}:${idx}`
+    // 取 URL 交给 player(lazy-url),不在这儿先 await:缓存 miss 时后端同步合成要
+    // 1-3s,在外面等的话这段时间里上一段录音还在响、这颗按钮也不转圈 —— 学生看到的
+    // 就是「点了没反应,旧的还在放」。进了 play() 才有 stop() 和 loadingId。
+    await player.play(id, {
+      kind: 'lazy-url',
+      resolve: async () => {
+        const cached = demoUrlCache.get(cacheKey)
+        if (cached) return cached
+        try {
+          const res = await deps.getDemoAudio(sessionId, idx)
+          demoUrlCache.set(cacheKey, res.url)
+          return res.url
+        } catch (cause) {
+          // 横幅仍由引擎竖:player 只有 errorId,卡片上没有它的出口
+          error.value = friendlyArticleError(cause)
+          throw cause
+        }
+      },
+    })
+  }
+
+  function stopAudio() {
+    player.stop()
+  }
+
+  // ---------------- lifecycle ----------------
+
+  function reset() {
+    stopPolling()
+    pollFailures = 0
+    pollErrorShown = false
+    stopPracticeTimer()
+    reportAbort?.abort()
+    reportAbort = null
+    activeStream?.abort('Article session reset')
+    activeStream = null
+    player.stop()
+    recorder.onChunk.value = null
+    // 录音器必须真的拆掉,不能只摘 onChunk:mediaStream 的 track、AudioContext、
+    // AudioWorkletNode 和时长 interval 都还活着。留着的后果不是「泄漏一点内存」——
+    // 下一次 startRecording 会挂上第二个时长 interval(180s 单段安全阀在 ~90 真实秒就开火),
+    // 而孤儿 worklet 的 onmessage 闭包着同一个 chunk 缓冲与 onChunk ref,会继续把音频
+    // 灌进**下一段**录音,上传的 WAV 与 WS 流里都是两套采集图交织的声音。
+    // 放在 reset() 而不是 restartSession():configId 切换与老师端「应用配置」同样只走
+    // reset(),三条路一次修完;completeAndShowReport / cancelRecording 本就先过录音器。
+    recorder.cleanup()
+    recordedParagraphs.clear()
+    localAudioByParagraph.value.clear()
+    demoUrlCache.clear()
+    completionPromise = null
+    completionNotified = false
+    completionSucceeded = false
+    hasFetchedAfterComplete = false
+    finishingRecording = false
+    stage.value = 'ready'
+    report.value = null
+    currentParagraphIdx.value = 0
+    recordingState.value = 'idle'
+    elapsedPracticeSeconds.value = null
+    error.value = null
+    sessionInvalid.value = false
+    if (waitingForVisible) {
+      deps.document.removeEventListener('visibilitychange', onVisible)
+      waitingForVisible = false
+    }
+  }
+
+  function dispose() {
+    disposed = true
+    reset()
+  }
+
+  onUnmounted(() => dispose())
+
+  return {
+    stage,
+    starting,
+    report,
+    currentParagraphIdx,
+    recordingState,
+    recordingDuration: recorder.recordingDuration,
+    elapsedPracticeSeconds,
+    sessionEnded,
+    overallStarted,
+    canViewReport,
+    localAudioByParagraph,
+    error,
+    sessionInvalid,
+    startSession,
+    resumeFromReport,
+    startRecording,
+    finishRecording,
+    cancelRecording,
+    rerecordParagraph,
+    selectParagraph,
+    completeAndShowReport,
+    dismissError,
+    restartSession,
+    playOwnAudio,
+    playDemoAudio,
+    stopAudio,
+    playingId: player.playingId,
+    loadingId: player.loadingId,
+    reset,
+    dispose,
+  }
+}

+ 65 - 20
src/views/Editor/EnglishSpeaking/composables/useAudioPlayer.ts

@@ -5,6 +5,13 @@ export type PlaySource =
   | { kind: 'tts'; text: string }
   | { kind: 'blob'; blob: Blob }
   | { kind: 'url'; url: string }
+  /**
+   * URL 要先问一次后端才知道(文章示范音频:缓存 miss 时后端同步合成 1-3s)。
+   * 解析必须发生在 play() 内部,不能由调用方先 await 再进来 —— 只有进了 play()
+   * 才算「这一次播放开始了」:前一段音频才会被 stop(),按钮才转得起圈,
+   * 中途改点别的按钮时这次解析才会被 abort。
+   */
+  | { kind: 'lazy-url'; resolve: (signal: AbortSignal) => Promise<string> }
 
 export interface AudioPlayer {
   /** Id of the message currently playing (audio element fired `playing`). */
@@ -37,8 +44,26 @@ export function useAudioPlayer(): AudioPlayer {
   // Closure-private state. Not reactive on purpose.
   let currentAudio: HTMLAudioElement | null = null
   let synthAbort: AbortController | null = null
-  const ttsCache = new Map<string, Blob>()
-  const cachedUrls: string[] = []
+
+  /**
+   * 已经建好的 object URL,按「这段音频是什么」缓存。重复播放同一段因此既不重新
+   * 下载、也不再堆一个新的 object URL(原本每按一次播放都两样都做一遍,而 object
+   * URL 要到卸载才 revoke —— 学生把一段示范听五遍就是五次下载加五条泄漏)。
+   *
+   * key 用**解析出来的那条远端 URL**,不用 id:`own-{idx}` 这种 id 在学生重录之后
+   * 仍然是同一个,指向的却是新的 audioUrl,按 id 缓存会把上一次的录音放给他听。
+   * URL 变了就是另一个 key,这个失效模式从根上不存在。tts 没有 URL,按 `tts:${id}`。
+   */
+  const objectUrls = new Map<string, string>()
+  /** 本地录音(kind: 'blob')没有 URL 可当 key,按 Blob 身份缓存。 */
+  const blobObjectUrls = new WeakMap<Blob, string>()
+  /** 建过的全部 object URL,卸载时统一 revoke。 */
+  const createdUrls: string[] = []
+
+  function trackUrl(url: string): string {
+    createdUrls.push(url)
+    return url
+  }
 
   function clearCurrentAudio() {
     if (currentAudio) {
@@ -67,37 +92,57 @@ export function useAudioPlayer(): AudioPlayer {
     loadingId.value = id
 
     try {
-      let blob: Blob
+      let objectUrl: string
+
       if (source.kind === 'blob') {
-        blob = source.blob
+        let cached = blobObjectUrls.get(source.blob)
+        if (!cached) {
+          cached = trackUrl(URL.createObjectURL(source.blob))
+          blobObjectUrls.set(source.blob, cached)
+        }
+        objectUrl = cached
       }
-      else if (source.kind === 'url') {
+      else if (source.kind === 'url' || source.kind === 'lazy-url') {
         synthAbort = new AbortController()
-        const res = await fetch(source.url, { signal: synthAbort.signal })
-        synthAbort = null
+        const signal = synthAbort.signal
+        const remoteUrl = source.kind === 'url' ? source.url : await source.resolve(signal)
+        // 解析期间被打断(stop() 或换了个按钮)—— 那条 URL 不再属于任何一次播放
         if (loadingId.value !== id) return
-        if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
-        blob = await res.blob()
+
+        const cached = objectUrls.get(remoteUrl)
+        if (cached) {
+          // 第二次起完全不碰网络。lazy-url 的 resolve 也已经命中引擎那层的 URL 缓存,
+          // 所以整条路上只剩一次 Map 查询。
+          synthAbort = null
+          objectUrl = cached
+        }
+        else {
+          const res = await fetch(remoteUrl, { signal })
+          synthAbort = null
+          if (loadingId.value !== id) return
+          if (!res.ok) throw new Error(`fetch audio failed: ${res.status}`)
+          objectUrl = trackUrl(URL.createObjectURL(await res.blob()))
+          objectUrls.set(remoteUrl, objectUrl)
+        }
       }
       else {
-        const cached = ttsCache.get(id)
+        const key = `tts:${id}`
+        const cached = objectUrls.get(key)
         if (cached) {
-          blob = cached
+          objectUrl = cached
         }
         else {
           synthAbort = new AbortController()
-          blob = await synthesize(source.text, synthAbort.signal)
+          const blob = await synthesize(source.text, synthAbort.signal)
           synthAbort = null
           // We may have been interrupted while awaiting (loadingId changed).
           if (loadingId.value !== id) return
-          ttsCache.set(id, blob)
+          objectUrl = trackUrl(URL.createObjectURL(blob))
+          objectUrls.set(key, objectUrl)
         }
       }
 
-      const url = URL.createObjectURL(blob)
-      cachedUrls.push(url)
-
-      const audio = new Audio(url)
+      const audio = new Audio(objectUrl)
       currentAudio = audio
 
       audio.onplaying = () => {
@@ -153,11 +198,11 @@ export function useAudioPlayer(): AudioPlayer {
 
   onUnmounted(() => {
     stop()
-    for (const url of cachedUrls) {
+    for (const url of createdUrls) {
       try { URL.revokeObjectURL(url) } catch { /* ignore */ }
     }
-    cachedUrls.length = 0
-    ttsCache.clear()
+    createdUrls.length = 0
+    objectUrls.clear()
   })
 
   return {

+ 72 - 4
src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts

@@ -7,6 +7,46 @@ import { ref, onUnmounted } from 'vue'
  * (或 "All levels")即可看到。Safari / Firefox 同理。
  */
 
+/**
+ * 想要的采样率。Azure 发音评估原生就是 16kHz —— 送 48kHz 不会让评分更准,只是把
+ * 重采样挪到别人那边做,而每一段录音的三个成本都是 3 倍:
+ *
+ *            记忆体(180s 单声道)   上行频宽      S3 存储
+ *   48kHz         16.5 MB         96 KB/s        ×3
+ *   16kHz          5.5 MB         32 KB/s        ×1
+ *
+ * 一个班 30 人同时录,上行是 2.8 MB/s 对 0.9 MB/s —— 在学校 wifi 上这一项比记忆体
+ * 更容易先出事。
+ */
+const TARGET_SAMPLE_RATE = 16000
+
+/**
+ * 优先要 16kHz,拿不到就用硬件默认。
+ *
+ * 原来这里是写死的 `new AudioContext()`,注解写着「iOS Safari 不支持自定义采样率」。
+ * 那在 Safari 14.1(2021)之前是对的。但与其去赌教室里那台 iPad 的版本号,不如让
+ * 「赌输」变成一件无所谓的事 —— **整条链路带的都是真实采样率**:WS handshake 送
+ * `recorder.sampleRate.value`(useArticleReadingEngine.ts),WAV header 写
+ * `audioContext.sampleRate`(encodeWAV),后端的评估器格式与字节上限又是从那个
+ * handshake 推出来的。所以退回 48kHz 时一切照常,只是没省到。
+ *
+ * 两种失败都要接住,而且形状不同:
+ *   ① 构造抛(NotSupportedError)→ 这里 catch,退回默认;
+ *   ② 构造成功但浏览器无视这个提示,sampleRate 仍是硬件值 → 不需要任何处理,
+ *      因为下面读的是 `audioContext.sampleRate` 而不是 TARGET_SAMPLE_RATE。
+ * 千万别把 sampleRate.value 写成常数 —— 那会让 ② 变成一个静默的谎:后端按 16kHz
+ * 解 48kHz 的数据,回放速度慢三倍,而没有任何一层会报错。
+ */
+function createAudioContext(): AudioContext {
+  try {
+    return new AudioContext({ sampleRate: TARGET_SAMPLE_RATE })
+  }
+  catch (err) {
+    console.debug('[recorder] 16kHz AudioContext rejected, using hardware default', err)
+    return new AudioContext()
+  }
+}
+
 export function useAudioRecorder() {
   const isRecording = ref(false)
   const permissionState = ref<PermissionState>('prompt')
@@ -28,6 +68,9 @@ export function useAudioRecorder() {
   // [DEBUG] 跨 start/stop 共享的录音诊断计数器
   let debugChunkCount = 0
   let debugPeakFloat = 0
+  // 墙钟起点。用它而不是 recordingDuration —— 采集图若被建了两套,那个 interval 也会
+  // 跟着翻倍,两边一起错就比不出问题。samplesSeconds / wallSeconds 明显大于 1 = 采样重复。
+  let debugStartedAt = 0
 
   // Check permission state
   async function checkPermission() {
@@ -100,15 +143,21 @@ export function useAudioRecorder() {
       throw new DOMException('Aborted', 'AbortError')
     }
 
-    // 创建 AudioContext(不指定 sampleRate,用硬件默认 — iOS Safari 不支持自定义采样率)
-    audioContext = new AudioContext()
+    // 优先 16kHz(见 createAudioContext)。sampleRate.value 一定是**实际拿到的**那个,
+    // 不是我们要的那个 —— 浏览器可以无视这个提示,而下游全部按这个值走。
+    audioContext = createAudioContext()
     sampleRate.value = audioContext.sampleRate
+    // createMediaStreamSource 会由浏览器在 graph 层把麦克风的硬件采样率重采样到
+    // context 的采样率,带正规的低通。别改成自己抽样:无低通的降采样会混叠,而混叠
+    // 的表现是发音分数**普遍偏低** —— 没有错误、没有日志,只有「这批学生分数怎么低了」。
     const source = audioContext.createMediaStreamSource(mediaStream)
 
     // 诊断录音管线是否健康:mic track 状态 + AudioContext 状态
     console.debug('[recorder] startRecording', {
       contextState: audioContext.state,
+      requestedSampleRate: TARGET_SAMPLE_RATE,
       contextSampleRate: audioContext.sampleRate,
+      sampleRateHonoured: audioContext.sampleRate === TARGET_SAMPLE_RATE,
       tracks: mediaStream.getAudioTracks().map(t => ({
         label: t.label,
         enabled: t.enabled,
@@ -131,6 +180,7 @@ export function useAudioRecorder() {
     // 重置诊断计数器(很便宜,一直追踪;只有打印受 DevTools Verbose filter 控制)
     debugChunkCount = 0
     debugPeakFloat = 0
+    debugStartedAt = Date.now()
 
     // 收集 PCM 数据;同时(可选)通过 onChunk 把 int16 字节推给外部(WebSocket 流式上传)
     workletNode.port.onmessage = (e: MessageEvent<Float32Array>) => {
@@ -167,7 +217,10 @@ export function useAudioRecorder() {
       throw new Error('No active recording')
     }
 
-    // 通知 worklet 停止
+    // 通知 worklet 停止。onmessage 必须当场摘掉:'stop' 是投递到音频线程的异步消息,
+    // 在它生效之前音频线程还会再吐几块,那几块会落进**下一段**录音的 pcmChunks
+    // (pcmChunks 是跨 start/stop 复用的同一个闭包变量)。
+    workletNode.port.onmessage = null
     workletNode.port.postMessage('stop')
     workletNode.disconnect()
     workletNode = null
@@ -178,10 +231,16 @@ export function useAudioRecorder() {
     // 合并前打一条"整段录音的最终统计"—— peak_float ≈ 0 就是录音管线产零
     let totalSamples = 0
     for (const c of pcmChunks) totalSamples += c.length
+    const wallSeconds = (Date.now() - debugStartedAt) / 1000
+    const samplesSeconds = totalSamples / sampleRate
     console.debug('[recorder] stopRecording final', {
       chunks: debugChunkCount,
       totalSamples,
-      duration: (totalSamples / sampleRate).toFixed(2) + 's',
+      sampleRate,
+      duration: samplesSeconds.toFixed(2) + 's',
+      wallSeconds: wallSeconds.toFixed(2) + 's',
+      // 1.0 = 正常。≈2 就是同时跑着两套采集图,采样重复 → 回放慢一半且刺啦
+      sampleRatio: (samplesSeconds / Math.max(wallSeconds, 0.001)).toFixed(2),
       peakFloat: debugPeakFloat.toFixed(6),
       contextState: audioContext.state,
       trackMuted: mediaStream?.getAudioTracks()[0]?.muted,
@@ -202,6 +261,15 @@ export function useAudioRecorder() {
     if (durationTimer) { clearInterval(durationTimer); durationTimer = null }
     stopSilenceDetection()
 
+    // stopRecording() 之外的路径(reset / 卸载 / startRecording 失败)也会走到这里,
+    // 那时 worklet 还挂着 onmessage。不摘掉它,孤儿 worklet 会继续往 pcmChunks 灌音频。
+    if (workletNode) {
+      workletNode.port.onmessage = null
+      try { workletNode.port.postMessage('stop') } catch { /* context 已关闭 */ }
+      try { workletNode.disconnect() } catch { /* 已断开 */ }
+      workletNode = null
+    }
+
     if (audioContext) { audioContext.close().catch(() => {}); audioContext = null }
     if (mediaStream) {
       mediaStream.getTracks().forEach(t => t.stop())

+ 505 - 0
src/views/Editor/EnglishSpeaking/configs/ArticlePracticeConfig.vue

@@ -0,0 +1,505 @@
+<template>
+  <div class="article-practice-config">
+    <!-- 滚动内容区域 -->
+    <div class="config-scroll">
+
+      <div class="config-card">
+
+        <!-- 1. 文章标题 -->
+        <div class="form-group">
+          <label class="form-label">{{ lang.ssArticleTitle }}</label>
+          <input
+            type="text"
+            :value="store.config.title"
+            @input="store.config.title = ($event.target as HTMLInputElement).value"
+            class="form-input"
+            :placeholder="lang.ssArticleTitlePlaceholder as string"
+          />
+        </div>
+
+        <!-- 2. 文章正文 -->
+        <div class="form-group">
+          <div class="label-row">
+            <label class="form-label">{{ lang.ssArticleContent }}</label>
+            <button
+              class="btn-auto-format"
+              :disabled="!store.config.content.trim() || isFormatting"
+              @click="openOverwrite"
+            >
+              {{ lang.ssArticleAutoFormat }}
+            </button>
+          </div>
+          <textarea
+            ref="contentRef"
+            :value="store.config.content"
+            :disabled="isFormatting"
+            @input="onContentInput(($event.target as HTMLTextAreaElement).value)"
+            class="form-textarea"
+            :placeholder="lang.ssArticleContentPlaceholder as string"
+          ></textarea>
+        </div>
+
+        <!-- 3. 练习时长 -->
+        <div class="form-group slider-group">
+          <label class="sub-label">{{ lang.ssArticleTotalDuration }}</label>
+          <input
+            type="range"
+            :value="store.config.practice.duration"
+            min="1" max="60" step="1"
+            class="config-slider"
+            @input="store.config.practice.duration = Number(($event.target as HTMLInputElement).value)"
+          />
+          <div class="slider-labels">
+            <span class="slider-min">1 {{ lang.ssMinute }}</span>
+            <span class="slider-value">{{ store.config.practice.duration }} {{ lang.ssMinute }}</span>
+            <span class="slider-max">60 {{ lang.ssMinute }}</span>
+          </div>
+        </div>
+
+        <!-- 4. 展示学习报告 -->
+        <div class="toggle-row">
+          <span class="toggle-label">{{ lang.ssShowReport }}</span>
+          <button
+            class="toggle-switch"
+            :class="{ on: store.config.evaluation.showReport }"
+            @click="store.config.evaluation.showReport = !store.config.evaluation.showReport"
+          >
+            <span class="toggle-knob"></span>
+          </button>
+        </div>
+
+      </div>
+
+      <div class="bottom-spacer"></div>
+    </div>
+
+    <!-- 底部操作栏 —— 不置灰:有人在练也照常可按,后端 409 时弹确认框(spec §4.3 B) -->
+    <div class="config-footer">
+      <button
+        class="btn-apply"
+        :disabled="applying || isFormatting"
+        @click="handleApply"
+      >
+        <!-- 保存会同步把整篇示范音频合成好,正文改过的那次要几秒。按钮必须说出
+             它在等什么 —— 只转圈或只置灰,老师看到的是「应用配置卡住了」。 -->
+        <span v-if="applying" class="btn-apply__spinner"></span>
+        {{ applying ? lang.ssArticleGeneratingDemo : lang.ssApplyConfig }}
+      </button>
+    </div>
+
+    <ArticleFormatDialog
+      :visible="dialogVisible"
+      :mode="dialogMode"
+      :loading="isFormatting"
+      @confirm="confirmDialog"
+      @cancel="handleDialogCancel"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch } from 'vue'
+import { lang } from '@/main'
+import { useArticleReadingStore } from '@/store/articleReading'
+import useCreateElement from '@/hooks/useCreateElement'
+import { getSpeakingConfig } from '@/services/speaking'
+import { createArticleConfig, updateArticleConfig } from '../services/articleReading'
+import { articleActiveCountFromError, friendlyArticleError } from '../services/articleErrors'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import type { ArticleReadingConfig } from '@/types/articleReading'
+import message from '@/utils/message'
+import { showConfirmDialog } from '@/utils/confirmDialog'
+import ArticleFormatDialog from '../components/ArticleFormatDialog.vue'
+import { useArticleAutoFormat } from '../composables/useArticleAutoFormat'
+
+defineEmits<{
+  (e: 'back'): void
+}>()
+
+const store = useArticleReadingStore()
+const { createFrameElement } = useCreateElement()
+const applying = ref(false)
+
+// 自动分段(spec §3.2):三种触发共用一个弹框状态机
+const contentRef = ref<HTMLTextAreaElement | null>(null)
+const content = computed({
+  get: () => store.config.content,
+  set: (value: string) => {
+    store.config.content = value
+  },
+})
+const {
+  dialogVisible,
+  dialogMode,
+  isFormatting,
+  onContentInput,
+  openOverwrite,
+  guardBeforeApply,
+  confirmDialog,
+  cancelDialog,
+} = useArticleAutoFormat(content)
+
+// 守卫框的「手动修改」= 关框 + 焦点回文本框;其余 mode 只关框
+const handleDialogCancel = () => {
+  if (isFormatting.value) return
+  const wasGuard = dialogMode.value === 'guard'
+  cancelDialog()
+  if (wasGuard) nextTick(() => contentRef.value?.focus())
+}
+
+// 载入已有配置:竞态用 token 作废旧响应,防慢请求覆盖新选中的元素
+let loadToken = 0
+watch(() => store.activeConfigId, async (configId) => {
+  if (!configId) return
+  const token = ++loadToken
+  try {
+    const { config } = await getSpeakingConfig<ArticleReadingConfig>(configId)
+    if (token !== loadToken) return
+    if (config?.type !== 'article') {
+      console.warn('[article-reading] ignored non-article config:', configId)
+      return
+    }
+    store.replaceConfig(config)
+  } catch (err) {
+    if (token !== loadToken) return
+    console.error('[article-reading] load config failed:', err)
+  }
+}, { immediate: true })
+
+/**
+ * 保存到已有 config。返回 **false = 老师在确认框里选了取消**,一个字都没写。
+ *
+ * 409 是问句不是墙(spec §4.3 B,2026-07-24 定):detail 里带着按下那一刻的在练人数,
+ * 用它填确认框把后果说清楚 —— 那 N 个学生刷新会回到开始页、这一轮成绩不进班级名单 ——
+ * 老师选「仍要保存」就带 force 原样重发一次。绝不把 409 的原始 JSON 甩给老师
+ * (承 §9.2.5 那次教训:`request()` 抛的是 `response.text()`)。
+ */
+async function saveExistingConfig(configId: string): Promise<boolean> {
+  try {
+    await updateArticleConfig(configId, store.config)
+    return true
+  }
+  catch (err: unknown) {
+    const practising = articleActiveCountFromError(err)
+    if (practising === null) throw err // 别的错误照旧交给 handleApply 的 catch
+
+    const confirmed = await showConfirmDialog({
+      title: lang.ssArticlePractisingOverwriteTitle as string,
+      content: String(lang.ssArticlePractisingOverwriteMessage).replace('{n}', String(practising)),
+      confirmText: lang.ssArticlePractisingOverwriteConfirm as string,
+      cancelText: lang.ssArticlePractisingOverwriteCancel as string,
+    })
+    if (!confirmed) return false
+
+    await updateArticleConfig(configId, store.config, true)
+    return true
+  }
+}
+
+const handleApply = async () => {
+  if (applying.value) return
+  if (!store.config.title.trim() || !store.config.content.trim()) {
+    message.error(lang.ssArticleRequired as string)
+    return
+  }
+  // 换行归一化回写 + 单段 300 词硬守卫(spec §3.2 step 4);被拦下时弹框已开
+  if (!guardBeforeApply()) return
+
+  applying.value = true
+  try {
+    // 写入目标 = **面板正在编辑的那个 config**。`activeConfigId` 由两个入口置上:双击画布上的
+    // 文章朗读元素(`FrameElement`)、Layer2 插入新工具。面板的表单内容也是照它加载的
+    // (见下方 watch),所以它才是唯一正确的写入目标。
+    //
+    // 曾经的写法是「在当前幻灯片上 find 第一个 article frame」,那是错的(2026-07-27 实测撞到):
+    // 面板开着切页后,find 命中的是**另一个**元素 —— 把 A 的正文写进 B;一个都找不到时更糟,
+    // 掉进下面的新建分支凭空多一个 config,老师看到「保存成功」,而学生手上那个 config 一个字
+    // 都没变,服务端的在练检查也就只数了那个新 config 名下的 0 个人,怎么看都「拦不住」。
+    if (store.activeConfigId) {
+      // 文章专属的保存接口(spec §4.3 B):服务端在真正写入的那一刻再数一次在练人数 ——
+      // 面板不预先问、也不置灰,挡不住的正是「老师想了两分钟、期间有学生按了开始」这种时间差。
+      // 取消 = 什么都没发生:不重置预览、不弹「已保存」。
+      if (!await saveExistingConfig(store.activeConfigId)) return
+    } else {
+      // 面板没有可编辑的 config(两个入口都会带 id,正常进不到这里)。当作全新建。
+      // 走文章自己的 POST 而不是共用的那个:新建同样要把整篇示范音频合成好才写入,
+      // 否则这条兜底路生出来的 config 是唯一一份「学生点示范要等」的配置。
+      const { id } = await createArticleConfig(store.config)
+      createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
+      store.setActiveConfigId(id)
+    }
+    store.requestResetPreview()
+    message.success(lang.ssArticleSaved as string)
+  } catch (err: unknown) {
+    console.error('[article-reading] apply failed:', err)
+    // 「有人在练」那一条已经在 saveExistingConfig 里变成确认框了,走到这里的都是真错误
+    message.error(friendlyArticleError(err))
+  } finally {
+    applying.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.article-practice-config {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.config-scroll {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px;
+}
+
+.config-card {
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  padding: 16px;
+}
+
+.form-group {
+  margin-bottom: 12px;
+
+  &:last-child {
+    margin-bottom: 0;
+  }
+}
+
+.form-label {
+  display: block;
+  font-size: 12px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 6px;
+}
+
+.label-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 6px;
+
+  .form-label {
+    margin-bottom: 0;
+  }
+}
+
+/* enspeak: px-2.5 py-1 text-[11px] text-orange-600 bg-orange-50 hover:bg-orange-100
+   disabled:bg-gray-100 disabled:text-gray-400 rounded-md font-medium */
+.btn-auto-format {
+  padding: 4px 10px;
+  border: none;
+  border-radius: 6px;
+  font-size: 11px;
+  font-weight: 500;
+  color: #ea580c;
+  background: #fff7ed;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #ffedd5;
+  }
+
+  &:disabled {
+    background: #f3f4f6;
+    color: #9ca3af;
+    cursor: not-allowed;
+  }
+}
+
+.sub-label {
+  display: block;
+  font-size: 12px;
+  color: #6b7280;
+  margin-bottom: 6px;
+}
+
+.form-input {
+  width: 100%;
+  height: 36px;
+  padding: 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  color: #374151;
+  background: #fff;
+  transition: all 0.2s;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+}
+
+.form-textarea {
+  width: 100%;
+  height: 128px;
+  padding: 8px 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #374151;
+  background: #fff;
+  font-family: inherit;
+  resize: none;
+  transition: all 0.2s;
+  box-sizing: border-box;
+
+  &:focus {
+    outline: none;
+    border-color: #f97316;
+  }
+
+  &::placeholder {
+    color: #9ca3af;
+  }
+
+  &:disabled {
+    background: #f3f4f6;
+    color: #9ca3af;
+    cursor: not-allowed;
+  }
+}
+
+.config-slider {
+  width: 100%;
+  height: 6px;
+  border-radius: 3px;
+  appearance: none;
+  background: #e5e7eb;
+  cursor: pointer;
+  accent-color: #f97316;
+
+  &::-webkit-slider-thumb {
+    appearance: none;
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    background: #f97316;
+    cursor: pointer;
+    border: 2px solid #fff;
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+  }
+}
+
+.slider-labels {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 6px;
+  font-size: 12px;
+}
+
+.slider-min, .slider-max {
+  color: #9ca3af;
+}
+
+.slider-value {
+  font-weight: 500;
+  color: #374151;
+}
+
+.toggle-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 12px;
+}
+
+.toggle-label {
+  font-size: 12px;
+  color: #4b5563;
+}
+
+.toggle-switch {
+  position: relative;
+  width: 44px;
+  height: 24px;
+  border-radius: 12px;
+  border: none;
+  cursor: pointer;
+  transition: background 0.2s;
+  background: #d1d5db;
+
+  &.on {
+    background: #f97316;
+
+    .toggle-knob {
+      left: 22px;
+    }
+  }
+}
+
+.toggle-knob {
+  position: absolute;
+  top: 4px;
+  left: 4px;
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+  transition: left 0.2s;
+}
+
+.bottom-spacer {
+  height: 24px;
+}
+
+.config-footer {
+  border-top: 1px solid #f3f4f6;
+  padding: 12px 16px;
+  background: #fff;
+  flex-shrink: 0;
+}
+
+.btn-apply {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  width: 100%;
+  height: 36px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  color: #fff;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #ea580c;
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.btn-apply__spinner {
+  width: 12px;
+  height: 12px;
+  flex-shrink: 0;
+  border-radius: 50%;
+  border: 1.5px solid currentColor;
+  border-top-color: transparent;
+  animation: article-apply-spin 0.7s linear infinite;
+}
+
+@keyframes article-apply-spin {
+  to { transform: rotate(360deg); }
+}
+</style>

+ 16 - 0
src/views/Editor/EnglishSpeaking/data/articleReadingTasks.json

@@ -0,0 +1,16 @@
+{
+  "SHEP-5-上-Unit2": [
+    {
+      "id": "ar-unit2-zoo",
+      "taskType": "article-reading",
+      "titleZh": "动物园的一天",
+      "titleEn": "A Day at the Zoo",
+      "subtitle": "朗读一篇关于动物园游览经历的短文",
+      "difficulty": 2,
+      "count": 1,
+      "unit": "篇",
+      "durationMinutes": 10,
+      "content": "Last Sunday, I went to the zoo with my family. It was a sunny day and we were very excited.\n\nFirst, we saw the pandas. They were eating bamboo. The pandas were black and white, and they looked so cute. \"Look at the baby panda!\" I said. \"It is so small and round.\"\n\nThen, we went to see the monkeys. The monkeys were very funny. They jumped from tree to tree and made loud sounds. One little monkey was eating a banana.\n\nAfter that, we visited the bird house. There were many colorful birds. Some birds could talk! A parrot said \"Hello!\" to me. I was so surprised.\n\nAt the end of the day, we saw the elephants. They were the biggest animals in the zoo. The elephants had long noses and big ears. They used their noses to drink water. It was amazing!\n\nI love going to the zoo. I learned many things about animals."
+    }
+  ]
+}

+ 4 - 0
src/views/Editor/EnglishSpeaking/data/topicDiscussionTasks.json

@@ -2,6 +2,7 @@
   "SHEP-5-上-Unit2": [
     {
       "id": "td-unit2-1",
+      "taskType": "topic-discussion",
       "titleZh": "我的上学方式",
       "titleEn": "How I get to school",
       "subtitle": "和 AI 聊聊你每天怎么上学",
@@ -14,6 +15,7 @@
     },
     {
       "id": "td-unit2-2",
+      "taskType": "topic-discussion",
       "titleZh": "比较交通方式",
       "titleEn": "Comparing ways to travel",
       "subtitle": "讨论不同交通方式的优缺点",
@@ -26,6 +28,7 @@
     },
     {
       "id": "td-unit2-3",
+      "taskType": "topic-discussion",
       "titleZh": "家人的出行方式",
       "titleEn": "How my family gets around",
       "subtitle": "介绍家人上班上学的交通方式",
@@ -38,6 +41,7 @@
     },
     {
       "id": "td-unit2-4",
+      "taskType": "topic-discussion",
       "titleZh": "上学路上的见闻",
       "titleEn": "Things I see on my way to school",
       "subtitle": "分享上学路上看到的有趣事物",

+ 70 - 18
src/views/Editor/EnglishSpeaking/layers/Layer2Speaking.vue

@@ -76,13 +76,20 @@
 <script lang="ts" setup>
 import { ref, computed } from 'vue'
 import { lang } from '@/main'
-import type { CreationMode, TopicDiscussionTask } from '@/types/englishSpeaking'
+import type { CreationMode, SpeakingRecommendationTask, TopicDiscussionTask } from '@/types/englishSpeaking'
+import type { ArticleReadingTask } from '@/types/articleReading'
+import { ARTICLE_READING_TOOL_TYPE, TOPIC_DISCUSSION_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import { filterSpeakingTasks, type SpeakingTaskFilter } from '../preview/articleReadingModel'
 import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
 import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
 import { createSpeakingConfig } from '@/services/speaking'
+import { createArticleConfig } from '../services/articleReading'
+import { friendlyArticleError } from '../services/articleErrors'
 import message from '@/utils/message'
-import tasksData from '../data/topicDiscussionTasks.json'
+import topicTasksData from '../data/topicDiscussionTasks.json'
+import articleTasksData from '../data/articleReadingTasks.json'
 import CreationModeSwitch from '../components/CreationModeSwitch.vue'
 import RecommendCard from '../components/RecommendCard.vue'
 import AIGenerationForm from '../components/AIGenerationForm.vue'
@@ -96,14 +103,16 @@ const emit = defineEmits<{
 }>()
 
 const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
 
 const creationMode = ref<CreationMode>('smart')
-const selectedTaskType = ref('all')
+const selectedTaskType = ref<SpeakingTaskFilter>('all')
 
-// 任务类型筛选选项(本次仅口语 - 话题讨论)
+// 任务类型筛选选项
 const taskTypeOptions = [
   { id: 'all', name: lang.ssAll as string },
   { id: 'topic-discussion', name: lang.ssTopicDiscussion as string },
+  { id: 'article-reading', name: lang.ssArticleReading as string },
 ]
 
 // 手动创建的任务类型列表
@@ -114,20 +123,23 @@ const manualTaskTypes = [
     category: lang.ssFreeDialogue as string,
     icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
   },
+  {
+    id: 'article-reading',
+    name: lang.ssArticleReading as string,
+    category: lang.ssArticleReadAloud as string,
+    icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>',
+  },
 ]
 
-// 根据 unitId 获取推荐任务
-const unitTasks = computed<TopicDiscussionTask[]>(() => {
-  const data = tasksData as Record<string, TopicDiscussionTask[]>
-  return data[props.unitId] || []
+// 根据 unitId 获取推荐任务(话题讨论 + 文章朗读,靠 taskType 判别,不互相伪装)
+const unitTasks = computed<SpeakingRecommendationTask[]>(() => {
+  const topics = topicTasksData as Record<string, TopicDiscussionTask[]>
+  const articles = articleTasksData as Record<string, ArticleReadingTask[]>
+  return [...(topics[props.unitId] || []), ...(articles[props.unitId] || [])]
 })
 
 // 按任务类型筛选
-const filteredTasks = computed(() => {
-  if (selectedTaskType.value === 'all') return unitTasks.value
-  // 目前所有数据都是话题讨论类型,所以 topic-discussion 返回全部
-  return unitTasks.value
-})
+const filteredTasks = computed(() => filterSpeakingTasks(unitTasks.value, selectedTaskType.value))
 
 // 新交互:点卡片即新建一页 + 创建配置 + 插入画布元素;配置页改由"点击画布里的 77 型元素"唤起
 const { createFrameElement } = useCreateElement()
@@ -140,7 +152,7 @@ async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
   try {
     const { id } = await createSpeakingConfig(speakingStore.config)
     createSlide()
-    createFrameElement(id, 77)
+    createFrameElement(id, TOPIC_DISCUSSION_TOOL_TYPE)
     // 创建后立即唤起左侧配置面板(与点击画布 77 型 frame 同款信号),使用户可直接编辑
     speakingStore.openConfigPanel()
     message.success(source === 'select' ? '已添加口语练习到幻灯片' : '已添加空白口语工具,点击画布上的元素进行配置')
@@ -152,16 +164,56 @@ async function insertSpeakingToolToCanvas(source: 'select' | 'manual') {
   }
 }
 
+// 文章朗读走同一把 inserting 门闩:POST 成功后才建页与元素,失败不留孤立页面。
+// 用文章自己的建立接口而不是共用的 createSpeakingConfig:后端在那里同步把整篇示范
+// 音频合成好,全部成功才写入。所以「选推荐卡片」这条路(正文一出生就是满的)建完
+// 就已经烤热,学生点示范时是 S3 直接命中。这一步因此可能要几秒 —— inserting 门闩
+// 本来就在挡重复点击,按钮的 loading 态也照旧生效。
+async function insertArticleToolToCanvas(source: 'select' | 'manual') {
+  if (inserting.value) return
+  inserting.value = true
+  // 选推荐卡片这条路正文一出生就是满的,后端会当场把整篇示范音频合成好 —— 几秒。
+  // 这里的卡片点下去没有任何自身的 loading 态,不挂一条常驻提示,老师看到的就是
+  // 「点了没反应」,然后再点一次(inserting 门闩会吃掉,但他不知道)。
+  // 手动创建那条路正文是空的,后端立刻返回,提示一闪而过,无妨。
+  const pending = message.info(lang.ssArticleGeneratingDemo as string, { duration: 0 })
+  try {
+    const { id } = await createArticleConfig(articleStore.config)
+    createSlide()
+    createFrameElement(id, ARTICLE_READING_TOOL_TYPE)
+    articleStore.openConfigPanel(id)
+    message.success(source === 'select' ? lang.ssArticleTemplateCreated : lang.ssArticleManualCreated)
+  } catch (err: unknown) {
+    console.error('[article-reading] create failed:', err)
+    // 经 friendlyArticleError:合成失败时后端给的是 `Demo audio synthesis failed: [2]`,
+    // 原样甩出去老师看不懂,也不会知道配置根本没保存
+    message.error(friendlyArticleError(err))
+  } finally {
+    pending.close()
+    inserting.value = false
+  }
+}
+
 // 选择推荐卡片 → 预填充 store → 立即创建配置 + 插入元素
-const handleSelectTask = (task: TopicDiscussionTask) => {
+const handleSelectTask = (task: SpeakingRecommendationTask) => {
+  if (task.taskType === 'article-reading') {
+    articleStore.prefillFromTask(task, speakingStore.config.grade)
+    void insertArticleToolToCanvas('select')
+    return
+  }
   speakingStore.prefillFromTask(task.titleEn, task.vocabulary, task.sentences)
-  insertSpeakingToolToCanvas('select')
+  void insertSpeakingToolToCanvas('select')
 }
 
 // 手动创建 → 清空 store → 立即创建配置 + 插入元素
-const handleManualCreate = (_taskTypeId: string) => {
+const handleManualCreate = (taskTypeId: string) => {
+  if (taskTypeId === 'article-reading') {
+    articleStore.resetConfigEmpty(speakingStore.config.grade)
+    void insertArticleToolToCanvas('manual')
+    return
+  }
   speakingStore.resetConfigEmpty()
-  insertSpeakingToolToCanvas('manual')
+  void insertSpeakingToolToCanvas('manual')
 }
 </script>
 

+ 186 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleDetailedReport.vue

@@ -0,0 +1,186 @@
+<template>
+  <div class="article-report">
+    <div class="article-report__scroll">
+      <!-- 1. 整篇总评(还原 enspeak OverallReport scoreLayout="compactStars") -->
+      <ArticleOverallReport
+        :report="report"
+        :show-restart="showRestart"
+        @restart="emit('restart')"
+      />
+
+      <!-- 2. 逐段详情 -->
+      <div class="report-paragraphs">
+        <div class="report-paragraphs__head">
+          <span class="report-paragraphs__title">{{ lang.ssArticleParagraphsTitle }}</span>
+          <button
+            v-if="recordedIdxs.length"
+            class="report-paragraphs__toggle"
+            @click="toggleAll"
+          >
+            {{ allExpanded ? lang.ssArticleCollapseAll : lang.ssArticleExpandAll }}
+          </button>
+        </div>
+
+        <div
+          v-for="(paragraph, index) in sortedParagraphs"
+          :key="paragraph.idx"
+          class="paragraph-block"
+        >
+          <!-- 段落分隔线 -->
+          <div v-if="index > 0" class="paragraph-divider">
+            <span class="paragraph-divider__line"></span>
+            <span class="paragraph-divider__label">{{ paragraphLabel(index) }}</span>
+            <span class="paragraph-divider__line"></span>
+          </div>
+
+          <div class="paragraph-shell">
+            <ArticleParagraphCard
+              :paragraph="paragraph"
+              :expanded="expandedIdxs.has(paragraph.idx)"
+              :demo-audio-loading="loadingId === `demo-${paragraph.idx}`"
+              :demo-audio-playing="playingId === `demo-${paragraph.idx}`"
+              :own-audio-loading="loadingId === `own-${paragraph.idx}`"
+              :own-audio-playing="playingId === `own-${paragraph.idx}`"
+              :own-audio-available="Boolean(paragraph.audioUrl)"
+              @toggle="toggleOne(paragraph.idx)"
+              @play-demo="emit('play-demo', paragraph.idx)"
+              @play-own="emit('play-own', paragraph.idx)"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from 'vue'
+import { lang } from '@/main'
+import ArticleOverallReport from './ArticleOverallReport.vue'
+import ArticleParagraphCard from './ArticleParagraphCard.vue'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  report: ArticleReadingReport
+  playingId: string | null
+  loadingId: string | null
+  /** 学生自己的报告页给「再来一次」;老师端 StudentReportModal 不给。 */
+  showRestart?: boolean
+}>(), { showRestart: false })
+
+const emit = defineEmits<{
+  (event: 'play-demo', paragraphIdx: number): void
+  (event: 'play-own', paragraphIdx: number): void
+  (event: 'restart'): void
+}>()
+
+const sortedParagraphs = computed(() => [...props.report.paragraphs].sort((a, b) => a.idx - b.idx))
+
+// 只有已出分的段落有评价区可展开(原型同款)
+const recordedIdxs = computed(() =>
+  sortedParagraphs.value.filter(paragraph => paragraph.status === 'completed').map(p => p.idx),
+)
+
+const expandedIdxs = ref<Set<number>>(new Set())
+
+// 默认展开全部已录段落;换一份报告(老师逐个学生看)时重新初始化
+watch(
+  () => `${props.report.sessionId}|${recordedIdxs.value.join(',')}`,
+  () => {
+    expandedIdxs.value = new Set(recordedIdxs.value)
+  },
+  { immediate: true },
+)
+
+const allExpanded = computed(() =>
+  recordedIdxs.value.length > 0 && recordedIdxs.value.every(idx => expandedIdxs.value.has(idx)),
+)
+
+function toggleAll() {
+  expandedIdxs.value = allExpanded.value ? new Set() : new Set(recordedIdxs.value)
+}
+
+function toggleOne(idx: number) {
+  const next = new Set(expandedIdxs.value)
+  if (next.has(idx)) next.delete(idx)
+  else next.add(idx)
+  expandedIdxs.value = next
+}
+
+function paragraphLabel(index: number): string {
+  return String(lang.ssArticleParagraphIndex).replace('{n}', String(index + 1))
+}
+</script>
+
+<style lang="scss" scoped>
+.article-report {
+  width: 100%;
+  height: 100%;
+  background: #fff;
+  overflow: hidden;
+}
+
+.article-report__scroll {
+  height: 100%;
+  overflow-y: auto;
+  padding: 24px;
+  max-width: 720px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.report-paragraphs__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+
+.report-paragraphs__title {
+  font-size: 12px;
+  font-weight: 500;
+  color: #6b7280;
+}
+
+.report-paragraphs__toggle {
+  border: none;
+  background: transparent;
+  padding: 0;
+  font-size: 10px;
+  color: #f97316;
+  cursor: pointer;
+  transition: color 0.2s;
+
+  &:hover { color: #ea580c; }
+}
+
+// 段落之间的「第 N 段」分隔线
+.paragraph-divider {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 12px 0;
+}
+
+.paragraph-divider__line {
+  flex: 1;
+  height: 1px;
+  background: #e5e7eb;
+}
+
+.paragraph-divider__label {
+  font-size: 9px;
+  color: #9ca3af;
+  white-space: nowrap;
+}
+
+.paragraph-shell {
+  padding: 16px;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  background: #fff;
+}
+</style>

+ 357 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleOverallReport.vue

@@ -0,0 +1,357 @@
+<template>
+  <div class="overall-report">
+    <!-- 完成提示(enspeak OverallReport.tsx:178-184) -->
+    <div class="completion-header">
+      <div class="completion-emoji">🎉</div>
+      <h2 class="completion-title">{{ lang.ssArticleReportDone }}</h2>
+      <p class="completion-subtitle">{{ subtitle }}</p>
+    </div>
+
+    <template v-if="overall">
+      <!-- 综合评分:五星紧凑横排 + 两列维度星级(scoreLayout="compactStars") -->
+      <div class="score-card">
+        <div class="score-card__head">
+          <div class="score-card__summary">
+            <span class="score-card__label">{{ lang.ssArticleOverallScore }}</span>
+            <ArticleStarRating :score="overall.overallScore" size="lg" step="half" />
+            <span class="score-card__value">{{ overallStars }} {{ lang.ssArticlePointsUnit }}</span>
+          </div>
+          <span class="score-card__praise">{{ praise }}</span>
+        </div>
+
+        <div class="score-card__dims">
+          <div v-for="dim in dimRows" :key="dim.key" class="score-card__dim">
+            <span class="score-card__dim-label">{{ dim.label }}</span>
+            <ArticleStarRating :score="dim.score" size="sm" step="half" />
+          </div>
+        </div>
+      </div>
+
+      <!-- AI 点评 -->
+      <div class="comment-card">
+        <div class="comment-card__inner">
+          <div class="comment-card__avatar">👨‍🏫</div>
+          <div class="comment-card__body">
+            <div class="comment-card__name">{{ lang.ssArticleTeacherSays }}</div>
+            <p class="comment-card__text">{{ overall.aiComment }}</p>
+          </div>
+        </div>
+      </div>
+
+      <!-- 亮点与建议 -->
+      <div class="notes-grid">
+        <div class="note-card">
+          <div class="note-card__head">
+            <span class="note-card__icon note-card__icon--good">✓</span>
+            {{ lang.ssArticleHighlights }}
+          </div>
+          <div class="note-card__list">
+            <div v-for="(item, index) in overall.highlights.slice(0, 3)" :key="`h-${index}`" class="note-card__item">
+              • {{ item }}
+            </div>
+          </div>
+        </div>
+
+        <div class="note-card">
+          <div class="note-card__head">
+            <span class="note-card__icon note-card__icon--warn">→</span>
+            {{ lang.ssArticleSuggestions }}
+          </div>
+          <div class="note-card__list">
+            <div v-for="(item, index) in overall.suggestions.slice(0, 3)" :key="`s-${index}`" class="note-card__item">
+              • {{ item }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </template>
+
+    <!-- 软失败 / 无总评:不挡住逐段结果(spec §4.1 边界 5) -->
+    <div v-else class="report-notice">
+      {{ report.overallStatus === 'failed' ? lang.ssArticleOverallFailed : lang.ssArticleOverallMissing }}
+    </div>
+
+    <!-- 再来一次(enspeak OverallReport.tsx:301-309;老师端看学生报告时不给这个出口) -->
+    <div v-if="showRestart" class="report-actions">
+      <button class="restart-btn" @click="emit('restart')">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+          <polyline points="23 4 23 10 17 10" />
+          <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
+        </svg>
+        {{ lang.ssArticleTryAgain }}
+      </button>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import ArticleStarRating from './ArticleStarRating.vue'
+import { scoreToStars } from './articleReadingModel'
+import type { ArticleReadingReport } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  report: ArticleReadingReport
+  /** 学生自己的报告页给「再来一次」;老师端 StudentReportModal 不给。 */
+  showRestart?: boolean
+}>(), { showRestart: false })
+
+const emit = defineEmits<{ restart: [] }>()
+
+const overall = computed(() => props.report.overall)
+
+const overallStars = computed(() => Math.round(scoreToStars(overall.value?.overallScore ?? null) * 2) / 2)
+
+// 星级 → 鼓励文案(enspeak scoreToPraise)
+const praise = computed(() => {
+  const stars = overallStars.value
+  if (stars >= 5) return lang.ssArticlePraiseExcellent as string
+  if (stars >= 4) return lang.ssArticlePraiseGreat as string
+  if (stars >= 3) return lang.ssArticlePraiseGood as string
+  return lang.ssArticlePraiseKeepGoing as string
+})
+
+const dimRows = computed(() => {
+  const dims = overall.value?.dims
+  return [
+    { key: 'accuracy', label: lang.ssAccuracy as string, score: dims?.accuracy ?? null },
+    { key: 'fluency', label: lang.ssFluency as string, score: dims?.fluency ?? null },
+    { key: 'completeness', label: lang.ssCompleteness as string, score: dims?.completeness ?? null },
+    { key: 'prosody', label: lang.ssRhythm as string, score: dims?.prosody ?? null },
+  ]
+})
+
+const readCount = computed(() =>
+  props.report.paragraphs.filter(paragraph => paragraph.status === 'completed').length,
+)
+
+// 原型副标题是「与 X 对话 N 轮 · 时长」,朗读没有轮次,换成同位置的篇名 + 已读段数 + 时长
+const subtitle = computed(() => {
+  const parts: string[] = []
+  if (props.report.title) parts.push(props.report.title)
+  parts.push(`${lang.ssArticleReadCount}:${readCount.value} / ${props.report.paragraphs.length} ${lang.ssArticleParagraphsUnit}`)
+  if (props.report.totalDurationSeconds !== null) {
+    parts.push(`${lang.ssArticleTotalDuration}:${formatDuration(props.report.totalDurationSeconds)}`)
+  }
+  return parts.join(' · ')
+})
+
+function formatDuration(seconds: number): string {
+  const mm = Math.floor(seconds / 60)
+  const ss = seconds % 60
+  return mm > 0 ? `${mm} ${lang.ssMinute} ${ss} ${lang.ssArticleSecondsUnit}` : `${ss} ${lang.ssArticleSecondsUnit}`
+}
+</script>
+
+<style lang="scss" scoped>
+// 原型是 max-w-md mx-auto space-y-3 的窄栏,报告页外层已限宽,这里只保留纵向节奏
+.overall-report {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+// ---- 完成提示 ----
+.completion-header {
+  text-align: center;
+}
+
+.completion-emoji {
+  font-size: 30px;
+  line-height: 1.2;
+  margin-bottom: 4px;
+}
+
+.completion-title {
+  margin: 0;
+  font-size: 16px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.completion-subtitle {
+  margin: 2px 0 0;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #6b7280;
+}
+
+// ---- 综合评分卡 ----
+.score-card {
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+  overflow: hidden;
+}
+
+.score-card__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 14px 16px;
+  border-bottom: 1px solid #f3f4f6;
+  background: linear-gradient(to right, #fff7ed, #fffdf9);
+}
+
+.score-card__summary {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.score-card__label {
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.score-card__value {
+  font-size: 12px;
+  font-weight: 600;
+  color: #f59e0b;
+  font-variant-numeric: tabular-nums;
+}
+
+.score-card__praise {
+  font-size: 12px;
+  font-weight: 600;
+  color: #f59e0b;
+}
+
+.score-card__dims {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  column-gap: 16px;
+  row-gap: 10px;
+  padding: 12px 16px;
+}
+
+.score-card__dim {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.score-card__dim-label {
+  font-size: 12px;
+  color: #4b5563;
+}
+
+// ---- AI 点评 ----
+.comment-card {
+  padding: 12px;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+}
+
+.comment-card__inner {
+  display: flex;
+  gap: 10px;
+}
+
+.comment-card__avatar {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: #fff7ed;
+  font-size: 16px;
+}
+
+.comment-card__body {
+  flex: 1;
+  min-width: 0;
+}
+
+.comment-card__name {
+  margin-bottom: 4px;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.comment-card__text {
+  margin: 0;
+  font-size: 12px;
+  line-height: 1.65;
+  color: #374151;
+}
+
+// ---- 亮点与建议 ----
+.notes-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 8px;
+}
+
+.note-card {
+  padding: 10px;
+  border: 1px solid #f3f4f6;
+  border-radius: 12px;
+  background: #fff;
+}
+
+.note-card__head {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin-bottom: 6px;
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.note-card__icon--good { color: #22c55e; }
+.note-card__icon--warn { color: #f59e0b; }
+
+.note-card__list {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.note-card__item {
+  font-size: 10px;
+  line-height: 1.5;
+  color: #4b5563;
+}
+
+// ---- 软失败提示 ----
+.report-notice {
+  padding: 12px 16px;
+  border: 1px solid #fed7aa;
+  border-radius: 12px;
+  background: #fff7ed;
+  font-size: 12px;
+  color: #c2410c;
+}
+
+// ---- 再来一次 ----
+.report-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-top: 4px;
+}
+
+.restart-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 24px;
+  border: none;
+  border-radius: 8px;
+  background: #f97316;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+</style>

+ 409 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleParagraphCard.vue

@@ -0,0 +1,409 @@
+<template>
+  <div class="paragraph-card">
+    <!-- 段落原文 -->
+    <p class="paragraph-card__text" :class="{ 'paragraph-card__text--muted': !isCompleted }">
+      {{ paragraph.text }}
+    </p>
+
+    <!-- 星级 / 状态 + 操作按钮(enspeak ArticleDetailedReport.tsx:108-169) -->
+    <div class="paragraph-card__meta">
+      <div class="paragraph-card__status">
+        <template v-if="isCompleted">
+          <ArticleStarRating :score="paragraph.overallScore" size="md" step="full" />
+          <span class="status-tag status-tag--done">
+            <svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+              <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+            </svg>
+            {{ lang.ssArticleRecordedTag }}
+          </span>
+        </template>
+
+        <span v-else-if="paragraph.status === 'generating'" class="status-tag status-tag--generating">
+          <span class="status-spinner"></span>
+          {{ lang.ssArticleProcessing }}
+        </span>
+
+        <span v-else-if="paragraph.status === 'failed'" class="status-tag status-tag--failed">
+          {{ lang.ssArticleEvalFailed }}
+        </span>
+      </div>
+
+      <div class="paragraph-card__actions" :class="{ 'paragraph-card__actions--locked': !interactive }">
+        <!-- 系统朗读示范:任何状态都可听(后端按 reference_text 合成) -->
+        <button
+          class="act-btn act-btn--demo"
+          :class="{ playing: demoAudioPlaying }"
+          :disabled="demoAudioLoading"
+          :tabindex="interactive ? undefined : -1"
+          :aria-pressed="demoAudioPlaying"
+          :title="lang.ssArticleDemoAudio as string"
+          @click.stop="emit('play-demo')"
+        >
+          <span v-if="demoAudioLoading" class="act-btn__spinner"></span>
+          <svg v-else width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+            <path d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" />
+          </svg>
+          {{ lang.ssArticleDemoAudio }}
+        </button>
+
+        <!-- 我的录音回放:仅已录 -->
+        <button
+          v-if="ownAudioAvailable"
+          class="act-btn act-btn--own"
+          :class="{ playing: ownAudioPlaying }"
+          :disabled="ownAudioLoading"
+          :tabindex="interactive ? undefined : -1"
+          :aria-pressed="ownAudioPlaying"
+          :title="lang.ssArticleMyAudio as string"
+          @click.stop="emit('play-own')"
+        >
+          <span v-if="ownAudioLoading" class="act-btn__spinner"></span>
+          <svg v-else width="14" height="14" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
+          </svg>
+          {{ lang.ssArticleMyAudio }}
+        </button>
+
+        <!-- 展开评价:容器负责状态,这里只是开关 -->
+        <button
+          v-if="isCompleted"
+          class="expand-btn"
+          :class="{ open: expanded }"
+          :tabindex="interactive ? undefined : -1"
+          :title="lang.ssArticleViewEval as string"
+          @click.stop="emit('toggle')"
+        >
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 展开的评价区 -->
+    <div v-if="expanded && isCompleted" class="paragraph-card__evaluation">
+      <!-- 段落总评:四维横排五星 -->
+      <div class="evaluation-block">
+        <p class="evaluation-block__title">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
+          </svg>
+          {{ lang.ssArticleParagraphSummary }}
+        </p>
+        <div class="dims-row">
+          <template v-for="(dim, index) in dimRows" :key="dim.key">
+            <span v-if="index > 0" class="dims-row__sep">|</span>
+            <span class="dims-row__item">
+              <span class="dims-row__label">{{ dim.label }}</span>
+              <ArticleStarRating :score="dim.score" size="sm" step="full" />
+            </span>
+          </template>
+        </div>
+      </div>
+
+      <!-- 整段反馈:一条连续词流,不分句 -->
+      <div class="evaluation-block">
+        <p class="evaluation-block__title">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
+            <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+          </svg>
+          {{ lang.ssArticleWordFeedback }}
+        </p>
+        <p class="word-stream"><span
+          v-for="(token, index) in words"
+          :key="`${index}-${token.word}`"
+          class="article-word"
+          :class="`article-word--${token.status}`"
+        ><template v-if="token.status === 'omission'">[{{ token.word }}]</template><template v-else>{{ token.word }}</template>{{ ' ' }}</span></p>
+
+        <!-- 词级统计栏:每一项自带该类词的视觉标记 -->
+        <div class="word-legend">
+          <span class="article-word article-word--good">word {{ lang.ssArticleWordCorrect }} {{ counts.good }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--misread">word {{ lang.ssArticleWordMisread }} {{ counts.misread }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--omission">[word] {{ lang.ssArticleWordOmission }} {{ counts.omission }} {{ lang.ssArticleWordsUnit }}</span>
+          <span class="word-legend__sep">|</span>
+          <span class="article-word article-word--insertion">word {{ lang.ssArticleWordInsertion }} {{ counts.insertion }} {{ lang.ssArticleWordsUnit }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { lang } from '@/main'
+import ArticleStarRating from './ArticleStarRating.vue'
+import type { ArticleParagraphResult } from '@/types/articleReading'
+
+const props = withDefaults(defineProps<{
+  paragraph: ArticleParagraphResult
+  expanded: boolean
+  demoAudioLoading: boolean
+  demoAudioPlaying: boolean
+  ownAudioLoading: boolean
+  ownAudioPlaying: boolean
+  ownAudioAvailable: boolean
+  /**
+   * 卡内按钮是否接管点击。练习页只有**选中的那一段**是 true —— 其余段落整张卡
+   * 只做一件事:把自己选中。报告页没有「当前段」的概念,全部可点。
+   */
+  interactive?: boolean
+}>(), { interactive: true })
+
+const emit = defineEmits<{
+  (event: 'toggle'): void
+  (event: 'play-demo'): void
+  (event: 'play-own'): void
+}>()
+
+const isCompleted = computed(() => props.paragraph.status === 'completed')
+
+// 后端给的就是段内扁平词级(spec §6),不再有句层可展平。
+const words = computed(() => props.paragraph.words)
+
+/**
+ * 统计栏只数「自带标记」的四类:正确(绿)/ 错读(波浪线)/ 漏读([])/ 多读(删除线)。
+ * warn / bad 在词流里照常着色,但它们没有独立标记,原型的统计栏也不含这两档。
+ */
+const counts = computed(() => {
+  const tally = { good: 0, misread: 0, omission: 0, insertion: 0 }
+  for (const token of words.value) {
+    if (token.status in tally) tally[token.status as keyof typeof tally] += 1
+  }
+  return tally
+})
+
+const dimRows = computed(() => {
+  const dims = props.paragraph.dims
+  return [
+    { key: 'accuracy', label: lang.ssAccuracy as string, score: dims?.accuracy ?? null },
+    { key: 'fluency', label: lang.ssFluency as string, score: dims?.fluency ?? null },
+    { key: 'completeness', label: lang.ssCompleteness as string, score: dims?.completeness ?? null },
+    { key: 'prosody', label: lang.ssRhythm as string, score: dims?.prosody ?? null },
+  ]
+})
+</script>
+
+<style lang="scss" scoped>
+.paragraph-card__text {
+  margin: 0;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #1f2937;
+
+  &--muted { color: #9ca3af; }
+}
+
+.paragraph-card__meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-top: 12px;
+  flex-wrap: wrap;
+}
+
+.paragraph-card__status {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.paragraph-card__actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+
+  // 未选中的段落:这一下点击是「我要练这一段」,别让按钮把它吃掉。
+  // pointer-events 而不是 disabled —— 事件要能冒泡到外层的段落壳去选中,
+  // 而且 disabled 的半透明叠在整卡已有的 opacity 上会糊成一团。
+  &--locked {
+    pointer-events: none;
+  }
+}
+
+// ---- 状态药丸 ----
+.status-tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 8px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 500;
+  white-space: nowrap;
+
+  &--done {
+    background: #ecfdf5;
+    color: #059669;
+  }
+
+  &--generating {
+    background: #fff7ed;
+    color: #ea580c;
+  }
+
+  &--failed {
+    background: #fef2f2;
+    color: #dc2626;
+  }
+}
+
+// ---- 操作按钮 ----
+.act-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 5px 10px;
+  border-radius: 8px;
+  font-size: 10px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s, border-color 0.2s;
+
+  &:disabled {
+    opacity: 0.45;
+    cursor: not-allowed;
+  }
+
+  &--demo {
+    border: 1px solid #fed7aa;
+    background: #fff7ed;
+    color: #ea580c;
+
+    &:hover:not(:disabled) { background: #ffedd5; }
+    &.playing { background: #ffedd5; border-color: #fdba74; }
+  }
+
+  &--own {
+    border: 1px solid #bfdbfe;
+    background: #eff6ff;
+    color: #2563eb;
+
+    &:hover:not(:disabled) { background: #dbeafe; }
+    &.playing { background: #dbeafe; border-color: #93c5fd; }
+  }
+}
+
+.act-btn__spinner,
+.status-spinner {
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  border: 1.5px solid currentColor;
+  border-top-color: transparent;
+  animation: article-card-spin 0.7s linear infinite;
+}
+
+@keyframes article-card-spin {
+  to { transform: rotate(360deg); }
+}
+
+.expand-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #f97316;
+  cursor: pointer;
+  transition: background 0.2s, transform 0.2s;
+
+  &:hover { background: #ffedd5; }
+  &.open { transform: rotate(180deg); }
+}
+
+// ---- 展开的评价区 ----
+.paragraph-card__evaluation {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid #e5e7eb;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.evaluation-block__title {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  margin: 0 0 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: #9ca3af;
+
+  svg {
+    color: #fb923c;
+    opacity: 0.6;
+    flex-shrink: 0;
+  }
+}
+
+// 四维横排,维度之间用浅灰竖线分隔
+.dims-row {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  font-size: 11px;
+  color: #6b7280;
+}
+
+.dims-row__item {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.dims-row__sep {
+  color: #e5e7eb;
+}
+
+.word-stream {
+  margin: 0;
+  font-size: 14px;
+  line-height: 1.75;
+  color: #374151;
+}
+
+.article-word {
+  font-weight: 500;
+}
+
+.article-word--good { color: #047857; }
+.article-word--warn { color: #d97706; }
+.article-word--bad { color: #dc2626; }
+
+.article-word--misread {
+  color: #dc2626;
+  text-decoration-line: underline;
+  text-decoration-style: wavy;
+  text-decoration-color: #f87171;
+}
+
+.article-word--omission { color: #dc2626; }
+
+.article-word--insertion {
+  color: #dc2626;
+  text-decoration: line-through;
+  text-decoration-color: #f87171;
+}
+
+.word-legend {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-top: 8px;
+  font-size: 11px;
+}
+
+.word-legend__sep {
+  color: #d1d5db;
+}
+</style>

+ 408 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue

@@ -0,0 +1,408 @@
+<template>
+  <div class="article-reading-preview">
+    <!-- resume 探针在途:隐藏起始页,避免刷新闪开始页 -->
+    <div v-if="resuming" class="article-report-loading">
+      <span class="spinner"></span>
+    </div>
+
+    <!-- ready -->
+    <div v-else-if="engine.stage.value === 'ready'" class="article-ready-stage">
+      <h1 class="ready-title">{{ articleStore.config.title || lang.ssArticleReading }}</h1>
+      <p v-if="hasArticle" class="ready-summary">{{ articleSummary }}</p>
+      <p v-else class="ready-placeholder">{{ lang.ssArticlePleaseConfigure }}</p>
+
+      <p v-if="hasArticle" class="ready-meta">
+        {{ articleStore.config.practice.mode === 'time'
+          ? `${articleStore.config.practice.duration} ${lang.ssMinute}`
+          : lang.ssArticleUnlimitedLabel }}
+      </p>
+
+      <p v-if="readyError" class="article-error">{{ readyError }}</p>
+
+      <button class="start-btn" :disabled="startDisabled" @click="engine.startSession">
+        {{ lang.ssArticleStart }}
+      </button>
+    </div>
+
+    <!-- reading -->
+    <ArticleReadingView
+      v-else-if="engine.stage.value === 'reading' && engine.report.value"
+      :paragraphs="engine.report.value.paragraphs"
+      :current-paragraph-idx="engine.currentParagraphIdx.value"
+      :recording-state="engine.recordingState.value"
+      :recording-duration="engine.recordingDuration.value"
+      :elapsed-practice-seconds="engine.elapsedPracticeSeconds.value"
+      :show-report="showReport"
+      :session-ended="engine.sessionEnded.value"
+      :overall-started="engine.overallStarted.value"
+      :can-view-report="engine.canViewReport.value"
+      :local-audio-idxs="localAudioIdxs"
+      :playing-id="engine.playingId.value"
+      :loading-id="engine.loadingId.value"
+      :error="readingError"
+      @select-paragraph="engine.selectParagraph"
+      @start-recording="engine.startRecording"
+      @finish-recording="engine.finishRecording"
+      @cancel-recording="engine.cancelRecording"
+      @play-demo="engine.playDemoAudio"
+      @play-own="engine.playOwnAudio"
+      @view-report="engine.completeAndShowReport"
+      @restart="handleRestart"
+      @dismiss-error="engine.dismissError"
+    />
+
+    <!-- report-loading:一次性显示门,不暴露半成品报告 -->
+    <div v-else-if="engine.stage.value === 'report-loading'" class="article-report-loading">
+      <span class="spinner"></span>
+      <p class="loading-text">{{ lang.ssArticleGeneratingReport }}</p>
+      <p v-if="engine.error.value" class="article-error">{{ engine.error.value }}</p>
+      <button v-if="engine.error.value" class="retry-btn" @click="engine.completeAndShowReport">
+        {{ lang.ssArticleRetry }}
+      </button>
+    </div>
+
+    <!-- report -->
+    <ArticleDetailedReport
+      v-else-if="engine.report.value"
+      :report="engine.report.value"
+      :playing-id="engine.playingId.value"
+      :loading-id="engine.loadingId.value"
+      show-restart
+      @play-demo="engine.playDemoAudio"
+      @play-own="engine.playOwnAudio"
+      @restart="handleRestart"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, toRef, inject, onUnmounted } from 'vue'
+import { lang } from '@/main'
+import ArticleReadingView from './ArticleReadingView.vue'
+import ArticleDetailedReport from './ArticleDetailedReport.vue'
+import { useArticleReadingEngine } from '../composables/useArticleReadingEngine'
+import { useArticleReadingStore } from '@/store/articleReading'
+import { getSpeakingConfig } from '@/services/speaking'
+import { getLatestArticleSession } from '../services/articleReading'
+import { friendlyArticleError } from '../services/articleErrors'
+import { ARTICLE_READING_TOOL_TYPE } from '@/configs/englishSpeakingTools'
+import message from '@/utils/message'
+import type { ArticleReadingConfig } from '@/types/articleReading'
+
+const props = defineProps<{
+  configId: string
+}>()
+
+const articleStore = useArticleReadingStore()
+const loadError = ref<string | null>(null)
+
+// 'active' 覆盖两种「这一轮尚未完成」:真正开始,以及重新开始把旧轮作废。
+// 老师端忽略这个值、只是据此重取,所以关键在于广播有没有发出去。
+type SpeakingNotify = (
+  status: 'active' | 'completed',
+  payload: { configId: string; sessionId: string },
+) => void
+const notifySpeakingProgress = inject<SpeakingNotify>('notifySpeakingProgress', () => {})
+const recordSpeakingStart = inject<(sessionId: string, toolType?: number) => void>(
+  'recordSpeakingStart',
+  () => {},
+)
+
+// **两道门缺一不可**:`type` 说这个人是什么身份,`userid` 才说他是谁。
+// 只看 userid 会把老师也算进去 —— 编辑器 URL(App.vue:103)自己就带着 userid。
+//
+// 身份用项目既有的 `type`(`Student/index.vue:625`:老师端 type=1、学生端 type=2),
+// 不用 `mode`:`mode=student` 是**视图**不是身份,老师上课时走的也是这个视图
+// (班级面板就渲染在里面)。拿视图当身份,老师在上课页试录就会被算成学生 —— 正是
+// 本次要修的那个 bug 换个地方复发。`type` 是身份,与哪个视图被渲染出来无关。
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return { role: params.get('type'), userId: params.get('userid') || '' }
+})
+const isStudentRuntime = computed(() => runtimeParams.value.role === '2')
+const runtimeUserId = computed(() => runtimeParams.value.userId)
+/** 传给后端的身份:编辑态是 null = 老师预览(spec §4.5),不进任何统计。 */
+const sessionUserId = computed(() => (isStudentRuntime.value ? runtimeUserId.value : null))
+
+/**
+ * 缺省为 true。store 用 Object.assign 合并服务端配置,所以一行 `evaluation: {}` 会把
+ * 默认值冲掉、留下 undefined —— 而 undefined 落到的是最严的那一档(一次机会、永久只读、
+ * 无报告、老师端也没有重置入口)。现有写入方都带这个键,但失败方向不对,值一行钱。
+ */
+const showReport = computed(() => articleStore.config.evaluation.showReport !== false)
+
+const engine = useArticleReadingEngine({
+  configId: toRef(props, 'configId'),
+  userId: sessionUserId,
+  durationMinutes: computed(() => articleStore.config.practice.duration),
+  showReport,
+  onSessionStarted(sessionId) {
+    if (!isStudentRuntime.value) return
+    notifySpeakingProgress('active', { configId: props.configId, sessionId })
+    recordSpeakingStart(sessionId, ARTICLE_READING_TOOL_TYPE)
+  },
+  onSessionCompleted(sessionId) {
+    if (!isStudentRuntime.value) return
+    notifySpeakingProgress('completed', { configId: props.configId, sessionId })
+  },
+  onSessionAbandoned(sessionId) {
+    if (!isStudentRuntime.value) return
+    // 库里这一轮已经不作数了,但老师端只在收到学生广播时重取。不发这一条,他会一直
+    // 看着一个「已完成 + 分数」,点进去还是作废那轮的报告(spec §4.2)。
+    notifySpeakingProgress('active', { configId: props.configId, sessionId })
+  },
+})
+
+const localAudioIdxs = computed(() => [...engine.localAudioByParagraph.value.keys()])
+
+/**
+ * 练习页要显示的错误文案。后端 409 = 这一轮已被作废,重试没有意义,
+ * 直接换成「请重新开始」,而不是把 `{"detail":"..."}` 原样甩给学生。
+ */
+const readingError = computed(() => {
+  if (engine.sessionInvalid.value) return lang.ssArticleSessionExpired as string
+  return engine.error.value
+})
+
+async function handleRestart() {
+  const ok = await engine.restartSession()
+  if (!ok) {
+    message.error(lang.ssArticleRestartFailed as string)
+    return
+  }
+  // 与另外两处 reset() 调用点一致:作废在途的 resume 探针,否则它落地时会把刚清掉的
+  // 那一轮又水合回来。今天走不到(探针只在 resuming 遮住 UI 时跑),但这是唯一一条
+  // 不作废探针的 reset 路径,留着就是下一个人的坑。
+  resumeToken += 1
+}
+
+const hasArticle = computed(() =>
+  Boolean(articleStore.config.title.trim() && articleStore.config.content.trim()),
+)
+
+const articleSummary = computed(() => {
+  const content = articleStore.config.content.trim()
+  return content.length > 120 ? `${content.slice(0, 120)}...` : content
+})
+
+/** 还没开始就已知的阻塞原因:配置没加载出来、或学生态缺 userid。这两条也决定按钮是否可点。 */
+const startBlockedReason = computed(() => {
+  if (loadError.value) return loadError.value
+  // 编辑态没有身份是正常的(那就是老师预览);只有学生态缺 userid 才是真的进不去。
+  if (hasArticle.value && isStudentRuntime.value && !runtimeUserId.value) {
+    return lang.ssArticleMissingUser as string
+  }
+  return null
+})
+
+/**
+ * ready 页面真正渲染的那句话(spec §9.2.5 A2)。
+ * 除了「按不下去的原因」,还必须包含「按下去了但失败了」—— 此前 engine.error 在这个 stage
+ * 根本没有出口,`POST /session` 一失败学生看到的就是一片空白:按钮弹回、什么都没说。
+ */
+const readyError = computed(() => startBlockedReason.value || engine.error.value)
+
+// 只看阻塞原因,不看 engine.error:开始失败之后学生应该还能再点一次。
+// `engine.starting` 是例外 —— POST 在途时必须置灰,否则双击会建出两条 session
+// (后端 get-or-create 无锁,见 useArticleReadingEngine.startSession 的注释)。
+const startDisabled = computed(
+  () => !hasArticle.value || engine.starting.value || Boolean(startBlockedReason.value),
+)
+
+async function loadConfig() {
+  loadError.value = null
+  if (!props.configId) return
+  try {
+    const record = await getSpeakingConfig<ArticleReadingConfig>(props.configId)
+    if (record.config.type !== 'article') {
+      throw new Error('Speaking config is not an article')
+    }
+    articleStore.replaceConfig(record.config)
+  } catch (cause) {
+    console.error('[article-reading] load config failed:', cause)
+    // 共用端点的 parse() 抛的是 `[404] {"detail":"..."}`,直接渲染就是把原始 JSON
+    // 甩给用户。走同一层:命中就是中文,命不中也是兜底文案,不会是响应体。
+    loadError.value = friendlyArticleError(cause)
+  }
+}
+
+// resume 探针在途标记:为 true 时隐藏 ready 起始页,避免刷新瞬间闪一下开始页
+// (对齐话题讨论的 checking-history)。仅学生端(有 userid)才探测。
+const resuming = ref(false)
+// 作废在途 resume:configId 变化 / 老师重置时递增,丢弃过期探针结果
+let resumeToken = 0
+
+async function maybeResumeSession(token: number) {
+  // 编辑态不探测:预览 session 的 user_id 是 NULL,后端按定义查不到(spec §4.5),
+  // 而 GET /sessions/latest 对空 userId 仍然回 400 —— 发出去只会在控制台留一条噪音。
+  if (!isStudentRuntime.value) return
+  if (!props.configId || !runtimeUserId.value) return
+  try {
+    const { session } = await getLatestArticleSession(props.configId, runtimeUserId.value)
+    if (token !== resumeToken || !session) return
+    // 有历史 session → 跳过起始页,直接回到朗读 / 结果页
+    engine.resumeFromReport(session)
+    notifySpeakingProgress(
+      session.status === 'completed' ? 'completed' : 'active',
+      { configId: props.configId, sessionId: session.sessionId },
+    )
+  }
+  catch (cause) {
+    console.error('[article-reading] resume probe failed:', cause)
+    // 探针失败 → 保持 ready 起始页,学生可手动开始
+  }
+  finally {
+    if (token === resumeToken) resuming.value = false
+  }
+}
+
+watch(() => props.configId, async () => {
+  const token = ++resumeToken
+  engine.reset()
+  resuming.value = Boolean(props.configId && isStudentRuntime.value && runtimeUserId.value)
+  await loadConfig()
+  if (token !== resumeToken) return
+  await maybeResumeSession(token)
+}, { immediate: true })
+
+// 把阶段同步进 store,供 CanvasTool 决定「重置预览」显不显示(镜像话题讨论
+// TopicDiscussionPreview 的 setPreviewState)。`immediate` 不能省:切页换实例时新预览
+// 一挂就得把 store 拨回 ready,否则按钮沿用上一个实例的阶段。
+watch(() => engine.stage.value, (stage) => {
+  articleStore.setPreviewStage(stage)
+}, { immediate: true })
+
+// 老师在配置面板点应用 → 预览回到 ready 并重载(编辑态不 resume,见 maybeResumeSession)
+watch(() => articleStore.resetSignal, () => {
+  resumeToken += 1
+  resuming.value = false
+  engine.reset()
+  void loadConfig()
+})
+
+onUnmounted(() => engine.dispose())
+</script>
+
+<style lang="scss" scoped>
+.article-reading-preview {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  background: #fff;
+  overflow: hidden;
+}
+
+// ---- ready ----
+.article-ready-stage {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 32px;
+  text-align: center;
+}
+
+.ready-title {
+  margin: 0;
+  font-size: 24px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.ready-summary {
+  margin: 0;
+  max-width: 560px;
+  font-size: 14px;
+  line-height: 1.7;
+  color: #6b7280;
+}
+
+.ready-placeholder {
+  margin: 0;
+  font-size: 14px;
+  color: #9ca3af;
+}
+
+.ready-meta {
+  margin: 0;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.start-btn {
+  margin-top: 8px;
+  padding: 10px 32px;
+  border: none;
+  border-radius: 999px;
+  background: #f97316;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.25);
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+
+  &:disabled {
+    background: #d1d5db;
+    box-shadow: none;
+    cursor: not-allowed;
+  }
+}
+
+// ---- report loading ----
+.article-report-loading {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 32px;
+  text-align: center;
+}
+
+.spinner {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  border: 3px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-preview-spin 0.8s linear infinite;
+}
+
+@keyframes article-preview-spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  margin: 0;
+  font-size: 14px;
+  color: #6b7280;
+}
+
+.article-error {
+  margin: 0;
+  font-size: 12px;
+  color: #dc2626;
+}
+
+.retry-btn {
+  padding: 6px 20px;
+  border: 1px solid #fdba74;
+  border-radius: 999px;
+  background: #fff;
+  color: #ea580c;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #fff7ed; }
+}
+</style>

+ 829 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleReadingView.vue

@@ -0,0 +1,829 @@
+<template>
+  <div class="article-reading-view">
+    <!-- 顶部操作栏 -->
+    <div class="reading-topbar">
+      <div class="reading-topbar__progress">
+        {{ progressLabel }}
+      </div>
+      <div class="reading-topbar__right">
+        <span class="timer-pill">
+          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+            <rect x="9.5" y="1.5" width="5" height="2.2" rx="1" fill="#6B7280" />
+            <circle cx="12" cy="14" r="8.2" fill="#F3F4F6" stroke="#9CA3AF" stroke-width="1.8" />
+            <path d="M12 14V9.4" stroke="#6B7280" stroke-width="1.8" stroke-linecap="round" />
+            <path d="M12 14l3 1.8" stroke="#6B7280" stroke-width="1.8" stroke-linecap="round" />
+          </svg>
+          {{ timerLabel }}
+        </span>
+        <button
+          class="icon-btn"
+          :title="lang.ssArticleMore as string"
+          :disabled="readOnly"
+          @click="showExitConfirm = true"
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="5" cy="12" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" />
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 段落列表:当前段全不透明,其余淡出 -->
+    <div ref="listRef" class="reading-list">
+      <div class="reading-list__inner">
+        <div
+          v-for="paragraph in paragraphs"
+          :key="paragraph.idx"
+          :ref="el => setCardRef(paragraph.idx, el)"
+          class="paragraph-shell"
+          :class="{ 'paragraph-shell--current': paragraph.idx === currentParagraphIdx }"
+          @click="selectParagraph(paragraph.idx)"
+        >
+          <!-- 「已读」只由卡内那颗状态药丸说(已录 / 处理中 / 评估失败)。
+               这里曾经还有一个绝对定位的打勾徽章,和绿色药丸说的是同一件事。 -->
+          <ArticleParagraphCard
+            :paragraph="paragraph"
+            :expanded="isExpanded(paragraph)"
+            :interactive="paragraph.idx === currentParagraphIdx"
+            :demo-audio-loading="loadingId === `demo-${paragraph.idx}`"
+            :demo-audio-playing="playingId === `demo-${paragraph.idx}`"
+            :own-audio-loading="loadingId === `own-${paragraph.idx}`"
+            :own-audio-playing="playingId === `own-${paragraph.idx}`"
+            :own-audio-available="hasOwnAudio(paragraph)"
+            @toggle="toggleExpanded"
+            @play-demo="emit('play-demo', paragraph.idx)"
+            @play-own="emit('play-own', paragraph.idx)"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 错误横幅:麦克风 / 示范音频 / 轮询失败都靠它露出。
+         到点 complete 那条重试动作已随 §4.4 移除 —— 判定权在后端,前端没有会失败的
+         complete 需要重试;学生只需要看见发生了什么,然后再点一次原来那个按钮。 -->
+    <div v-if="error" class="reading-error" role="alert">
+      <span class="reading-error__text">{{ error }}</span>
+      <button class="reading-error__dismiss" @click="emit('dismiss-error')">
+        {{ lang.ssArticleDismiss }}
+      </button>
+    </div>
+
+    <!-- 底部录音栏:idle / recording / processing / done 四态 -->
+    <div class="reading-bottombar">
+      <div class="bottombar-progress">
+        <div class="bottombar-progress__track" :class="{ active: recordingState === 'recording' }">
+          <div class="bottombar-progress__fill" :class="{ active: recordingState === 'recording' }"></div>
+        </div>
+      </div>
+
+      <div class="bottombar-states">
+        <!-- idle -->
+        <div class="bottombar-state bottombar-state--idle" :class="{ visible: recordingState === 'idle' }">
+          <button v-if="!isFirstParagraph" class="nav-btn" @click="goPrevious">
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M15 18l-6-6 6-6" />
+            </svg>
+            {{ lang.ssArticlePrevious }}
+          </button>
+          <div v-else class="nav-spacer"></div>
+
+          <div class="bottombar-center">
+            <button class="record-btn" :disabled="busy || readOnly" @click="emit('start-recording')">
+              <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M12 14a3 3 0 003-3V5a3 3 0 00-6 0v6a3 3 0 003 3z" />
+                <path d="M19 11a7 7 0 01-14 0H3a9 9 0 008 8.94V23h2v-3.06A9 9 0 0021 11h-2z" />
+              </svg>
+              {{ currentRecorded ? lang.ssArticleRerecordBtn : lang.ssArticleStartRecord }}
+            </button>
+            <button v-if="currentRecorded" class="replay-btn" :title="lang.ssArticleReplay as string" @click="emit('play-own', currentParagraphIdx)">
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
+                <path d="M8 5v14l11-7z" />
+              </svg>
+              {{ lang.ssArticleReplay }}
+            </button>
+          </div>
+
+          <button v-if="canViewReport && showReport" class="report-btn" @click="emit('view-report')">
+            {{ lang.ssArticleViewReport }}
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M5 12h14M13 6l6 6-6 6" />
+            </svg>
+          </button>
+          <button v-else-if="!isLastParagraph" class="nav-btn" @click="goNext">
+            {{ lang.ssArticleNext }}
+            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M5 12h14M13 6l6 6-6 6" />
+            </svg>
+          </button>
+          <div v-else class="nav-spacer"></div>
+        </div>
+
+        <!-- recording -->
+        <div class="bottombar-state bottombar-state--recording" :class="{ visible: recordingState === 'recording' }">
+          <div class="recording-pill">
+            <button class="cancel-btn" :disabled="busy" @click="emit('cancel-recording')">
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+              </svg>
+              {{ lang.ssArticleCancelRecord }}
+            </button>
+            <div class="recording-indicator">
+              <span class="recording-dot"></span>
+              <span class="recording-time">{{ formatTime(recordingDuration) }}</span>
+            </div>
+            <button class="finish-btn" :disabled="busy" @click="emit('finish-recording')">
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+                <path d="M20 6L9 17l-5-5" />
+              </svg>
+              {{ lang.ssArticleFinishRecord }}
+            </button>
+          </div>
+        </div>
+
+        <!-- processing。原型第四态「完成 ✓」不存在于此:ack 一到就打回 idle 并跳下一段
+             (决策 A),没有可以停留的时刻,详见 ArticleRecordingState 的注释 -->
+        <div class="bottombar-state bottombar-state--centered" :class="{ visible: recordingState === 'processing' }">
+          <span class="state-spinner"></span>
+          <span class="state-text">{{ lang.ssArticleProcessing }}</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 退出确认 -->
+    <div v-if="showExitConfirm" class="exit-overlay" @click.self="showExitConfirm = false">
+      <div class="exit-dialog">
+        <div class="exit-dialog__head">
+          <h3 class="exit-dialog__title">{{ lang.ssArticleExitTitle }}</h3>
+          <button class="exit-dialog__close" @click="showExitConfirm = false">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+            </svg>
+          </button>
+        </div>
+        <p class="exit-dialog__hint">{{ lang.ssArticleExitHint }}</p>
+        <div class="exit-dialog__actions">
+          <button class="exit-dialog__secondary" @click="showExitConfirm = false">
+            {{ lang.ssArticleContinuePractice }}
+          </button>
+          <button class="exit-dialog__secondary" @click="handleRestart">
+            {{ lang.ssArticleRestart }}
+          </button>
+          <button v-if="showReport" class="exit-dialog__primary" @click="confirmExit">
+            {{ lang.ssArticleEndAndReport }}
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch, nextTick } from 'vue'
+import { lang } from '@/main'
+import ArticleParagraphCard from './ArticleParagraphCard.vue'
+import type { ArticleParagraphResult } from '@/types/articleReading'
+import type { ArticleRecordingState } from '../composables/useArticleReadingEngine'
+
+const props = defineProps<{
+  paragraphs: ArticleParagraphResult[]
+  currentParagraphIdx: number
+  recordingState: ArticleRecordingState
+  recordingDuration: number
+  elapsedPracticeSeconds: number | null
+  canViewReport: boolean
+  localAudioIdxs: number[]
+  playingId: string | null
+  loadingId: string | null
+  showReport: boolean
+  sessionEnded: boolean
+  /** 总评已开始生成 → 后端已不接受重录,练习页转只读。 */
+  overallStarted: boolean
+  /** 已本地化的错误文案;null = 无错。 */
+  error: string | null
+}>()
+
+// 具名元组式,不是调用签名式:事件多到这个数量后,调用签名的重载联合会把参数类型
+// 推断成 `any[] | unknown[]`,父组件那边每个带参数的处理器都会报不可赋值。
+const emit = defineEmits<{
+  'select-paragraph': [idx: number]
+  'start-recording': []
+  'finish-recording': []
+  'cancel-recording': []
+  'play-demo': [idx: number]
+  'play-own': [idx: number]
+  'view-report': []
+  'restart': []
+  'dismiss-error': []
+}>()
+
+const showExitConfirm = ref(false)
+/**
+ * 展开态只跟着「当前段」走:选中的那一段只要评完了就默认摊开,学生不用再点一次箭头。
+ * 这个 ref 是学生手动收起当前段的一次性覆盖,换段即失效 —— 非当前段的箭头本来就点不到
+ * (interactive=false),所以不需要按 idx 记一整张表。
+ */
+const currentCollapsed = ref(false)
+const listRef = ref<HTMLElement | null>(null)
+const cardRefs = new Map<number, HTMLElement>()
+
+function setCardRef(idx: number, el: unknown) {
+  if (el instanceof HTMLElement) cardRefs.set(idx, el)
+  else cardRefs.delete(idx)
+}
+
+// 录音/处理中禁止重复触发
+const busy = computed(() => props.recordingState === 'processing')
+
+// 两条路都把练习页转为只读:
+//   ① 总评已经开始生成 —— 后端在这之后拒绝重录(409 `Report already generated`,2026-07-27)。
+//      **两种模式都成立**:不锁的话那颗录音按钮按下去必然报错。
+//   ② 关闭「展示学习报告」且这一轮已结束(spec §9.2.1)。只禁 ⋯ 而留录音会让学生
+//      「能改成绩却不能重来」,老师端状态也在完成/进行中之间反复跳,所以一并禁用。
+const readOnly = computed(() =>
+  props.overallStarted || (!props.showReport && props.sessionEnded)
+)
+
+const isFirstParagraph = computed(() => props.currentParagraphIdx <= 0)
+const isLastParagraph = computed(() => props.currentParagraphIdx >= props.paragraphs.length - 1)
+
+const progressLabel = computed(() =>
+  String(lang.ssArticleParagraphProgress)
+    .replace('{cur}', String(props.currentParagraphIdx + 1))
+    .replace('{total}', String(props.paragraphs.length)),
+)
+
+const timerLabel = computed(() =>
+  props.elapsedPracticeSeconds === null
+    ? (lang.ssArticleUnlimitedLabel as string)
+    : formatTime(props.elapsedPracticeSeconds),
+)
+
+// 分钟不补零,还原 enspeak ArticleReadingView.tsx:276-280 的 `${mins}:${secs.padStart(2,'0')}`。
+// 录音药丸共用本函数,原型同样共用,所以录音计时一并变成 0:07 —— 是还原,不是副作用。
+function formatTime(seconds: number): string {
+  const safe = Math.max(0, Math.floor(seconds))
+  const mm = String(Math.floor(safe / 60))
+  const ss = String(safe % 60).padStart(2, '0')
+  return `${mm}:${ss}`
+}
+
+function isRecorded(paragraph: ArticleParagraphResult): boolean {
+  return paragraph.status !== 'pending' || props.localAudioIdxs.includes(paragraph.idx)
+}
+
+function hasOwnAudio(paragraph: ArticleParagraphResult): boolean {
+  return props.localAudioIdxs.includes(paragraph.idx) || Boolean(paragraph.audioUrl)
+}
+
+const currentRecorded = computed(() => {
+  const paragraph = props.paragraphs.find(p => p.idx === props.currentParagraphIdx)
+  return paragraph ? isRecorded(paragraph) : false
+})
+
+function selectParagraph(idx: number) {
+  if (idx === props.currentParagraphIdx) return
+  emit('select-paragraph', idx)
+}
+
+function isExpanded(paragraph: ArticleParagraphResult): boolean {
+  return paragraph.idx === props.currentParagraphIdx && !currentCollapsed.value
+}
+
+function toggleExpanded() {
+  currentCollapsed.value = !currentCollapsed.value
+}
+
+function goPrevious() {
+  if (!isFirstParagraph.value) selectParagraph(props.currentParagraphIdx - 1)
+}
+
+function goNext() {
+  if (!isLastParagraph.value) selectParagraph(props.currentParagraphIdx + 1)
+}
+
+function confirmExit() {
+  showExitConfirm.value = false
+  emit('view-report')
+}
+
+function handleRestart() {
+  showExitConfirm.value = false
+  emit('restart')
+}
+
+// 选中段自动滚到视野中央。收起状态一并归零 —— 换段不只有点击一条路,
+// 录完一段后引擎也会自己往下推(advanceAfterAck),那条路不经过 selectParagraph。
+watch(() => props.currentParagraphIdx, async idx => {
+  currentCollapsed.value = false
+  await nextTick()
+  cardRefs.get(idx)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+})
+</script>
+
+<style lang="scss" scoped>
+.article-reading-view {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+
+// ---- 顶部栏 ----
+.reading-topbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-bottom: 1px solid #f3f4f6;
+  flex-shrink: 0;
+}
+
+.reading-topbar__progress {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+}
+
+.reading-topbar__right {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.timer-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #f9fafb;
+  color: #4b5563;
+  font-size: 13px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
+.icon-btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  padding: 6px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: #9ca3af;
+  cursor: pointer;
+  transition: background 0.2s, color 0.2s;
+
+  &:hover {
+    background: #f3f4f6;
+    color: #4b5563;
+  }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+  }
+}
+
+// ---- 段落列表 ----
+.reading-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
+}
+
+.reading-list__inner {
+  max-width: 672px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.paragraph-shell {
+  position: relative;
+  padding: 16px;
+  border: 2px solid #e5e7eb;
+  border-radius: 8px;
+  background: #fff;
+  cursor: pointer;
+  opacity: 0.4;
+  transition: all 0.3s;
+
+  &:hover { opacity: 0.6; border-color: #d1d5db; }
+
+  &--current {
+    border-color: #f97316;
+    background: #fff7ed;
+    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+    transform: scale(1.02);
+    opacity: 1;
+
+    &:hover { opacity: 1; border-color: #f97316; }
+  }
+}
+
+// ---- 错误横幅(spec §9.2.3)----
+.reading-error {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 0 16px 8px;
+  padding: 8px 12px;
+  border: 1px solid #fecaca;
+  border-radius: 8px;
+  background: #fef2f2;
+}
+
+.reading-error__text {
+  flex: 1;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #b91c1c;
+}
+
+.reading-error__action,
+.reading-error__dismiss {
+  flex-shrink: 0;
+  padding: 4px 10px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.reading-error__action {
+  border: 1px solid #fdba74;
+  background: #fff;
+  color: #ea580c;
+
+  &:hover {
+    background: #fff7ed;
+  }
+}
+
+.reading-error__dismiss {
+  border: none;
+  background: transparent;
+  color: #9ca3af;
+
+  &:hover {
+    background: #fee2e2;
+  }
+}
+
+// ---- 底部录音栏 ----
+.reading-bottombar {
+  flex-shrink: 0;
+  border-top: 1px solid #f3f4f6;
+  background: #fff;
+  padding: 10px 16px 12px;
+}
+
+.bottombar-progress {
+  max-width: 384px;
+  margin: 0 auto 8px;
+}
+
+.bottombar-progress__track {
+  height: 1px;
+  border-radius: 999px;
+  overflow: hidden;
+  background: transparent;
+
+  &.active { background: #f3f4f6; }
+}
+
+.bottombar-progress__fill {
+  height: 100%;
+  width: 0;
+  opacity: 0;
+  border-radius: 999px;
+  background: #fb923c;
+  transition: width 1s linear, opacity 0.2s ease;
+
+  &.active { width: 100%; opacity: 1; }
+}
+
+.bottombar-states {
+  position: relative;
+  max-width: 384px;
+  height: 36px;
+  margin: 0 auto;
+}
+
+.bottombar-state {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.18s ease-out;
+
+  &.visible {
+    opacity: 1;
+    pointer-events: auto;
+  }
+
+  &--idle {
+    justify-content: space-between;
+    gap: 4px;
+  }
+
+  &--centered {
+    justify-content: center;
+    gap: 8px;
+    pointer-events: none;
+  }
+}
+
+.bottombar-center {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.nav-spacer {
+  width: 60px;
+  flex-shrink: 0;
+}
+
+.nav-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #6b7280;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+
+  &:hover {
+    background: #f9fafb;
+    border-color: #d1d5db;
+    color: #4b5563;
+  }
+}
+
+.report-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+
+.record-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 24px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  box-shadow: 0 4px 6px -1px rgba(249, 115, 22, 0.25);
+  transition: background 0.2s, transform 0.15s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+  &:active:not(:disabled) { transform: scale(0.95); }
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.replay-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 14px;
+  border-radius: 999px;
+  border: 1px solid #fdba74;
+  background: #fff;
+  color: #ea580c;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, transform 0.15s;
+
+  &:hover { background: #fff7ed; }
+  &:active { transform: scale(0.95); }
+}
+
+.recording-pill {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  padding: 6px 10px;
+  border-radius: 999px;
+  border: 1px solid #f3f4f6;
+  background: #f9fafb;
+}
+
+.recording-indicator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  flex: 1;
+}
+
+.recording-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #ef4444;
+  animation: article-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes article-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.35; }
+}
+
+.recording-time {
+  font-size: 12px;
+  font-weight: 600;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  color: #374151;
+}
+
+.cancel-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: 1px solid #e5e7eb;
+  background: #fff;
+  color: #6b7280;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s, border-color 0.2s, color 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #fef2f2;
+    border-color: #fecaca;
+    color: #ef4444;
+  }
+
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.finish-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  border: none;
+  background: #f97316;
+  color: #fff;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  flex-shrink: 0;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) { background: #ea580c; }
+  &:disabled { opacity: 0.6; cursor: not-allowed; }
+}
+
+.state-spinner {
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  border: 2px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-spin 0.7s linear infinite;
+}
+
+@keyframes article-spin {
+  to { transform: rotate(360deg); }
+}
+
+.state-text {
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+// ---- 退出确认 ----
+.exit-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  background: rgba(0, 0, 0, 0.3);
+  backdrop-filter: blur(2px);
+}
+
+.exit-dialog {
+  width: 100%;
+  max-width: 320px;
+  padding: 24px;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
+}
+
+.exit-dialog__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 4px;
+}
+
+.exit-dialog__title {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.exit-dialog__close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 8px;
+  background: #f3f4f6;
+  color: #6b7280;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #e5e7eb; }
+}
+
+.exit-dialog__hint {
+  margin: 0 0 16px;
+  font-size: 12px;
+  color: #9ca3af;
+}
+
+.exit-dialog__actions {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.exit-dialog__secondary {
+  width: 100%;
+  padding: 10px 0;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  background: transparent;
+  color: #4b5563;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #f9fafb; }
+}
+
+.exit-dialog__primary {
+  width: 100%;
+  padding: 10px 0;
+  border: none;
+  border-radius: 12px;
+  background: #f97316;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #ea580c; }
+}
+</style>

+ 60 - 0
src/views/Editor/EnglishSpeaking/preview/ArticleStarRating.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="star-rating" :class="`star-rating--${size}`" role="img" :aria-label="ariaLabel">
+    <div class="star-rating__track" aria-hidden="true">★★★★★</div>
+    <div class="star-rating__fill" :style="{ width: `${fillPercent}%` }" aria-hidden="true">★★★★★</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { scoreToStars } from './articleReadingModel'
+
+/**
+ * 还原 enspeak 的两种星级口径(`OverallReport.tsx:19` / `ArticleDetailedReport.tsx:12`):
+ * 总评用 0.5 步进(避免 88 和 96 都落到 5 星),段落卡用整星。
+ * `exact` 是本组件原有的连续填充,班级面板继续用它。
+ */
+const props = withDefaults(defineProps<{
+  score: number | null
+  size?: 'xs' | 'sm' | 'md' | 'lg'
+  step?: 'exact' | 'half' | 'full'
+}>(), { size: 'md', step: 'exact' })
+
+const stars = computed(() => {
+  const raw = scoreToStars(props.score)
+  if (props.step === 'half') return Math.round(raw * 2) / 2
+  if (props.step === 'full') return Math.round(raw)
+  return raw
+})
+
+const fillPercent = computed(() => (stars.value / 5) * 100)
+const ariaLabel = computed(() => `${stars.value} out of 5 stars`)
+</script>
+
+<style lang="scss" scoped>
+.star-rating {
+  position: relative;
+  display: inline-block;
+  line-height: 1;
+  white-space: nowrap;
+  letter-spacing: 1px;
+
+  &--xs { font-size: 11px; }
+  &--sm { font-size: 14px; }
+  &--md { font-size: 16px; }
+  &--lg { font-size: 18px; }
+}
+
+// 空星 gray-200 / 实星 amber-400,取自 enspeak StarRating(#E5E7EB / #FBBF24)
+.star-rating__track {
+  color: #e5e7eb;
+}
+
+// 覆盖层按比例截断,得到小数星
+.star-rating__fill {
+  position: absolute;
+  inset: 0;
+  overflow: hidden;
+  color: #fbbf24;
+}
+</style>

+ 2 - 2
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -83,7 +83,7 @@
 
 <script lang="ts" setup>
 import { ref, computed, watch, onMounted, onUnmounted, inject } from 'vue'
-import type { DialogueReport, OverallEvaluation, PreviewAIRole, PreviewDialogueState, SessionStartInfo } from '@/types/englishSpeaking'
+import type { DialogueReport, OverallEvaluation, PreviewAIRole, PreviewDialogueState, SessionStartInfo, TopicDiscussionConfig } from '@/types/englishSpeaking'
 import { useSpeakingStore } from '@/store/speaking'
 import { getSpeakingConfig } from '@/services/speaking'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
@@ -488,7 +488,7 @@ async function loadConfigFromBackend(id: string) {
     return
   }
   try {
-    const { config } = await getSpeakingConfig(id)
+    const { config } = await getSpeakingConfig<TopicDiscussionConfig>(id)
     if (!isHistoryTokenCurrent(token)) return
     speakingStore.$patch({ config })
   } catch (err) {

+ 56 - 0
src/views/Editor/EnglishSpeaking/preview/articleReadingModel.ts

@@ -0,0 +1,56 @@
+import type {
+  ArticleReadingConfig,
+  ArticleReadingReport,
+  ArticleReadingTask,
+} from '@/types/articleReading'
+import type { SpeakingRecommendationTask } from '@/types/englishSpeaking'
+
+export type SpeakingTaskFilter = 'all' | 'topic-discussion' | 'article-reading'
+
+export function buildEmptyArticleConfig(grade: string): ArticleReadingConfig {
+  return {
+    type: 'article',
+    grade,
+    title: '',
+    content: '',
+    practice: { mode: 'time', duration: 10 },
+    evaluation: { showReport: true },
+  }
+}
+
+export function buildArticleConfig(task: ArticleReadingTask, grade: string): ArticleReadingConfig {
+  return {
+    type: 'article',
+    grade,
+    title: task.titleEn,
+    content: task.content,
+    practice: { mode: 'time', duration: task.durationMinutes },
+    evaluation: { showReport: true },
+  }
+}
+
+export function filterSpeakingTasks(
+  tasks: SpeakingRecommendationTask[],
+  filter: SpeakingTaskFilter,
+): SpeakingRecommendationTask[] {
+  return filter === 'all' ? tasks : tasks.filter(task => task.taskType === filter)
+}
+
+export function scoreToStars(score: number | null | undefined): number {
+  if (score == null) return 0
+  return Math.round(Math.min(100, Math.max(0, score)) * 5) / 100
+}
+
+/** 最终报告一次性显示:无段落还在生成,且整篇总评已定局(spec §4.1)。 */
+export function isFinalReportReady(
+  report: Pick<ArticleReadingReport, 'paragraphs' | 'overallStatus'>,
+): boolean {
+  return !report.paragraphs.some(paragraph => paragraph.status === 'generating')
+    && (report.overallStatus === 'completed'
+      || report.overallStatus === 'failed'
+      || report.overallStatus === null)
+}
+
+export function pollDelayMs(attempt: number): number {
+  return Math.min(8000, 1000 * 2 ** Math.max(0, attempt))
+}

+ 93 - 0
src/views/Editor/EnglishSpeaking/services/articleErrors.ts

@@ -0,0 +1,93 @@
+import { lang } from '@/main'
+import { ArticleApiError } from './articleReading'
+
+/**
+ * 后端英文 detail → 面向用户的本地化文案(spec §9.2.5)。
+ *
+ * 形状照话题讨论的 `friendlyErrorMessage`(`useDialogueEngine.ts:586-601`),
+ * 但有一处**故意不同**:话题讨论未命中时 `return map[raw] || raw`,把英文原文漏了出去。
+ * 这里未命中一律回通用兜底 —— 后端的 detail 全是英文短句,学生看不懂,而且
+ * `request()` 早期版本会把整个 `{"detail":"..."}` 塞进 message,漏出去就是原始 JSON。
+ *
+ * 映射表在函数体内构造:`lang` 是响应式对象,模块顶层展开会把语言冻在首次 import 那一刻。
+ */
+export function friendlyArticleError(cause: unknown): string {
+  const detail = extractDetail(cause)
+  if (!detail) return lang.ssArticleErrGeneric as string
+
+  const map: Record<string, string> = {
+    'Config not found': lang.ssArticleErrConfigNotFound as string,
+    'Config is not an article config': lang.ssArticleErrNotArticleConfig as string,
+    'Article content is empty': lang.ssArticleErrContentEmpty as string,
+    'Session not found': lang.ssArticleErrSessionNotFound as string,
+    'Paragraph not found': lang.ssArticleErrParagraphNotFound as string,
+    'Session is abandoned': lang.ssArticleSessionExpired as string,
+    'Session has ended': lang.ssArticleErrSessionEnded as string,
+    // 总评开始后重录被拒(spec §4.1 规则 4,2026-07-27)。视图本该已经把录音锁上了,
+    // 撞到这条只剩陈旧标签页那一种 —— 但陈旧标签页也不该看到英文原文。
+    'Report already generated': lang.ssArticleErrReportGenerated as string,
+    'Segmentation failed': lang.ssArticleSegmentFailed as string,
+    'Segmentation returned empty output': lang.ssArticleSegmentFailed as string,
+    'content is required': lang.ssArticleErrBadRequest as string,
+    'configId is required': lang.ssArticleErrBadRequest as string,
+    'userId is required': lang.ssArticleErrBadRequest as string,
+    'users is required': lang.ssArticleErrBadRequest as string,
+    'users capped at 100': lang.ssArticleErrBadRequest as string,
+  }
+
+  // `Demo audio synthesis failed: [2, 5]` 带着段号,所以走正则而不是查表。
+  // 段号要留给老师 —— 「第几段生成失败」是他唯一能采取行动的信息(去看那一段是不是
+  // 异常长、或纯符号)。文案还要说清「配置未保存」:保存是全成功才写入,失败是
+  // 一个干净的 no-op,老师需要知道自己的修改还没进去、重按一次就是完整重试。
+  const demoFailed = /^Demo audio synthesis failed: \[([\d,\s]+)\]$/.exec(detail)
+  if (demoFailed) {
+    const paragraphs = demoFailed[1].split(',').map(n => n.trim()).filter(Boolean).join('、')
+    return String(lang.ssArticleErrDemoAudio).replace('{n}', paragraphs)
+  }
+
+  // `Students are practising: N` **故意不在这张表里**(spec §4.3 B,2026-07-24 改成确认框):
+  // 它不是错误而是问句,`handleApply` 在 catch 里先用 articleActiveCountFromError() 截住它去弹
+  // 确认框,老师选「仍要保存」就带 force 重发。真漏到这里只能是别处误用,走通用兜底即可。
+  return map[detail] ?? (lang.ssArticleErrGeneric as string)
+}
+
+/**
+ * 409 `Students are practising: N` 里的那个 N;不是这条错误 → null。
+ *
+ * 后端把人数编进 detail 就是为了让这一步不必再请求一次 —— 多问那一次恰恰又会拿到
+ * **另一个时刻**的数字,而弹框里那个数字必须是老师按下那一刻的(spec §4.3 B)。
+ */
+export function articleActiveCountFromError(cause: unknown): number | null {
+  const detail = typeof cause === 'string' ? cause : extractDetail(cause)
+  if (!detail) return null
+  const matched = /^Students are practising: (\d+)$/.exec(detail)
+  return matched ? Number(matched[1]) : null
+}
+
+/** 后端 409 = 这一轮已被作废(spec §4.2)。视图据此改成「请重新开始」而不是给重试按钮。 */
+export function isArticleSessionGone(cause: unknown): boolean {
+  return cause instanceof ArticleApiError && cause.status === 409
+}
+
+/**
+ * 从各种 throw 形状里刨出后端那句裸英文。三种来源都要吃得下:
+ *  - `articleReading.ts` 的 `request()`:已经拆好的裸 detail
+ *  - WS 错误帧(`articleReadingStream.ts:42`):同样是裸 detail
+ *  - `@/services/speaking` 的 `parse()`:`[404] {"detail":"..."}`(共用端点,改不动它)
+ */
+function extractDetail(cause: unknown): string | null {
+  const raw = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : ''
+  if (!raw) return null
+  const jsonStart = raw.indexOf('{')
+  if (jsonStart < 0) return raw
+  try {
+    const parsed: unknown = JSON.parse(raw.slice(jsonStart))
+    if (parsed && typeof parsed === 'object' && typeof (parsed as { detail?: unknown }).detail === 'string') {
+      return (parsed as { detail: string }).detail
+    }
+  }
+  catch {
+    // 不是 JSON —— 原样当英文短句处理,命不中就走兜底
+  }
+  return raw
+}

+ 162 - 0
src/views/Editor/EnglishSpeaking/services/articleReading.ts

@@ -0,0 +1,162 @@
+import { SPEAKING_ARTICLE_API_BASE_URL } from './speakingApiConfig'
+import type {
+  ArticleClassSessionSummary,
+  ArticleClassSummaryResponse,
+  ArticleClassUser,
+  ArticleReadingConfig,
+  ArticleReadingReport,
+} from '@/types/articleReading'
+
+/**
+ * 带 HTTP status 的失败。`message` 是后端那句**裸英文 detail**(如 `Session not found`),
+ * 不是响应体 —— 它是给 `friendlyArticleError()` 当映射 key 用的机器可读值。
+ * 任何直接把它渲染给人看的地方都是 bug(spec §9.2.5)。
+ */
+export class ArticleApiError extends Error {
+  readonly status: number
+
+  constructor(message: string, status: number) {
+    super(message)
+    this.name = 'ArticleApiError'
+    this.status = status
+  }
+}
+
+async function request<T>(path: string, init?: RequestInit): Promise<T> {
+  const response = await fetch(`${SPEAKING_ARTICLE_API_BASE_URL}${path}`, init)
+  if (!response.ok) {
+    throw new ArticleApiError(await readDetail(response), response.status)
+  }
+  return response.json() as Promise<T>
+}
+
+/** FastAPI 一律回 `{"detail": "..."}`;拆不出来就退回 status,**绝不把整个响应体当文案**。 */
+async function readDetail(response: Response): Promise<string> {
+  const body = await response.text().catch(() => '')
+  try {
+    const parsed: unknown = JSON.parse(body)
+    if (parsed && typeof parsed === 'object' && typeof (parsed as { detail?: unknown }).detail === 'string') {
+      return (parsed as { detail: string }).detail
+    }
+  }
+  catch {
+    // 不是 JSON(网关 502 的 HTML 页面之类)—— 别把它当文案
+  }
+  return `Article API failed: ${response.status}`
+}
+
+const jsonHeaders = { 'Content-Type': 'application/json' }
+
+/** `userId` 为 null = 编辑态老师预览(spec §4.5):后端落 NULL,不进任何统计。 */
+export function createArticleSession(configId: string, userId: string | null) {
+  return request<ArticleReadingReport>('/session', {
+    method: 'POST',
+    headers: jsonHeaders,
+    body: JSON.stringify({ configId, userId }),
+  })
+}
+
+export function getArticleReport(sessionId: string, signal?: AbortSignal) {
+  return request<ArticleReadingReport>(`/session/${encodeURIComponent(sessionId)}/report`, { signal })
+}
+
+/** 只读 resume 探针:该生该 config 最新 session 的 report;无则 session=null。绝不创建 session。 */
+export function getLatestArticleSession(configId: string, userId: string) {
+  const query = new URLSearchParams({ configId, userId }).toString()
+  return request<{ session: ArticleReadingReport | null }>(`/sessions/latest?${query}`)
+}
+
+/** 幂等;已完成的 session 走后端第二分支,completedAt 可能为 null */
+export function completeArticleSession(sessionId: string) {
+  return request<{ sessionId: string; status: 'completed'; completedAt: string | null }>(
+    `/session/${encodeURIComponent(sessionId)}/complete`,
+    { method: 'POST' },
+  )
+}
+
+export function getArticleDemoAudio(sessionId: string, paragraphIdx: number) {
+  return request<{ url: string }>(
+    `/session/${encodeURIComponent(sessionId)}/paragraph/${paragraphIdx}/demo-audio`,
+  )
+}
+
+/** 老师端:请求体是 users:[{userId,name}],不是 dialogue 的 userIds */
+export function listArticleSessionsByConfig(configId: string, users: ArticleClassUser[]) {
+  return request<{ summaries: ArticleClassSessionSummary[] }>('/sessions/by-config', {
+    method: 'POST',
+    headers: jsonHeaders,
+    body: JSON.stringify({ configId, users }),
+  })
+}
+
+/** 无 locale 参数:班级总结固定中文(spec §8.1) */
+export function generateArticleClassSummary(configId: string, users: ArticleClassUser[]) {
+  return request<ArticleClassSummaryResponse>('/sessions/by-config/summary', {
+    method: 'POST',
+    headers: jsonHeaders,
+    body: JSON.stringify({ configId, users }),
+  })
+}
+
+/**
+ * 配置端自动分段(spec §3.2 / §6):整篇送后端 → LLM → `\n\n` 分段纯文本。
+ *
+ * 后端 segmenter 超时 30s;模块内的 `request()` 本身不带超时,
+ * 若后端挂住 fetch 会永远 pending,故这里自带 40s AbortController 兜底。
+ */
+export function segmentArticle(content: string) {
+  const controller = new AbortController()
+  const timer = setTimeout(() => controller.abort(), 40_000)
+  return request<{ content: string }>('/segment', {
+    method: 'POST',
+    headers: jsonHeaders,
+    body: JSON.stringify({ content }),
+    signal: controller.signal,
+  }).finally(() => clearTimeout(timer))
+}
+
+/** 「重新开始」:把当前 session 标作废(spec §4.2)。幂等;不建新 session —— 下次 createArticleSession 会因查不到未作废的行而新建。 */
+export function abandonArticleSession(sessionId: string) {
+  return request<{ sessionId: string; status: string }>(
+    `/session/${encodeURIComponent(sessionId)}/abandon`,
+    { method: 'POST' },
+  )
+}
+
+/**
+ * 文章专属的建立接口。响应形状与共用的 `POST /config` 一致。
+ *
+ * 为什么新建也要走文章自己的端点:后端在这里会把整篇的示范音频**同步**合成好,
+ * 全部成功才写入配置。学生点「示范」时因此永远是 S3 直接命中,不用等合成。
+ * 合成失败 = 完全没写入,老师重按一次就是完整的重试(已合成的段落留在 S3,
+ * 重试只补剩下的),所以不需要任何「半份配置」的收拾逻辑。
+ *
+ * 共用的 `POST /config` 拿到什么存什么,给不了「TTS 成功才写入」这个语义,
+ * 而把它塞进去就会让通用端点长出只对一种题型生效的行为。
+ */
+export function createArticleConfig(config: ArticleReadingConfig, ownerUid: string | null = null) {
+  return request<{ id: string; config: ArticleReadingConfig; ownerUid: string | null }>(
+    '/config',
+    { method: 'POST', headers: jsonHeaders, body: JSON.stringify({ config, ownerUid }) },
+  )
+}
+
+/**
+ * 文章专属的保存接口(spec §4.3 B)。响应形状与共用的 `PUT /config/{id}` 一致。
+ * 同 `createArticleConfig`:写入前先把整篇示范音频备齐,全部成功才写。
+ *
+ * 有人正在练时后端回 409,detail 带着人数 —— 那是**问句不是墙**:`handleApply` 据此弹确认框,
+ * 老师选「仍要保存」就带 `force=true` 原样重发一次,后端照写。所以面板不需要预先问人数、
+ * 也不需要置灰(`GET /active-count` 已随之删除)。
+ */
+export function updateArticleConfig(
+  configId: string,
+  config: ArticleReadingConfig,
+  force = false,
+) {
+  const query = force ? '?force=true' : ''
+  return request<{ id: string; config: ArticleReadingConfig; ownerUid: string | null }>(
+    `/config/${encodeURIComponent(configId)}${query}`,
+    { method: 'PUT', headers: jsonHeaders, body: JSON.stringify({ config }) },
+  )
+}

+ 111 - 0
src/views/Editor/EnglishSpeaking/services/articleReadingStream.ts

@@ -0,0 +1,111 @@
+import { buildArticleWsUrl } from './speakingApiConfig'
+import type { ArticleStreamAck } from '@/types/articleReading'
+
+interface OpenArticleParagraphStreamOptions {
+  sessionId: string
+  paragraphIdx: number
+  sampleRate: number
+}
+
+export interface ArticleParagraphStream {
+  pushChunk(chunk: ArrayBuffer): void
+  stop(): Promise<ArticleStreamAck>
+  abort(reason?: string): void
+}
+
+/**
+ * 段落录音上行:start JSON → 0..n 个 binary PCM 帧 → stop JSON → 等 ack。
+ *
+ * 录音器常常早于 socket open 就吐出 chunk,所以 CONNECTING 期间入队而非丢弃,
+ * 否则开头的音频会缺失。没有 HTTP/文件兜底:断掉的录音直接作废,由 UI 提供重录。
+ */
+export function openArticleParagraphStream(
+  options: OpenArticleParagraphStreamOptions,
+): ArticleParagraphStream {
+  const path = `/session/${encodeURIComponent(options.sessionId)}/paragraph/${options.paragraphIdx}/stream`
+  const socket = new WebSocket(buildArticleWsUrl(path))
+  const queuedChunks: ArrayBuffer[] = []
+  let stopRequested = false
+  let settled = false
+  let resolveAck!: (ack: ArticleStreamAck) => void
+  let rejectAck!: (error: Error) => void
+  const ackPromise = new Promise<ArticleStreamAck>((resolve, reject) => {
+    resolveAck = resolve
+    rejectAck = reject
+  })
+  // stop() 之前没人持有 ackPromise,Node/浏览器会把提前的 reject 记为未处理拒绝
+  ackPromise.catch(() => {})
+
+  function rejectOnce(message: string) {
+    if (settled) return
+    settled = true
+    rejectAck(new Error(message))
+  }
+
+  function sendStop() {
+    socket.send(JSON.stringify({ type: 'stop' }))
+  }
+
+  socket.onopen = () => {
+    socket.send(JSON.stringify({
+      type: 'start',
+      sampleRate: options.sampleRate,
+      bits: 16,
+      channels: 1,
+    }))
+    for (const chunk of queuedChunks) socket.send(chunk)
+    queuedChunks.length = 0
+    if (stopRequested) sendStop()
+  }
+
+  socket.onmessage = event => {
+    let message: ArticleStreamAck | { type: 'error'; message: string }
+    try {
+      message = JSON.parse(String(event.data)) as ArticleStreamAck | { type: 'error'; message: string }
+    } catch {
+      rejectOnce('Article recording returned invalid JSON')
+      socket.close()
+      return
+    }
+    if (message.type === 'error') {
+      rejectOnce(message.message)
+      socket.close()
+      return
+    }
+    if (message.type === 'ack' && message.status === 'generating' && !settled) {
+      settled = true
+      resolveAck(message)
+      return
+    }
+    rejectOnce('Article recording returned an invalid acknowledgement')
+    socket.close()
+  }
+
+  socket.onerror = () => rejectOnce('Article recording connection failed')
+  socket.onclose = () => {
+    if (!settled) rejectOnce('Article recording connection closed before acknowledgement')
+  }
+
+  return {
+    pushChunk(chunk) {
+      if (stopRequested) throw new Error('Cannot push PCM after stop')
+      if (socket.readyState === WebSocket.OPEN) socket.send(chunk)
+      else if (socket.readyState === WebSocket.CONNECTING) queuedChunks.push(chunk)
+      else rejectOnce('Article recording connection is not open')
+    },
+    stop() {
+      if (!stopRequested) {
+        stopRequested = true
+        if (socket.readyState === WebSocket.OPEN) sendStop()
+        else if (socket.readyState !== WebSocket.CONNECTING) {
+          rejectOnce('Article recording connection is not open')
+        }
+      }
+      return ackPromise
+    },
+    abort(reason = 'Article recording aborted') {
+      rejectOnce(reason)
+      socket.close()
+    },
+  }
+}

+ 6 - 0
src/views/Editor/EnglishSpeaking/services/speakingApiConfig.ts

@@ -5,6 +5,7 @@ const SPEAKING_API_HOST = (ENV_SPEAKING_API_HOST || FALLBACK_SPEAKING_API_HOST).
 
 export const SPEAKING_DIALOGUE_API_BASE_URL = `${SPEAKING_API_HOST}/api/speaking/dialogue`
 export const SPEAKING_CONFIG_API_BASE_URL = `${SPEAKING_API_HOST}/api/speaking/config`
+export const SPEAKING_ARTICLE_API_BASE_URL = `${SPEAKING_API_HOST}/api/speaking/article`
 
 export type SpeakTransport = 'websocket' | 'http'
 
@@ -16,3 +17,8 @@ export function buildSpeakingWsUrl(path: string): string {
   const normalizedPath = path.startsWith('/') ? path : `/${path}`
   return `${SPEAKING_DIALOGUE_API_BASE_URL.replace(/^http/, 'ws')}${normalizedPath}`
 }
+
+export function buildArticleWsUrl(path: string): string {
+  const normalizedPath = path.startsWith('/') ? path : `/${path}`
+  return `${SPEAKING_ARTICLE_API_BASE_URL.replace(/^http/, 'ws')}${normalizedPath}`
+}

+ 146 - 0
src/views/Student/components/ArticleReadingClassPanel/ClassSummary.vue

@@ -0,0 +1,146 @@
+<template>
+  <div class="class-summary">
+    <div class="class-summary__head">
+      <span class="class-summary__title">{{ lang.ssArticleClassSummary }}</span>
+      <div class="class-summary__meta">
+        <!-- 定时刷新已有总结时只在头部转小圈:正文原地留着,不清掉老师读到一半的字 -->
+        <span v-if="loading && summary" class="class-summary__spinner"></span>
+        <span v-if="generatedAt" class="class-summary__time">{{ formatTime(generatedAt) }}</span>
+        <button class="class-summary__refresh" :disabled="loading" @click="emit('refresh')">
+          {{ summary ? lang.ssArticleRefresh : lang.ssArticleGenerate }}
+        </button>
+      </div>
+    </div>
+
+    <!-- 整块 loading / 错误只在还没有任何总结时出现;已有总结时失败靠下次定时 tick 重试 -->
+    <div v-if="loading && !summary" class="class-summary__loading">
+      <span class="class-summary__spinner"></span>
+      {{ lang.ssArticleSummarizing }}
+    </div>
+
+    <div v-else-if="error && !summary" class="class-summary__error">
+      <span>{{ lang.ssArticleSummaryFailed }}</span>
+      <button @click="emit('refresh')">{{ lang.ssArticleRetry }}</button>
+    </div>
+
+    <!-- 后端固定中文输出,前端逐字显示,不切成条目也不机器翻译 -->
+    <p v-else-if="summary" class="class-summary__text">{{ summary }}</p>
+
+    <p v-else class="class-summary__empty">{{ lang.ssArticleSummaryEmpty }}</p>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { lang } from '@/main'
+
+defineProps<{
+  summary: string
+  loading: boolean
+  error: string | null
+  generatedAt: string | null
+}>()
+
+const emit = defineEmits<{ refresh: [] }>()
+
+function formatTime(iso: string): string {
+  const date = new Date(iso)
+  if (Number.isNaN(date.getTime())) return ''
+  const hh = String(date.getHours()).padStart(2, '0')
+  const mm = String(date.getMinutes()).padStart(2, '0')
+  return `${hh}:${mm}`
+}
+</script>
+
+<style lang="scss" scoped>
+.class-summary {
+  flex-shrink: 0;
+  margin-top: 16px;
+  padding: 12px 16px;
+  border: 1px solid #fed7aa;
+  border-radius: 10px;
+  background: #fff7ed;
+}
+
+.class-summary__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.class-summary__title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #c2410c;
+}
+
+.class-summary__meta {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.class-summary__time {
+  font-size: 11px;
+  color: #9ca3af;
+}
+
+.class-summary__refresh {
+  padding: 2px 10px;
+  border: 1px solid #fdba74;
+  border-radius: 999px;
+  background: #fff;
+  color: #ea580c;
+  font-size: 11px;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover:not(:disabled) { background: #ffedd5; }
+  &:disabled { opacity: 0.5; cursor: not-allowed; }
+}
+
+.class-summary__loading,
+.class-summary__error {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  color: #9ca3af;
+
+  button {
+    padding: 2px 10px;
+    border: 1px solid #fdba74;
+    border-radius: 999px;
+    background: #fff;
+    color: #ea580c;
+    font-size: 11px;
+    cursor: pointer;
+  }
+}
+
+.class-summary__spinner {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  border: 2px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-summary-spin 0.7s linear infinite;
+}
+
+@keyframes article-summary-spin {
+  to { transform: rotate(360deg); }
+}
+
+.class-summary__text {
+  margin: 0;
+  font-size: 12px;
+  line-height: 1.8;
+  color: #7c2d12;
+}
+
+.class-summary__empty {
+  margin: 0;
+  font-size: 12px;
+  color: #9ca3af;
+}
+</style>

+ 105 - 0
src/views/Student/components/ArticleReadingClassPanel/StudentGrid.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="grid-4">
+    <div
+      v-for="student in students"
+      :key="student.userId"
+      class="student-card"
+      :class="'status-' + student.status"
+      @click="$emit('click', student)"
+    >
+      <div class="avatar" :class="'avatar-' + student.status">
+        {{ student.name.charAt(0) }}
+      </div>
+      <!-- 完成态与其余状态同形:只有名字,分数在个人报告弹窗里看(spec §8) -->
+      <span class="name">{{ student.name }}</span>
+      <span v-if="student.status === 'completed'" class="indicator check">✓</span>
+      <span v-else-if="student.status === 'reading'" class="indicator dot pulse"></span>
+      <span v-else class="indicator dash">—</span>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import type { ArticleClassSessionSummary } from '@/types/articleReading'
+
+defineProps<{ students: ArticleClassSessionSummary[] }>()
+defineEmits<{ click: [student: ArticleClassSessionSummary] }>()
+</script>
+
+<style lang="scss" scoped>
+.grid-4 {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.student-card {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 8px 12px;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: box-shadow 0.15s ease;
+  background: #fff;
+  border: 2px solid transparent;
+
+  &:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
+
+  &.status-completed { border-color: #facc15; }
+  &.status-reading { border-color: #fde68a; }
+  &.status-not_started {
+    background: #f3f4f6;
+    border: 2px dashed #9ca3af;
+  }
+}
+
+.avatar {
+  flex-shrink: 0;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+  font-weight: 600;
+  color: #fff;
+  background: #d1d5db;
+
+  &.avatar-completed { background: #f59e0b; }
+  &.avatar-reading { background: #fbbf24; }
+}
+
+.name {
+  flex: 1;
+  min-width: 0;
+  font-size: 13px;
+  color: #111827;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.indicator {
+  flex-shrink: 0;
+  font-size: 12px;
+
+  &.check { color: #16a34a; }
+  &.dash { color: #9ca3af; }
+
+  &.dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #fbbf24;
+  }
+
+  &.pulse { animation: article-class-pulse 1.4s ease-in-out infinite; }
+}
+
+@keyframes article-class-pulse {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.35; }
+}
+</style>

+ 222 - 0
src/views/Student/components/ArticleReadingClassPanel/StudentReportModal.vue

@@ -0,0 +1,222 @@
+<template>
+  <div class="report-modal-overlay" @click.self="emit('close')">
+    <div class="report-modal">
+      <div class="report-modal__head">
+        <span class="report-modal__name">{{ student.name }}</span>
+        <button class="report-modal__close" @click="emit('close')">
+          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
+          </svg>
+        </button>
+      </div>
+
+      <div class="report-modal__body">
+        <div v-if="loading" class="report-modal__state">
+          <span class="report-modal__spinner"></span>
+          {{ lang.ssArticleLoadingReport }}
+        </div>
+
+        <div v-else-if="error" class="report-modal__state">
+          <p class="report-modal__error">{{ error }}</p>
+          <button class="report-modal__retry" @click="loadReport">{{ lang.ssArticleRetry }}</button>
+        </div>
+
+        <ArticleDetailedReport
+          v-else-if="report"
+          :report="report"
+          :playing-id="player.playingId.value"
+          :loading-id="player.loadingId.value"
+          @play-demo="playDemo"
+          @play-own="playOwn"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import { lang } from '@/main'
+import ArticleDetailedReport from '@/views/Editor/EnglishSpeaking/preview/ArticleDetailedReport.vue'
+import { useAudioPlayer } from '@/views/Editor/EnglishSpeaking/composables/useAudioPlayer'
+import { getArticleReport, getArticleDemoAudio } from '@/views/Editor/EnglishSpeaking/services/articleReading'
+import { friendlyArticleError } from '@/views/Editor/EnglishSpeaking/services/articleErrors'
+import type { ArticleReadingReport, ArticleClassSessionSummary } from '@/types/articleReading'
+
+const props = defineProps<{ student: ArticleClassSessionSummary }>()
+const emit = defineEmits<{ close: [] }>()
+
+const report = ref<ArticleReadingReport | null>(null)
+const loading = ref(false)
+const error = ref<string | null>(null)
+const player = useAudioPlayer()
+const demoUrlCache = new Map<string, string>()
+
+let loadToken = 0
+
+async function loadReport() {
+  const sessionId = props.student.sessionId
+  if (!sessionId) return
+  const token = ++loadToken
+  loading.value = true
+  error.value = null
+  try {
+    const next = await getArticleReport(sessionId)
+    if (token !== loadToken) return
+    report.value = next
+  } catch (cause) {
+    if (token !== loadToken) return
+    // 老师看到的是学生真实结果,绝不用样例分数顶替。
+    //
+    // 文案必须过 friendlyArticleError(spec §9.2.5):`request()` 现在抛的是后端那句
+    // **裸英文 detail**(`Session not found` / `Config not found` …),它是给映射表当 key 用的
+    // 机器可读值,不是给人看的。直接渲染 `cause.message` 就是把英文短句甩给老师 ——
+    // §9.2.5 收敛全部 call site 时漏掉的最后一处。
+    error.value = friendlyArticleError(cause)
+  } finally {
+    if (token === loadToken) loading.value = false
+  }
+}
+
+async function playDemo(idx: number) {
+  const id = `demo-${idx}`
+  if (player.playingId.value === id) {
+    player.stop()
+    return
+  }
+  const sessionId = props.student.sessionId
+  if (!sessionId) return
+  const cacheKey = `${sessionId}:${idx}`
+  let url = demoUrlCache.get(cacheKey)
+  if (!url) {
+    try {
+      const res = await getArticleDemoAudio(sessionId, idx)
+      url = res.url
+      demoUrlCache.set(cacheKey, url)
+    } catch (cause) {
+      console.error('[article-reading] demo audio failed:', cause)
+      return
+    }
+  }
+  await player.play(id, { kind: 'url', url })
+}
+
+async function playOwn(idx: number) {
+  const id = `own-${idx}`
+  if (player.playingId.value === id) {
+    player.stop()
+    return
+  }
+  const url = report.value?.paragraphs.find(paragraph => paragraph.idx === idx)?.audioUrl
+  if (!url) return
+  await player.play(id, { kind: 'url', url })
+}
+
+onMounted(() => { void loadReport() })
+onUnmounted(() => {
+  loadToken += 1
+  player.stop()
+})
+</script>
+
+<style lang="scss" scoped>
+.report-modal-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 2000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 32px;
+  background: rgba(0, 0, 0, 0.35);
+}
+
+.report-modal {
+  width: 100%;
+  max-width: 760px;
+  height: 100%;
+  max-height: 80vh;
+  display: flex;
+  flex-direction: column;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
+  overflow: hidden;
+}
+
+.report-modal__head {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 20px;
+  border-bottom: 1px solid #f3f4f6;
+}
+
+.report-modal__name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.report-modal__close {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 8px;
+  background: #f3f4f6;
+  color: #6b7280;
+  cursor: pointer;
+  transition: background 0.2s;
+
+  &:hover { background: #e5e7eb; }
+}
+
+.report-modal__body {
+  flex: 1;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.report-modal__state {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  font-size: 13px;
+  color: #9ca3af;
+}
+
+.report-modal__spinner {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  border: 3px solid #fb923c;
+  border-top-color: transparent;
+  animation: article-modal-spin 0.8s linear infinite;
+}
+
+@keyframes article-modal-spin {
+  to { transform: rotate(360deg); }
+}
+
+.report-modal__error {
+  margin: 0;
+  color: #dc2626;
+}
+
+.report-modal__retry {
+  padding: 4px 16px;
+  border: 1px solid #fdba74;
+  border-radius: 999px;
+  background: #fff;
+  color: #ea580c;
+  font-size: 12px;
+  cursor: pointer;
+}
+</style>

+ 233 - 0
src/views/Student/components/ArticleReadingClassPanel/index.vue

@@ -0,0 +1,233 @@
+<template>
+  <div class="article-class-panel" :style="cardStyle">
+    <div class="content-card">
+      <div v-if="!props.configId" class="state-banner">
+        {{ lang.ssSpkConfigMissing }}
+      </div>
+
+      <template v-else>
+        <!-- 状态筛选 + 排序 -->
+        <div class="filter-row">
+          <div class="status-filter">
+            <button
+              v-for="tab in filterTabs"
+              :key="tab.key"
+              :class="{ active: statusFilter === tab.key }"
+              @click="statusFilter = tab.key"
+            >
+              {{ tab.label }} {{ tab.count }}
+            </button>
+          </div>
+          <SortMenu v-model="sortBy" />
+        </div>
+
+        <div v-if="error === 'FETCH_FAILED'" class="error-bar">
+          <span>{{ lang.ssSpkLoadFailed }}</span>
+          <button @click="fetchClassSummary">{{ lang.ssSpkRetry }}</button>
+        </div>
+
+        <div class="grid-scroll">
+          <StudentGrid :students="filteredAndSortedStudents" @click="handleStudentClick" />
+        </div>
+
+        <ClassSummary
+          :summary="aiSummary"
+          :loading="aiLoading"
+          :error="aiError"
+          :generated-at="aiGeneratedAt"
+          @refresh="refreshAISummary"
+        />
+      </template>
+    </div>
+
+    <StudentReportModal
+      v-if="reportModalStudent && reportModalStudent.sessionId"
+      :student="reportModalStudent"
+      @close="reportModalStudent = null"
+    />
+
+    <NotStartedTip
+      v-if="notStartedTipStudent"
+      :studentName="notStartedTipStudent.name"
+      @close="notStartedTipStudent = null"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, toRef } from 'vue'
+import { lang } from '@/main'
+import SortMenu, { type SortKey } from '../SpeakingClassPanel/SortMenu.vue'
+import NotStartedTip from '../SpeakingClassPanel/NotStartedTip.vue'
+import StudentGrid from './StudentGrid.vue'
+import ClassSummary from './ClassSummary.vue'
+import StudentReportModal from './StudentReportModal.vue'
+import { useArticleClassSummary } from './useArticleClassSummary'
+import type { ArticleClassStudent, ArticleClassFilter } from './types'
+import type { ArticleClassSessionSummary } from '@/types/articleReading'
+
+const props = defineProps<{
+  configId: string
+  slideIndex: number
+  studentArray: ArticleClassStudent[]
+  courseId: string
+  slideWidth: number
+  slideHeight: number
+}>()
+
+const {
+  summaries, error, fetchClassSummary, scheduleRefetch,
+  aiSummary, aiLoading, aiError, aiGeneratedAt, refreshAISummary,
+} = useArticleClassSummary({
+  configId: toRef(props, 'configId'),
+  studentArray: toRef(props, 'studentArray'),
+})
+
+// 父级 Student/index.vue 通过 ref 调用
+defineExpose({ scheduleRefetch, fetchClassSummary })
+
+const statusFilter = ref<ArticleClassFilter>('all')
+const sortBy = ref<SortKey>('time-asc')
+
+// 状态沿用后端原生 completed / reading / not_started,不翻译成对话的提交模型
+const counts = computed(() => ({
+  all: summaries.value.length,
+  completed: summaries.value.filter(s => s.status === 'completed').length,
+  reading: summaries.value.filter(s => s.status === 'reading').length,
+  not_started: summaries.value.filter(s => s.status === 'not_started').length,
+}))
+
+const filterTabs = computed(() => [
+  { key: 'all' as ArticleClassFilter, label: lang.ssSpkFilterAll as string, count: counts.value.all },
+  { key: 'completed' as ArticleClassFilter, label: lang.ssArticleFilterCompleted as string, count: counts.value.completed },
+  { key: 'reading' as ArticleClassFilter, label: lang.ssArticleFilterReading as string, count: counts.value.reading },
+  { key: 'not_started' as ArticleClassFilter, label: lang.ssSpkFilterNotStarted as string, count: counts.value.not_started },
+])
+
+const filteredAndSortedStudents = computed(() => {
+  let arr = summaries.value
+  if (statusFilter.value !== 'all') arr = arr.filter(s => s.status === statusFilter.value)
+  arr = [...arr]
+  if (sortBy.value === 'name-asc') {
+    arr.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
+  } else {
+    arr.sort((a, b) => {
+      const ta = a.completedAt || ''
+      const tb = b.completedAt || ''
+      return sortBy.value === 'time-desc' ? tb.localeCompare(ta) : ta.localeCompare(tb)
+    })
+  }
+  return arr
+})
+
+const reportModalStudent = ref<ArticleClassSessionSummary | null>(null)
+const notStartedTipStudent = ref<ArticleClassSessionSummary | null>(null)
+
+function handleStudentClick(student: ArticleClassSessionSummary) {
+  if (student.status === 'not_started' || !student.sessionId) {
+    notStartedTipStudent.value = student
+  } else {
+    reportModalStudent.value = student
+  }
+}
+
+const cardStyle = computed(() => ({
+  width: props.slideWidth + 'px',
+  height: props.slideHeight + 'px',
+}))
+</script>
+
+<style lang="scss" scoped>
+.article-class-panel {
+  margin: 0 auto;
+  background: #f9fafb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px;
+  box-sizing: border-box;
+}
+
+.content-card {
+  background: #fff;
+  border-radius: 12px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
+  width: 100%;
+  max-width: 900px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 32px 48px;
+  overflow: hidden;
+}
+
+.state-banner {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  color: #9ca3af;
+  font-size: 14px;
+}
+
+.filter-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-shrink: 0;
+  margin-bottom: 12px;
+}
+
+.status-filter {
+  display: flex;
+  gap: 8px;
+
+  button {
+    padding: 4px 12px;
+    border: 1px solid #e5e7eb;
+    border-radius: 999px;
+    background: #fff;
+    color: #6b7280;
+    font-size: 12px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover { background: #f9fafb; }
+
+    &.active {
+      background: #f97316;
+      border-color: #f97316;
+      color: #fff;
+    }
+  }
+}
+
+.error-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-shrink: 0;
+  margin-bottom: 8px;
+  padding: 8px 12px;
+  border-radius: 8px;
+  background: #fef2f2;
+  color: #dc2626;
+  font-size: 12px;
+
+  button {
+    padding: 2px 10px;
+    border: 1px solid #fecaca;
+    border-radius: 999px;
+    background: #fff;
+    color: #dc2626;
+    font-size: 11px;
+    cursor: pointer;
+  }
+}
+
+.grid-scroll {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}
+</style>

+ 9 - 0
src/views/Student/components/ArticleReadingClassPanel/types.ts

@@ -0,0 +1,9 @@
+export interface ArticleClassStudent {
+  userid: string
+  name: string
+  classid?: string
+}
+
+export type ArticleClassFilter = 'all' | 'completed' | 'reading' | 'not_started'
+
+export type { ArticleClassSessionSummary } from '@/types/articleReading'

+ 171 - 0
src/views/Student/components/ArticleReadingClassPanel/useArticleClassSummary.ts

@@ -0,0 +1,171 @@
+import { ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
+import {
+  listArticleSessionsByConfig,
+  generateArticleClassSummary,
+} from '@/views/Editor/EnglishSpeaking/services/articleReading'
+import type { ArticleClassSessionSummary } from '@/types/articleReading'
+import type { ArticleClassStudent } from './types'
+
+const REFETCH_DEBOUNCE_MS = 800
+const AUTO_SUMMARY_INTERVAL_MS = 60_000
+
+/**
+ * 班级状态指纹。与后端缓存键同口径(article_service.py `_summaries_hash` 取的正是
+ * userId / status / overallScore),多带一个 configId —— 老师换配置时指纹自动失效。
+ * 指纹相同 = 没有新东西可总结,定时刷新一律跳过(spec §8.2)。
+ */
+export function classStateKey(configId: string, rows: ArticleClassSessionSummary[]): string {
+  const parts = rows
+    .map(row => `${row.userId}:${row.status}:${row.overallScore ?? ''}`)
+    .sort()
+  return `${configId}|${parts.join(',')}`
+}
+
+export function useArticleClassSummary(options: {
+  configId: Ref<string>
+  studentArray: Ref<ArticleClassStudent[]>
+}) {
+  const summaries = ref<ArticleClassSessionSummary[]>([])
+  const loading = ref(false)
+  const error = ref<string | null>(null)
+
+  const aiSummary = ref('')
+  const aiLoading = ref(false)
+  const aiError = ref<string | null>(null)
+  const aiGeneratedAt = ref<string | null>(null)
+  const aiFromCache = ref(false)
+  const aiLlmStatus = ref<'ok' | 'fallback' | null>(null)
+
+  // 单调递增令牌:慢响应回来时若已不是最新请求就丢弃
+  let listToken = 0
+  let summaryToken = 0
+  let refetchTimer: ReturnType<typeof setTimeout> | null = null
+  let autoTimer: ReturnType<typeof setInterval> | null = null
+  // 上次成功生成总结时的班级状态指纹;失败不记,于是下次 tick 自动重试
+  let lastSummarizedKey: string | null = null
+
+  function currentUsers() {
+    return options.studentArray.value
+      .filter(student => student.userid)
+      .map(student => ({ userId: student.userid, name: student.name }))
+  }
+
+  async function fetchClassSummary() {
+    const configId = options.configId.value
+    const users = currentUsers()
+    if (!configId || users.length === 0) {
+      summaries.value = []
+      return
+    }
+    const token = ++listToken
+    loading.value = true
+    error.value = null
+    try {
+      const res = await listArticleSessionsByConfig(configId, users)
+      if (token !== listToken) return
+      summaries.value = res.summaries
+    } catch (cause) {
+      if (token !== listToken) return
+      error.value = 'FETCH_FAILED'
+      console.error('[article-reading] class list failed:', cause)
+    } finally {
+      if (token === listToken) loading.value = false
+    }
+  }
+
+  /** WebSocket 进度事件会连发,去抖后只打一次接口 */
+  function scheduleRefetch() {
+    if (refetchTimer) clearTimeout(refetchTimer)
+    refetchTimer = setTimeout(() => {
+      refetchTimer = null
+      void fetchClassSummary()
+    }, REFETCH_DEBOUNCE_MS)
+  }
+
+  async function refreshAISummary() {
+    const configId = options.configId.value
+    const users = currentUsers()
+    if (!configId || users.length === 0) return
+    // 发请求前抓当时的指纹:成功才记下,于是手动点按钮同样会让紧接着的 tick 变成 no-op
+    const requestedKey = classStateKey(configId, summaries.value)
+    const token = ++summaryToken
+    aiLoading.value = true
+    aiError.value = null
+    try {
+      const res = await generateArticleClassSummary(configId, users)
+      if (token !== summaryToken) return
+      aiSummary.value = res.summary
+      aiGeneratedAt.value = res.generatedAt
+      aiFromCache.value = res.fromCache
+      aiLlmStatus.value = res.llmStatus
+      lastSummarizedKey = requestedKey
+    } catch (cause) {
+      if (token !== summaryToken) return
+      aiError.value = 'SUMMARY_FAILED'
+      console.error('[article-reading] class summary failed:', cause)
+    } finally {
+      if (token === summaryToken) aiLoading.value = false
+    }
+  }
+
+  /** 定时刷新的闸门(spec §8.2):只在真有新东西可总结时才调 LLM */
+  function maybeAutoSummary() {
+    if (aiLoading.value) return
+    // 老师切走的分页不烧;切回来时由 visibilitychange 立刻补一次
+    if (typeof document !== 'undefined' && document.hidden) return
+    const configId = options.configId.value
+    if (!configId) return
+    // 进行中的学生也要 update(用户 2026-07-26 定,放宽自「必须有人完成」)。
+    // 只有全班一人都没开始时才跳过 —— 那种情况总结栏只能写「未开始 N 人」,零信息量
+    if (!summaries.value.some(row => row.status === 'completed' || row.status === 'reading')) return
+    if (classStateKey(configId, summaries.value) === lastSummarizedKey) return
+    void refreshAISummary()
+  }
+
+  /** 先列表后总结:重拉列表顺带修掉「WS 广播早于分数落地」的陈旧 */
+  async function tick() {
+    await fetchClassSummary()
+    maybeAutoSummary()
+  }
+
+  function onVisibilityChange() {
+    if (!document.hidden) maybeAutoSummary()
+  }
+
+  watch(
+    [options.configId, options.studentArray],
+    () => { void tick() },
+    { immediate: true, deep: true },
+  )
+
+  onMounted(() => {
+    autoTimer = setInterval(() => void tick(), AUTO_SUMMARY_INTERVAL_MS)
+    document.addEventListener('visibilitychange', onVisibilityChange)
+  })
+
+  onUnmounted(() => {
+    if (refetchTimer) clearTimeout(refetchTimer)
+    refetchTimer = null
+    if (autoTimer) clearInterval(autoTimer)
+    autoTimer = null
+    document.removeEventListener('visibilitychange', onVisibilityChange)
+    // 令牌前进,卸载后回来的响应一律作废
+    listToken += 1
+    summaryToken += 1
+  })
+
+  return {
+    summaries,
+    loading,
+    error,
+    fetchClassSummary,
+    scheduleRefetch,
+    aiSummary,
+    aiLoading,
+    aiError,
+    aiGeneratedAt,
+    aiFromCache,
+    aiLlmStatus,
+    refreshAISummary,
+  }
+}

+ 34 - 8
src/views/Student/index.vue

@@ -106,9 +106,9 @@
           <ScreenSlideList :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"
             :animationIndex="0" :turnSlideToId="() => { }" :manualExitFullscreen="() => { }"  :slideIndex="slideIndex" v-show="!choiceQuestionDetailDialogOpenList.includes(slideIndex)"/>
 
-          <choiceQuestionDetailDialog ref="choiceQuestionDetailDialogRef" v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex) && currentSlideToolType !== 77" :roleType="props.type" :cid="props.cid" :courseid="props.courseid" :workId="workId" :workUrl="workUrl" :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :unsubmittedStudents="unsubmittedStudents" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale" :resultArray="currentIsResultArray" @setIsResultArray="setIsResultArray2" :isCreator="isCreator" @successLike="successLike" @sendMessage="sendMessage" :isFollowModeActive="isFollowModeActive"/>
+          <choiceQuestionDetailDialog ref="choiceQuestionDetailDialogRef" v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex) && !currentSlideHasEnglishSpeaking" :roleType="props.type" :cid="props.cid" :courseid="props.courseid" :workId="workId" :workUrl="workUrl" :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :unsubmittedStudents="unsubmittedStudents" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale" :resultArray="currentIsResultArray" @setIsResultArray="setIsResultArray2" :isCreator="isCreator" @successLike="successLike" @sendMessage="sendMessage" :isFollowModeActive="isFollowModeActive"/>
           <SpeakingClassPanel
-            v-else-if="choiceQuestionDetailDialogOpenList.includes(slideIndex) && currentSlideToolType === 77"
+            v-else-if="choiceQuestionDetailDialogOpenList.includes(slideIndex) && currentSlideToolType === TOPIC_DISCUSSION_TOOL_TYPE"
             ref="speakingPanelRef"
             :configId="currentSlideConfigId"
             :slideIndex="slideIndex"
@@ -118,6 +118,17 @@
             :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"
             :style="{ margin: '0 auto' }"
           />
+          <ArticleReadingClassPanel
+            v-else-if="choiceQuestionDetailDialogOpenList.includes(slideIndex) && currentSlideToolType === ARTICLE_READING_TOOL_TYPE"
+            ref="articleReadingPanelRef"
+            :configId="currentSlideConfigId"
+            :slideIndex="slideIndex"
+            :studentArray="studentArray"
+            :courseId="props.courseid || ''"
+            :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale"
+            :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"
+            :style="{ margin: '0 auto' }"
+          />
 
 
           <div class="slide-bottom" v-if="!isFullscreen">
@@ -437,6 +448,13 @@ import { Refresh } from '@icon-park/vue-next'
 import answerTheResult from './components/answerTheResult.vue'
 import choiceQuestionDetailDialog from './components/choiceQuestionDetailDialog.vue'
 import SpeakingClassPanel from './components/SpeakingClassPanel/index.vue'
+import ArticleReadingClassPanel from './components/ArticleReadingClassPanel/index.vue'
+import {
+  ARTICLE_READING_TOOL_TYPE,
+  TOPIC_DISCUSSION_TOOL_TYPE,
+  isArticleReadingTool,
+  isTopicDiscussionTool,
+} from '@/configs/englishSpeakingTools'
 import aiChat from './components/aiChat.vue'
 import messageInstruction from '@/utils/components/messageInstruction.vue'
 
@@ -585,6 +603,7 @@ const visibleChoice = ref(false)
 const visibleAI = ref(false)
 const choiceQuestionDetailDialogOpenList = ref<number[]>([])
 const speakingPanelRef = ref<InstanceType<typeof SpeakingClassPanel> | null>(null)
+const articleReadingPanelRef = ref<InstanceType<typeof ArticleReadingClassPanel> | null>(null)
 const choiceQuestionDetailDialogRef = ref<InstanceType<typeof choiceQuestionDetailDialog> | null>(null)
 
 provide('notifySpeakingProgress', (status: 'active' | 'completed', payload: { configId: string; sessionId: string }) => {
@@ -599,7 +618,10 @@ provide('notifySpeakingProgress', (status: 'active' | 'completed', payload: { co
   })
 })
 
-provide('recordSpeakingStart', async (sessionId: string) => {
+provide('recordSpeakingStart', async (
+  sessionId: string,
+  activityToolType: number = TOPIC_DISCUSSION_TOOL_TYPE,
+) => {
   // 老师端 (type=1) 与学生端 (type=2) 都允许写作业记录,方便老师自测/学生提交
   if (!sessionId || !props.courseid || !props.userid) return
   try {
@@ -609,7 +631,7 @@ provide('recordSpeakingStart', async (sessionId: string) => {
       stage: '0',
       task: String(slideIndex.value),
       tool: '0',
-      atool: '77',
+      atool: String(activityToolType),
       content: sessionId,
       type: '21',
     })
@@ -1062,7 +1084,8 @@ const currentSlideToolType = computed(() => {
   return Number((frame as any)?.toolType) || 0
 })
 const currentSlideConfigId = computed(() => {
-  if (currentSlideToolType.value !== 77) return ''
+  const toolType = currentSlideToolType.value
+  if (!isTopicDiscussionTool(toolType) && !isArticleReadingTool(toolType)) return ''
   const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
   return (frame as any)?.url || ''
 })
@@ -1074,10 +1097,12 @@ const currentSlideHasBilibiliVideo = computed(() => {
   )
 })
 
-// 检测当前幻灯片是否是英语口语
+// 检测当前幻灯片是否是英语口语(话题讨论或文章朗读):
+// 通用作业提交/刷新按钮对这两类都必须保持抑制
 const currentSlideHasEnglishSpeaking = computed(() => {
-  return elementList.value.some(element => 
-    element.type === ElementTypes.FRAME && (element.toolType === 77)
+  return elementList.value.some(element =>
+    element.type === ElementTypes.FRAME &&
+    (isTopicDiscussionTool(element.toolType) || isArticleReadingTool(element.toolType))
   )
 })
 
@@ -3637,6 +3662,7 @@ const getMessages = (msgObj: any) => {
   if (props.type == '1' && msgObj.type === 'speaking_session_updated' && msgObj.courseid === props.courseid) {
     console.log('收到英语口语状态更新,触发面板刷新')
     speakingPanelRef.value?.scheduleRefetch?.()
+    articleReadingPanelRef.value?.scheduleRefetch?.()
   }
 
   // 计时器消息 - 学生与老师端实时显示

+ 14 - 1
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -39,7 +39,13 @@
         ></video>
         <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
         <TopicDiscussionPreview
-          v-else-if="Number(elementInfo.toolType) === 77 && !isThumbnail && isVisible"
+          v-else-if="isTopicDiscussionTool(elementInfo.toolType) && !isThumbnail && isVisible"
+          :configId="elementInfo.url"
+          :style="{ width: '100%', height: '100%' }"
+        />
+        <!-- 文章朗读预览(type 772):独立分支;缩略图仍走下方通用工具名,不挂载录音 UI -->
+        <ArticleReadingPreview
+          v-else-if="isArticleReadingTool(elementInfo.toolType) && !isThumbnail && isVisible"
           :configId="elementInfo.url"
           :style="{ width: '100%', height: '100%' }"
         />
@@ -107,6 +113,12 @@ import type { PropType } from 'vue'
 import type { PPTFrameElement } from '@/types/slides'
 import { lang } from '@/main'
 import TopicDiscussionPreview from '@/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue'
+import ArticleReadingPreview from '@/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue'
+import {
+  ARTICLE_READING_TOOL_TYPE,
+  isArticleReadingTool,
+  isTopicDiscussionTool,
+} from '@/configs/englishSpeakingTools'
 import { ref, watch, nextTick } from 'vue'
 import { computed } from 'vue'
 
@@ -176,6 +188,7 @@ const getTypeLabel = (type: number): string => {
     77: 'ssEnglishSpeakingTool',
     78: 'ssVote',
     79: 'ssPhoto',
+    [ARTICLE_READING_TOOL_TYPE]: 'ssArticleReadingTool',
   }
   const key = typeMap[type]
   return (key ? lang[key] : lang.ssUnknown) as string

+ 24 - 11
src/views/components/element/FrameElement/index.vue

@@ -14,7 +14,7 @@
     >
       <div
         class="element-content"
-        :class="{ 'is-interactive-widget': isSpeakingTool }"
+        :class="{ 'is-interactive-widget': isInteractiveSpeakingTool }"
         v-contextmenu="contextmenus"
         @mousedown="$event => handleSelectElement($event)"
         @touchstart="$event => handleSelectElement($event)"
@@ -30,7 +30,13 @@
         ></video>
         <!-- 英语口语预览(type 77):使用 Vue 组件直接渲染,url 字段即 configId -->
         <TopicDiscussionPreview
-          v-else-if="isSpeakingTool"
+          v-else-if="isTopicDiscussion"
+          :configId="elementInfo.url"
+          :style="{ width: '100%', height: '100%' }"
+        />
+        <!-- 文章朗读预览(type 772):独立分支,不得回落到话题讨论或 iframe -->
+        <ArticleReadingPreview
+          v-else-if="isArticleReading"
           :configId="elementInfo.url"
           :style="{ width: '100%', height: '100%' }"
         />
@@ -67,10 +73,10 @@
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
         ></iframe>
 
-        <!-- type 77(英语口语预览)是一个需要内部交互的 Vue 组件:
+        <!-- type 77 / 772(英语口语预览)是需要内部交互的 Vue 组件:
              mask / drag-handler 会挡住 wheel 事件导致内部滚不了,
-             所以对 77 不渲染这两层,选中/拖动走外层 .element-content 的 mousedown。 -->
-        <template v-if="!isSpeakingTool">
+             所以对它们不渲染这两层,选中/拖动走外层 .element-content 的 mousedown。 -->
+        <template v-if="!isInteractiveSpeakingTool">
           <div class="drag-handler top"></div>
           <div class="drag-handler bottom"></div>
           <div class="drag-handler left"></div>
@@ -92,8 +98,11 @@ import type { PropType } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import { useSpeakingStore } from '@/store/speaking'
+import { useArticleReadingStore } from '@/store/articleReading'
+import { isTopicDiscussionTool, isArticleReadingTool } from '@/configs/englishSpeakingTools'
 import type { PPTFrameElement } from '@/types/slides'
 import TopicDiscussionPreview from '@/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue'
+import ArticleReadingPreview from '@/views/Editor/EnglishSpeaking/preview/ArticleReadingPreview.vue'
 import type { ContextmenuItem } from '@/components/Contextmenu/types'
 import { computed, ref, watch } from 'vue'
 
@@ -114,11 +123,14 @@ const props = defineProps({
 
 const { handleElementId } = storeToRefs(useMainStore())
 const speakingStore = useSpeakingStore()
+const articleStore = useArticleReadingStore()
 
-// 英语口语预览工具(FRAME + toolType 77):产品上占满整页、不可拖动、不可缩放/旋转。
-// 这个 flag 在 template 里决定是否渲染 mask/drag-handler + interactive-widget class,
-// 在 handleSelectElement 里决定是否禁止 drag + 是否弹配置面板。
-const isSpeakingTool = computed(() => Number(props.elementInfo.toolType) === 77)
+// 英语口语预览工具(FRAME + toolType 77 / 772):产品上占满整页、不可拖动、不可缩放/旋转。
+// 这些 flag 在 template 里决定是否渲染 mask/drag-handler + interactive-widget class,
+// 在 handleSelectElement 里决定是否禁止 drag + 弹哪一侧的配置面板。
+const isTopicDiscussion = computed(() => isTopicDiscussionTool(props.elementInfo.toolType))
+const isArticleReading = computed(() => isArticleReadingTool(props.elementInfo.toolType))
+const isInteractiveSpeakingTool = computed(() => isTopicDiscussion.value || isArticleReading.value)
 
 const iframeKey = ref(0)
 
@@ -132,9 +144,10 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
   e.stopPropagation()
   // 口语工具:强制 canMove=false 跳过 useSelectElement 的 dragElement 分支,禁止拖动。
   // 点击后仍弹出/切换配置面板(业务需求)。
-  const finalCanMove = isSpeakingTool.value ? false : canMove
+  const finalCanMove = isInteractiveSpeakingTool.value ? false : canMove
   props.selectElement(e, props.elementInfo, finalCanMove)
-  if (isSpeakingTool.value) speakingStore.openConfigPanel()
+  if (isTopicDiscussion.value) speakingStore.openConfigPanel()
+  if (isArticleReading.value) articleStore.openConfigPanel(props.elementInfo.url)
 }
 </script>
 

+ 106 - 0
src/views/lang/cn.json

@@ -793,6 +793,112 @@
   "ssManualCreate": "手动创建",
   "ssBackToRecommend": "返回推荐",
   "ssTopicDiscussion": "话题讨论",
+  "ssArticleReading": "文章朗读",
+  "ssArticleClassSummary": "AI 班级总结",
+  "ssArticleRefresh": "刷新",
+  "ssArticleGenerate": "生成",
+  "ssArticleSummarizing": "正在总结...",
+  "ssArticleSummaryFailed": "班级总结生成失败",
+  "ssArticleSummaryEmpty": "点击生成查看全班情况总结",
+  "ssArticleFilterCompleted": "已完成",
+  "ssArticleFilterReading": "进行中",
+  "ssArticleLoadingReport": "正在加载报告...",
+  "ssArticleReadingTool": "文章朗读",
+  "ssArticlePleaseConfigure": "请先配置文章",
+  "ssArticleStart": "开始朗读",
+  "ssArticleGeneratingReport": "正在生成学习报告...",
+  "ssArticleRetry": "重试",
+  "ssArticleMissingUser": "缺少学生信息,无法开始朗读",
+  "ssArticleReadCount": "已朗读段落",
+  "ssArticleTotalDuration": "练习时长",
+  "ssArticleHighlights": "做得好的地方",
+  "ssArticleSuggestions": "可以改进的地方",
+  "ssArticleOverallFailed": "整篇总评生成失败,以下为逐段结果",
+  "ssArticleOverallMissing": "暂无整篇总评,以下为逐段结果",
+  "ssArticleParagraphsTitle": "逐段详情",
+  "ssArticleSecondsUnit": "秒",
+  "ssArticleParagraphsUnit": "段",
+  "ssArticleDemoAudio": "示范",
+  "ssArticleMyAudio": "我的录音",
+  "ssArticleParagraphProgress": "第 {cur} / {total} 段",
+  "ssArticleUnlimitedLabel": "不限时",
+  "ssArticleProcessing": "评分中...",
+  "ssArticleEvalFailed": "评分失败",
+  "ssArticlePrevious": "上一段",
+  "ssArticleNext": "下一段",
+  "ssArticleStartRecord": "开始录音",
+  "ssArticleRerecordBtn": "重新录音",
+  "ssArticleFinishRecord": "完成",
+  "ssArticleCancelRecord": "取消",
+  "ssArticleReplay": "回放",
+  "ssArticleViewReport": "查看报告",
+  "ssArticleExitTitle": "选择操作",
+  "ssArticleExitHint": "请选择你的操作:",
+  "ssArticleContinuePractice": "继续练习",
+  "ssArticleEndAndReport": "结束并查看报告",
+  "ssArticleParagraphSummary": "段落总评",
+  "ssArticleWordFeedback": "整段反馈",
+  "ssArticleMore": "更多操作",
+  "ssArticleViewEval": "查看评价",
+  "ssArticleReportDone": "朗读完成!",
+  "ssArticleOverallScore": "综合评分",
+  "ssArticlePointsUnit": "分",
+  "ssArticlePraiseExcellent": "太棒了",
+  "ssArticlePraiseGreat": "棒极了",
+  "ssArticlePraiseGood": "不错哦",
+  "ssArticlePraiseKeepGoing": "加油鸭",
+  "ssArticleTeacherSays": "老师说",
+  "ssArticleTryAgain": "再来一次",
+  "ssArticleExpandAll": "展开全部",
+  "ssArticleCollapseAll": "收起全部",
+  "ssArticleParagraphIndex": "第 {n} 段",
+  "ssArticleRecordedTag": "已录",
+  "ssArticleWordCorrect": "正确",
+  "ssArticleWordMisread": "错读",
+  "ssArticleWordOmission": "漏读",
+  "ssArticleWordInsertion": "多读",
+  "ssArticleWordsUnit": "词",
+  "ssArticleReadAloud": "朗读练习",
+  "ssArticleTemplateCreated": "已添加文章朗读到幻灯片",
+  "ssArticleManualCreated": "已添加空白文章朗读,点击画布上的元素进行配置",
+  "ssArticleTitle": "文章标题",
+  "ssArticleTitlePlaceholder": "输入文章标题...",
+  "ssArticleContent": "文章正文",
+  "ssArticleContentPlaceholder": "粘贴或输入要朗读的文章正文...",
+  "ssArticleRequired": "请填写文章标题和正文",
+  "ssArticleSaved": "配置已应用",
+  "ssArticleAutoFormat": "自动分段",
+  "ssArticleAutoFormatting": "AI 分段中",
+  "ssArticleAutoFormattingHint": "预计需要约 30 秒,请稍候",
+  "ssArticlePasteFormatTitle": "是否进行自动分段?",
+  "ssArticlePasteFormatMessage": "检测到大段未分段文本,AI 可将其切分为适合朗读的段落",
+  "ssArticleOverwriteFormatTitle": "将覆盖当前已有分段结果,是否继续?",
+  "ssArticleOverwriteFormatMessage": "当前文本的分段将被 AI 分段结果替换",
+  "ssArticleGuardTitle": "单个段落超过最大支持字数",
+  "ssArticleGuardMessage": "请手动修改,或采用自动分段。",
+  "ssArticleGuardManual": "手动修改",
+  "ssArticleFormatCancel": "取消",
+  "ssArticleFormatConfirm": "确认",
+  "ssArticleSegmentFailed": "自动分段失败,请重试",
+  "ssArticleRestart": "重新开始",
+  "ssArticleRestartFailed": "重新开始失败,请重试",
+  "ssArticleSessionExpired": "上次练习已失效,请重新开始",
+  "ssArticleDismiss": "关闭",
+  "ssArticleErrConfigNotFound": "这篇文章的配置已不存在,请联系老师",
+  "ssArticleErrNotArticleConfig": "配置类型不匹配,请联系老师",
+  "ssArticleErrContentEmpty": "文章正文为空,请联系老师补充",
+  "ssArticleErrSessionNotFound": "练习记录已失效,请刷新页面重新开始",
+  "ssArticleErrParagraphNotFound": "段落已变更,请刷新页面重新开始",
+  "ssArticleErrSessionEnded": "本次练习时间已结束",
+  "ssArticleErrReportGenerated": "本次朗读报告已生成,无法再重录",
+  "ssArticleErrBadRequest": "请求参数有误,请刷新页面重试",
+  "ssArticleErrGeneric": "操作失败,请稍后重试",
+  "ssArticleErrDemoAudio": "第 {n} 段的示范音频生成失败,配置未保存,请重试",
+  "ssArticleGeneratingDemo": "生成示范音频中…",
+  "ssArticlePractisingOverwriteTitle": "有学生正在练习中",
+  "ssArticlePractisingOverwriteMessage": "{n} 名学生正在练习中。保存后他们刷新会回到开始页、重读新文章;当前这一轮的成绩不会进班级名单。仍要保存?",
+  "ssArticlePractisingOverwriteConfirm": "仍要保存",
+  "ssArticlePractisingOverwriteCancel": "取消",
   "ssDiscussionTopic": "讨论话题",
   "ssEnterTopic": "输入讨论话题...",
   "ssLearningGoals": "学习目标",

+ 106 - 0
src/views/lang/en.json

@@ -794,6 +794,112 @@
   "ssManualCreate": "Manual Create",
   "ssBackToRecommend": "Back to Recommend",
   "ssTopicDiscussion": "Topic Discussion",
+  "ssArticleReading": "Article Reading",
+  "ssArticleClassSummary": "AI class summary",
+  "ssArticleRefresh": "Refresh",
+  "ssArticleGenerate": "Generate",
+  "ssArticleSummarizing": "Summarizing...",
+  "ssArticleSummaryFailed": "Failed to generate the class summary",
+  "ssArticleSummaryEmpty": "Generate a summary of how the class did",
+  "ssArticleFilterCompleted": "Completed",
+  "ssArticleFilterReading": "In progress",
+  "ssArticleLoadingReport": "Loading the report...",
+  "ssArticleReadingTool": "Article Reading",
+  "ssArticlePleaseConfigure": "Please configure the article first",
+  "ssArticleStart": "Start reading",
+  "ssArticleGeneratingReport": "Generating your report...",
+  "ssArticleRetry": "Retry",
+  "ssArticleMissingUser": "Student information is missing, cannot start reading",
+  "ssArticleReadCount": "Paragraphs read",
+  "ssArticleTotalDuration": "Practice time",
+  "ssArticleHighlights": "What went well",
+  "ssArticleSuggestions": "What to improve",
+  "ssArticleOverallFailed": "The overall summary could not be generated; per-paragraph results follow",
+  "ssArticleOverallMissing": "No overall summary yet; per-paragraph results follow",
+  "ssArticleParagraphsTitle": "Paragraph details",
+  "ssArticleSecondsUnit": "s",
+  "ssArticleParagraphsUnit": "paragraphs",
+  "ssArticleDemoAudio": "Demo",
+  "ssArticleMyAudio": "My recording",
+  "ssArticleParagraphProgress": "Paragraph {cur} / {total}",
+  "ssArticleUnlimitedLabel": "Untimed",
+  "ssArticleProcessing": "Scoring...",
+  "ssArticleEvalFailed": "Scoring failed",
+  "ssArticlePrevious": "Previous",
+  "ssArticleNext": "Next",
+  "ssArticleStartRecord": "Start recording",
+  "ssArticleRerecordBtn": "Record again",
+  "ssArticleFinishRecord": "Done",
+  "ssArticleCancelRecord": "Cancel",
+  "ssArticleReplay": "Replay",
+  "ssArticleViewReport": "View report",
+  "ssArticleExitTitle": "Choose an action",
+  "ssArticleExitHint": "What would you like to do?",
+  "ssArticleContinuePractice": "Keep practising",
+  "ssArticleEndAndReport": "End and view report",
+  "ssArticleParagraphSummary": "Paragraph summary",
+  "ssArticleWordFeedback": "Paragraph feedback",
+  "ssArticleMore": "More actions",
+  "ssArticleViewEval": "View evaluation",
+  "ssArticleReportDone": "Reading complete!",
+  "ssArticleOverallScore": "Overall score",
+  "ssArticlePointsUnit": "pts",
+  "ssArticlePraiseExcellent": "Amazing",
+  "ssArticlePraiseGreat": "Great job",
+  "ssArticlePraiseGood": "Nice work",
+  "ssArticlePraiseKeepGoing": "Keep going",
+  "ssArticleTeacherSays": "Teacher says",
+  "ssArticleTryAgain": "Try again",
+  "ssArticleExpandAll": "Expand all",
+  "ssArticleCollapseAll": "Collapse all",
+  "ssArticleParagraphIndex": "Paragraph {n}",
+  "ssArticleRecordedTag": "Recorded",
+  "ssArticleWordCorrect": "correct",
+  "ssArticleWordMisread": "misread",
+  "ssArticleWordOmission": "omitted",
+  "ssArticleWordInsertion": "extra",
+  "ssArticleWordsUnit": "words",
+  "ssArticleReadAloud": "Read Aloud",
+  "ssArticleTemplateCreated": "Article reading added to the slide",
+  "ssArticleManualCreated": "Blank article reading added. Click the element on the canvas to configure it",
+  "ssArticleTitle": "Article title",
+  "ssArticleTitlePlaceholder": "Enter the article title...",
+  "ssArticleContent": "Article text",
+  "ssArticleContentPlaceholder": "Paste or type the text to read aloud...",
+  "ssArticleRequired": "Please fill in both the title and the text",
+  "ssArticleSaved": "Configuration applied",
+  "ssArticleAutoFormat": "Auto Format",
+  "ssArticleAutoFormatting": "Formatting...",
+  "ssArticleAutoFormattingHint": "This usually takes about 30 seconds",
+  "ssArticlePasteFormatTitle": "Split into paragraphs?",
+  "ssArticlePasteFormatMessage": "This text has no paragraph breaks. AI can split it into passages suited for reading aloud.",
+  "ssArticleOverwriteFormatTitle": "Replace the current paragraphs?",
+  "ssArticleOverwriteFormatMessage": "The current paragraph breaks will be replaced by the AI result.",
+  "ssArticleGuardTitle": "A paragraph exceeds the maximum length",
+  "ssArticleGuardMessage": "Please edit it manually, or use auto format.",
+  "ssArticleGuardManual": "Edit Manually",
+  "ssArticleFormatCancel": "Cancel",
+  "ssArticleFormatConfirm": "Confirm",
+  "ssArticleSegmentFailed": "Auto format failed. Please try again.",
+  "ssArticleRestart": "Start Over",
+  "ssArticleRestartFailed": "Could not start over. Please try again.",
+  "ssArticleSessionExpired": "This practice session is no longer valid. Please start over.",
+  "ssArticleDismiss": "Dismiss",
+  "ssArticleErrConfigNotFound": "This article's configuration no longer exists. Please contact your teacher.",
+  "ssArticleErrNotArticleConfig": "The configuration type does not match. Please contact your teacher.",
+  "ssArticleErrContentEmpty": "The article body is empty. Please ask your teacher to add it.",
+  "ssArticleErrSessionNotFound": "This practice record is no longer valid. Please refresh and start over.",
+  "ssArticleErrParagraphNotFound": "The paragraphs have changed. Please refresh and start over.",
+  "ssArticleErrSessionEnded": "Time is up for this practice session.",
+  "ssArticleErrReportGenerated": "The report for this attempt has been generated; re-recording is no longer available.",
+  "ssArticleErrBadRequest": "The request was invalid. Please refresh and try again.",
+  "ssArticleErrGeneric": "Something went wrong. Please try again later.",
+  "ssArticleErrDemoAudio": "Demo audio failed for paragraph {n}. Nothing was saved \u2014 please try again.",
+  "ssArticleGeneratingDemo": "Generating demo audio\u2026",
+  "ssArticlePractisingOverwriteTitle": "Students are practising",
+  "ssArticlePractisingOverwriteMessage": "{n} student(s) are practising. If you save, they will return to the start page on refresh and read the new text; scores from this round will not appear in the class list. Save anyway?",
+  "ssArticlePractisingOverwriteConfirm": "Save anyway",
+  "ssArticlePractisingOverwriteCancel": "Cancel",
   "ssDiscussionTopic": "Discussion Topic",
   "ssEnterTopic": "Enter discussion topic...",
   "ssLearningGoals": "Learning Goals",

+ 106 - 0
src/views/lang/hk.json

@@ -794,6 +794,112 @@
   "ssManualCreate": "手動創建",
   "ssBackToRecommend": "返回推薦",
   "ssTopicDiscussion": "話題討論",
+  "ssArticleReading": "文章朗讀",
+  "ssArticleClassSummary": "AI 班級總結",
+  "ssArticleRefresh": "重新整理",
+  "ssArticleGenerate": "產生",
+  "ssArticleSummarizing": "正在總結...",
+  "ssArticleSummaryFailed": "班級總結產生失敗",
+  "ssArticleSummaryEmpty": "點擊產生查看全班情況總結",
+  "ssArticleFilterCompleted": "已完成",
+  "ssArticleFilterReading": "進行中",
+  "ssArticleLoadingReport": "正在載入報告...",
+  "ssArticleReadingTool": "文章朗讀",
+  "ssArticlePleaseConfigure": "請先設定文章",
+  "ssArticleStart": "開始朗讀",
+  "ssArticleGeneratingReport": "正在產生學習報告...",
+  "ssArticleRetry": "重試",
+  "ssArticleMissingUser": "缺少學生資訊,無法開始朗讀",
+  "ssArticleReadCount": "已朗讀段落",
+  "ssArticleTotalDuration": "練習時長",
+  "ssArticleHighlights": "做得好的地方",
+  "ssArticleSuggestions": "可以改進的地方",
+  "ssArticleOverallFailed": "整篇總評產生失敗,以下為逐段結果",
+  "ssArticleOverallMissing": "暫無整篇總評,以下為逐段結果",
+  "ssArticleParagraphsTitle": "逐段詳情",
+  "ssArticleSecondsUnit": "秒",
+  "ssArticleParagraphsUnit": "段",
+  "ssArticleDemoAudio": "示範",
+  "ssArticleMyAudio": "我的錄音",
+  "ssArticleParagraphProgress": "第 {cur} / {total} 段",
+  "ssArticleUnlimitedLabel": "不限時",
+  "ssArticleProcessing": "評分中...",
+  "ssArticleEvalFailed": "評分失敗",
+  "ssArticlePrevious": "上一段",
+  "ssArticleNext": "下一段",
+  "ssArticleStartRecord": "開始錄音",
+  "ssArticleRerecordBtn": "重新錄音",
+  "ssArticleFinishRecord": "完成",
+  "ssArticleCancelRecord": "取消",
+  "ssArticleReplay": "回放",
+  "ssArticleViewReport": "查看報告",
+  "ssArticleExitTitle": "選擇操作",
+  "ssArticleExitHint": "請選擇你的操作:",
+  "ssArticleContinuePractice": "繼續練習",
+  "ssArticleEndAndReport": "結束並查看報告",
+  "ssArticleParagraphSummary": "段落總評",
+  "ssArticleWordFeedback": "整段回饋",
+  "ssArticleMore": "更多操作",
+  "ssArticleViewEval": "查看評價",
+  "ssArticleReportDone": "朗讀完成!",
+  "ssArticleOverallScore": "綜合評分",
+  "ssArticlePointsUnit": "分",
+  "ssArticlePraiseExcellent": "太棒了",
+  "ssArticlePraiseGreat": "棒極了",
+  "ssArticlePraiseGood": "不錯哦",
+  "ssArticlePraiseKeepGoing": "加油鴨",
+  "ssArticleTeacherSays": "老師說",
+  "ssArticleTryAgain": "再來一次",
+  "ssArticleExpandAll": "展開全部",
+  "ssArticleCollapseAll": "收起全部",
+  "ssArticleParagraphIndex": "第 {n} 段",
+  "ssArticleRecordedTag": "已錄",
+  "ssArticleWordCorrect": "正確",
+  "ssArticleWordMisread": "錯讀",
+  "ssArticleWordOmission": "漏讀",
+  "ssArticleWordInsertion": "多讀",
+  "ssArticleWordsUnit": "詞",
+  "ssArticleReadAloud": "朗讀練習",
+  "ssArticleTemplateCreated": "已新增文章朗讀到簡報頁",
+  "ssArticleManualCreated": "已新增空白文章朗讀,點擊畫布上的元素進行設定",
+  "ssArticleTitle": "文章標題",
+  "ssArticleTitlePlaceholder": "輸入文章標題...",
+  "ssArticleContent": "文章內文",
+  "ssArticleContentPlaceholder": "貼上或輸入要朗讀的文章內文...",
+  "ssArticleRequired": "請填寫文章標題與內文",
+  "ssArticleSaved": "設定已套用",
+  "ssArticleAutoFormat": "自動分段",
+  "ssArticleAutoFormatting": "AI 分段中",
+  "ssArticleAutoFormattingHint": "預計需要約 30 秒,請稍候",
+  "ssArticlePasteFormatTitle": "是否進行自動分段?",
+  "ssArticlePasteFormatMessage": "偵測到大段未分段文字,AI 可將其切分為適合朗讀的段落",
+  "ssArticleOverwriteFormatTitle": "將覆蓋目前已有分段結果,是否繼續?",
+  "ssArticleOverwriteFormatMessage": "目前文字的分段將被 AI 分段結果取代",
+  "ssArticleGuardTitle": "單個段落超過最大支援字數",
+  "ssArticleGuardMessage": "請手動修改,或採用自動分段。",
+  "ssArticleGuardManual": "手動修改",
+  "ssArticleFormatCancel": "取消",
+  "ssArticleFormatConfirm": "確認",
+  "ssArticleSegmentFailed": "自動分段失敗,請重試",
+  "ssArticleRestart": "重新開始",
+  "ssArticleRestartFailed": "重新開始失敗,請重試",
+  "ssArticleSessionExpired": "上次練習已失效,請重新開始",
+  "ssArticleDismiss": "關閉",
+  "ssArticleErrConfigNotFound": "這篇文章的設定已不存在,請聯絡老師",
+  "ssArticleErrNotArticleConfig": "設定類型不符,請聯絡老師",
+  "ssArticleErrContentEmpty": "文章內文為空,請聯絡老師補充",
+  "ssArticleErrSessionNotFound": "練習紀錄已失效,請重新整理頁面再開始",
+  "ssArticleErrParagraphNotFound": "段落已變更,請重新整理頁面再開始",
+  "ssArticleErrSessionEnded": "本次練習時間已結束",
+  "ssArticleErrReportGenerated": "本次朗讀報告已產生,無法再重錄",
+  "ssArticleErrBadRequest": "請求參數有誤,請重新整理頁面再試",
+  "ssArticleErrGeneric": "操作失敗,請稍後再試",
+  "ssArticleErrDemoAudio": "第 {n} 段的示範音頻生成失敗,配置未保存,請重試",
+  "ssArticleGeneratingDemo": "生成示範音頻中…",
+  "ssArticlePractisingOverwriteTitle": "有學生正在練習中",
+  "ssArticlePractisingOverwriteMessage": "{n} 名學生正在練習中。儲存後他們重新載入會回到開始頁、重讀新文章;目前這一輪的成績不會進班級名單。仍要儲存?",
+  "ssArticlePractisingOverwriteConfirm": "仍要儲存",
+  "ssArticlePractisingOverwriteCancel": "取消",
   "ssDiscussionTopic": "討論話題",
   "ssEnterTopic": "輸入討論話題...",
   "ssLearningGoals": "學習目標",