周联兴 1 dag sedan
förälder
incheckning
aa16f0ab1c
46 ändrade filer med 3963 tillägg och 78 borttagningar
  1. 37 4
      README.md
  2. 13 1
      config/index.ts
  3. 1 1
      package.json
  4. 35 0
      scripts/create-answer-reference.mjs
  5. 22 0
      scripts/create-capture-reference.mjs
  6. 165 0
      scripts/create-demo-media.mjs
  7. 1 1
      src/app.config.ts
  8. 1 1
      src/app.scss
  9. 2 1
      src/app.tsx
  10. BIN
      src/assets/answer-capture-reference.png
  11. BIN
      src/assets/explanation-0.mp4
  12. BIN
      src/assets/explanation-1.mp4
  13. BIN
      src/assets/explanation-2.mp4
  14. BIN
      src/assets/explanation-3.mp4
  15. BIN
      src/assets/paper-capture-reference.png
  16. BIN
      src/assets/sample-answer.png
  17. BIN
      src/assets/sample-paper.png
  18. 6 5
      src/components/DemoSheet.tsx
  19. 13 0
      src/components/PhotoImage.tsx
  20. 122 0
      src/components/StudentPicker.scss
  21. 77 0
      src/components/StudentPicker.tsx
  22. 222 0
      src/components/WorkbenchUI.tsx
  23. 522 0
      src/components/workbench.scss
  24. 116 0
      src/data/local-media.ts
  25. 58 0
      src/data/workbench-store.tsx
  26. 338 0
      src/data/workbench.ts
  27. 1 0
      src/pages/explanations/index.config.ts
  28. 4 0
      src/pages/explanations/index.scss
  29. 172 0
      src/pages/explanations/index.tsx
  30. 2 1
      src/pages/home/index.scss
  31. 15 36
      src/pages/home/index.tsx
  32. 1 0
      src/pages/library/index.config.ts
  33. 4 0
      src/pages/library/index.scss
  34. 224 0
      src/pages/library/index.tsx
  35. 1 0
      src/pages/messages/index.config.ts
  36. 4 0
      src/pages/messages/index.scss
  37. 160 0
      src/pages/messages/index.tsx
  38. 174 18
      src/pages/profile/index.tsx
  39. 1 0
      src/pages/upload/index.config.ts
  40. 186 0
      src/pages/upload/index.scss
  41. 782 0
      src/pages/upload/index.tsx
  42. 350 0
      tests/e2e/flows.spec.ts
  43. 7 8
      tests/e2e/workbench.spec.ts
  44. 32 1
      tests/environment.test.mjs
  45. 91 0
      tests/workbench.test.mjs
  46. 1 0
      types/global.d.ts

+ 37 - 4
README.md

@@ -2,7 +2,38 @@
 
 `math.client-pc` 的教师工作台小程序,使用 Taro 4、React 18、TypeScript、Vite 和 SCSS。此目录为独立 Git 仓库,使用 pnpm 管理依赖。
 
-当前仅实现首页和「我的」页面,所有环境均使用虚构的本地演示数据,不调用后端、不读取或上传文件、不播放真实视频。上传入口、消息、题目讲解、账号信息、密码校验与退出/重新进入演示账号支持本地交互;退出不代表真实鉴权。
+已按工作区「上传错题小程序.html」原型实现前端流程,所有环境使用本地模拟数据,不调用后端接口。可选择本地照片和 Word/PDF 文件,文件仅保存在当前设备;本地讲解视频可播放。退出/重新进入演示账号仅改变本地展示状态,不代表真实鉴权。
+
+## 原型功能
+
+- 上传错题:跨班级多选学生、连续拍摄/相册多选、Word/PDF 导入、照片预览与删除、按页识别、题号/划动切换、标记错题、修改题干/选择题选项/答案/解析/年级/知识点/难度,提交所选错题。
+- 上传试卷:必填名称、年份、类型、省份;多页采集与逐题编辑;提交后显示待审核,约 20 秒后本地模拟审核通过并产生通知。
+- 上传答题卡:拍摄规范、相册多选、连续拍摄、逐张预览/删除/重拍、批量提交。成功后提示到 PC 客户端「批量解析」查看结果,当前版本不执行真实解析。
+- 失败重试:确认页提供「体验上传失败与重试」入口;错题/试卷保留编辑结果,答题卡展示失败图片并支持仅重拍失败项,已成功图片不重复处理。
+- 题目讲解:待查看/处理中筛选、关联学生与请求/预计/生成时间、完成通知、包内无声讲解示范、观看进度与已读状态。初始示例包含 3 条就绪讲解、1 条约 12 秒后完成的讲解。
+- 消息:按视频讲解/试卷/题目/答题卡筛选、全部已读、上传与审核详情、试卷题目和原图分页查看。
+- 题库与统计:保留题库页面,支持按年级、知识点、题干或学生筛选,展示本地题量、错题、关联学生和近 7 天趋势;包含 3 份多页示例卷。首页不显示题库管理和上传记录入口,消息仍可通过右上角铃铛查看。
+- 我的:编辑姓名/手机号/邮箱并保存到本地、密码表单校验、关于与退出演示账号;不会保存或验证真实密码。
+
+识别结果固定使用数学样例,每张照片/每个文档模拟 3 道题。Word/PDF 仅演示文件选择和识别流程,不解析实际文档内容或实际页数,也不提供文档原图预览。真实拍照需要微信相机权限,H5 拍照行为由浏览器与设备支持决定。
+
+题库、通知、资料和观看进度使用 Taro 本地存储 `zhiliao-miniapp-workbench-v1`。微信端提交时将临时照片保存为本地文件;H5 保存为数据 URL,刷新后仍可查看。设备存储不足时显示失败并保留当前编辑供重试,未提交草稿不跨刷新保留。清除此项本地存储可恢复示例数据;不要使用真实敏感资料体验。
+
+照片参考图及 4 段讲解视频由 `scripts/create-demo-media.mjs` 离线生成,拍摄示例页的试卷参考图由 `scripts/create-capture-reference.mjs` 绘制,已随源码提供。需要重新生成时运行对应的 `node scripts/…mjs`(使用本机 Chrome),普通构建无需运行。媒体资源输出为独立文件,避免微信端不支持的视频数据 URL。
+
+上传错题页使用学生关联条、拍照区域和相册/文件双列入口;添加照片后显示数量与「识别全部照片」。「查看示例」页仅展示拍摄规范、完整试卷参考、三项拍摄提示和多页说明;拍照或导入照片请返回上传页操作。
+
+上传试卷页使用浅紫色信息卡:名称独占一行、年份与类型并排、省份独占一行。下方为拍照区域、已添加照片入口和「识别全部照片」按钮;相册和 Word/PDF 导入入口位于后方,表单和已添加文件在查看示例或照片后保留。
+
+上传答题卡页展示拍摄规范、浅紫色 A4 答题卡参考卡、三项提示,以及「从相册多选」「拍摄答题卡」双按钮。参考图片由 `scripts/create-answer-reference.mjs` 离线生成,包含姓名、班级、示意二维码和作答区域;首次选择照片后进入确认页。
+
+关联学生使用居中弹窗,未关联学生时班级显示「请选择班级」,不默认选中班级;已有选择时重新打开弹窗,回显首位已选学生的班级及该班已选学生。先选择班级,再通过学生下拉多选。跨班级保留名单,已选学生以可删除标签展示。取消会丢弃本次选择,确认后才更新上传页名单。
+
+关联名单仅用于本次上传,不保存为下次默认选择;重新进入、刷新页面或成功后「继续上传」均清空。查看照片和拍摄示例后返回,仍保留本次选择;已提交记录中的关联学生继续保留。
+
+拍照、从相册选择和导入 Word/PDF 均不校验关联学生;点击「识别全部照片」时才校验,未关联学生则弹出选择框,取消后保留已添加的文件。
+
+「已添加照片」按顺序展示照片缩略图、文件名、查看与删除入口;Word/PDF 使用文件图标。顶部可继续拍照,底部「完成」返回上传页,再统一识别。图片预览只包含照片,不包含文档。
 
 ## 安装与预览
 
@@ -110,12 +141,14 @@ pnpm build:h5
 pnpm test:e2e
 ```
 
-单元测试覆盖环境校验、模式文件加载、个人与进程覆盖优先级、布尔值转换、统一输出目录、根目录导入配置、错误命令和当前产物的实际编译变量;可通过 `EXPECTED_APP_ENV` 指定产物应有的环境。E2E 使用本机 Chrome,覆盖 390px/320px 两种视口、两页导航、本地弹层、密码演示、退出与重新进入,并检查无业务 API 请求。未安装 Chrome 时需先安装,或修改 Playwright 的浏览器配置。
+单元测试覆盖环境校验、模式文件加载、覆盖优先级、统一输出目录、产物配置,以及追加/删除照片时保留编辑、跨班级同名学生、表单校验、重复提交、审核回传和消息已读;可通过 `EXPECTED_APP_ENV` 指定产物应有的环境。E2E 使用本机 Chrome,覆盖 390px/320px 两种视口、错题编辑与重试、答题卡部分失败与重拍、试卷元信息/本地图片/PDF 选择/审核回传、刷新保留原图、视频播放、题库、个人页与无业务 API 请求。未安装 Chrome 时需先安装,或修改 Playwright 的浏览器配置。相机权限、微信文件选择器和原生视频播放仍需微信开发者工具及真机验证。
 
 ## 目录约定
 
-- `src/pages`:首页和个人页;`src/components`:公共弹层、安全区和环境标记。
-- `src/data`:本地演示数据及演示登录态,仅存内存,刷新恢复初始数据。
+各页面保留自己的 `index.scss` 样式入口,确保微信端生成对应的 `index.wxss`。产物测试逐页检查 JS、JSON、WXML、WXSS,避免微信开发者工具因缺失页面样式而编译失败。
+
+- `src/pages`:首页、个人页、上传、消息、讲解和题库;`src/components`:公共表单、题目卡片、学生选择、安全区与弹层。
+- `src/data`:本地领域类型、模拟识别/回传、持久化状态、媒体适配及演示登录态;后续接接口可替换数据层。
 - `config`、`scripts`:环境与跨平台构建入口。
 - `tests`:环境回归与浏览器交互验证。
 

+ 13 - 1
config/index.ts

@@ -10,6 +10,12 @@ export default defineConfig<'vite'>(() => {
   const platform = process.env.TARO_ENV ?? 'weapp'
   // Taro loads .env files for the explicit --mode before evaluating this config.
   const publicConfig = readPublicConfig(process.env)
+  // Taro's asset loader receives normalized module IDs on Windows. Use a
+  // relative output name so non-inline media never retain an absolute path.
+  const assetOptions = {
+    limit: 0,
+    name: (moduleId: string) => path.relative(path.resolve(__dirname, '../src'), moduleId).split(path.sep).join('/'),
+  }
   const metadataDirectory = path.resolve(__dirname, '../.cache/build', platform, environment.name)
   const metadata = {
     environment: environment.name, label: publicConfig.label, platform,
@@ -47,8 +53,14 @@ export default defineConfig<'vite'>(() => {
       ],
       options: {},
     },
-    mini: { postcss: { pxtransform: { enable: true }, cssModules: { enable: false } } },
+    mini: {
+      imageUrlLoaderOption: assetOptions,
+      mediaUrlLoaderOption: assetOptions,
+      postcss: { pxtransform: { enable: true }, cssModules: { enable: false } },
+    },
     h5: {
+      imageUrlLoaderOption: assetOptions,
+      mediaUrlLoaderOption: assetOptions,
       publicPath: '/',
       staticDirectory: 'static',
       router: { mode: 'hash' },

+ 1 - 1
package.json

@@ -19,7 +19,7 @@
     "build:h5": "node scripts/taro.mjs build h5 development",
     "typecheck": "tsc --noEmit",
     "lint": "eslint .",
-    "test": "node --experimental-strip-types --test tests/environment.test.mjs",
+    "test": "node --experimental-strip-types --test tests/*.test.mjs",
     "test:e2e": "playwright test"
   },
   "dependencies": {

+ 35 - 0
scripts/create-answer-reference.mjs

@@ -0,0 +1,35 @@
+import { chromium } from '@playwright/test'
+import { writeFile } from 'node:fs/promises'
+
+// Render the prototype's sample card locally, without loading scripts or remote assets.
+const html = `<style>
+* { box-sizing:border-box; } body { margin:0; font-family:Arial,'Microsoft YaHei',sans-serif; }
+.answer-paper { width:202px; background:white; padding:15px 13px 12px; color:#393441; position:relative; border:1px solid #d6cee2; }
+.answer-paper:before,.answer-paper:after { content:''; position:absolute; width:9px; height:9px; background:#343039; top:7px; }
+.answer-paper:before { left:7px; } .answer-paper:after { right:7px; }
+.ap-brand { font-size:8px; font-weight:700; color:#6f5d86; text-align:center; letter-spacing:2px; }
+.ap-title { text-align:center; font-size:12px; font-weight:700; margin:6px 0; }
+.ap-meta { display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #c9c3d1; padding-bottom:7px; font-size:7px; line-height:1.8; }
+.ap-qr { width:30px; height:30px; flex-shrink:0; } .ap-qr svg { display:block; width:100%; height:100%; }
+.ap-note { font-size:6px; color:#999; padding:5px 0; }
+.ap-question { border:1px solid #d8d3de; padding:6px; margin-top:5px; font-size:7px; min-height:43px; }
+.ap-question b { font-size:7px; } .ap-hand { color:#3d5680; font-style:italic; font-family:Georgia,'KaiTi',serif; font-size:11px; margin-top:6px; }
+.ap-footer { display:flex; justify-content:space-between; font-size:6px; color:#aaa; margin-top:8px; }
+</style>
+<div class="answer-paper">
+<div class="ap-brand">知了数学 · 答题卡</div><div class="ap-title">二次函数 · 答题卡</div>
+<div class="ap-meta"><span>姓名:李明\u3000 班级:初二(1)班<br>答题卡编号:261022000030001</span><span class="ap-qr"><svg viewBox="0 0 29 29"><rect width="29" height="29" fill="white"/><path fill="#333" d="M1 1h8v8H1z M20 1h8v8h-8z M1 20h8v8H1z M12 1h3v3h-3z M11 6h5v3h-5z M2 12h5v4H2z M10 11h4v5h-4z M16 11h3v3h-3z M21 12h7v3h-7z M16 17h6v3h-6z M24 17h4v5h-4z M11 20h3v8h-3z M16 23h4v5h-4z M22 25h6v3h-6z"/><path fill="white" d="M3 3h4v4H3z M22 3h4v4h-4z M3 22h4v4H3z"/><path fill="#333" d="M4 4h2v2H4z M23 4h2v2h-2z M4 23h2v2H4z"/></svg></span></div>
+<div class="ap-note">请在指定区域内作答,保持卷面清晰完整。</div>
+<div class="ap-question"><b>1. 选择题(请清晰填涂)</b><div style="margin-top:7px">1. [ A ] [ B ] [ ● ] [ D ]\u30002. [ A ] [ ● ] [ C ] [ D ]</div></div>
+<div class="ap-question"><b>2. 填空题</b><div class="ap-hand">3. (1, 0)、(3, 0)</div></div>
+<div class="ap-question" style="min-height:68px"><b>3. 解答题(保留解题步骤)</b><div class="ap-hand">解:y = a(x − 1)² + 4<br>代入 (0, 3),得 a = −1<br>∴ y = −(x − 1)² + 4</div></div>
+<div class="ap-footer"><span>■\u3000请保留四周边缘及二维码</span><span>第 1 页 / 共 1 页\u3000■</span></div>
+</div>`
+const browser = await chromium.launch({ channel: 'chrome', headless: true })
+try {
+  const page = await browser.newPage({ deviceScaleFactor: 3 })
+  await page.setContent(html)
+  await writeFile('src/assets/answer-capture-reference.png', await page.locator('.answer-paper').screenshot())
+} finally {
+  await browser.close()
+}

+ 22 - 0
scripts/create-capture-reference.mjs

@@ -0,0 +1,22 @@
+import { chromium } from '@playwright/test'
+import { writeFile } from 'node:fs/promises'
+
+// Code-drawn reference matching the local prototype; no remote media required.
+const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="800"><rect width="600" height="800" fill="#e8e6eb"/><rect x="30" y="24" width="540" height="752" fill="white"/><g fill="#333" font-family="Arial,Microsoft YaHei"><text x="300" y="82" text-anchor="middle" font-size="25">数学练习试卷</text><text x="65" y="125" font-size="15">姓名:________\u3000班级:________</text><path d="M60 145H540" stroke="#aaa"/><text x="65" y="185" font-size="18">1. 已知 y = x² − 4x + 3,求与 x 轴的交点。</text><text x="80" y="225" fill="#345a83" font-size="19">解:令 y = 0,得 (x − 1)(x − 3) = 0</text><text x="80" y="260" fill="#345a83" font-size="19">所以交点为 (1, 0)、(3, 0)。</text><text x="65" y="345" font-size="18">2. 求二次函数 y = −(x − 1)² + 4 的顶点。</text><text x="80" y="390" fill="#345a83" font-size="19">顶点坐标为 (1, 4)。</text><text x="65" y="480" font-size="18">3. 求函数的对称轴与单调区间。</text><path d="M100 670H460M220 710V530M130 650Q270 430 410 650" fill="none" stroke="#777" stroke-width="2"/><text x="300" y="746" text-anchor="middle" font-size="12" fill="#aaa">第 1 页</text></g></svg>`
+const browser = await chromium.launch({ channel: 'chrome', headless: true })
+try {
+  const page = await browser.newPage()
+  const png = await page.evaluate(async (source) => {
+    const image = new Image()
+    image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`
+    await image.decode()
+    const canvas = document.createElement('canvas')
+    canvas.width = 600
+    canvas.height = 800
+    canvas.getContext('2d').drawImage(image, 0, 0)
+    return canvas.toDataURL('image/png').split(',')[1]
+  }, svg)
+  await writeFile('src/assets/paper-capture-reference.png', Buffer.from(png, 'base64'))
+} finally {
+  await browser.close()
+}

+ 165 - 0
scripts/create-demo-media.mjs

@@ -0,0 +1,165 @@
+import { chromium } from '@playwright/test'
+import { mkdir, writeFile } from 'node:fs/promises'
+
+// Offline fixtures for photo guidance and silent explanation playback.
+const browser = await chromium.launch({ channel: 'chrome', headless: true })
+const page = await browser.newPage()
+await page.goto('about:blank')
+await mkdir('src/assets', { recursive: true })
+try {
+  for (const answer of [false, true]) {
+    const png = await page.evaluate(
+      ({ answer }) => {
+        const c = document.createElement('canvas')
+        c.width = 720
+        c.height = 960
+        const x = c.getContext('2d')
+        x.fillStyle = '#fff'
+        x.fillRect(0, 0, 720, 960)
+        x.strokeStyle = '#50425e'
+        x.lineWidth = 2
+        x.strokeRect(24, 24, 672, 912)
+        x.fillStyle = '#49315e'
+        x.textAlign = 'center'
+        x.font = 'bold 32px sans-serif'
+        x.fillText(answer ? '知了数学 · 答题卡' : '知了数学 · 数学练习卷', 360, 80)
+        x.font = '22px sans-serif'
+        x.fillText('二次函数专项练习', 360, 128)
+        x.textAlign = 'left'
+        x.fillStyle = '#5e5667'
+        x.font = '18px sans-serif'
+        x.fillText('姓名:李一       班级:初二       日期:2026/10/26', 54, 180)
+        if (answer) {
+          for (let r = 0; r < 15; r++)
+            for (let col = 0; col < 15; col++)
+              if ((r * 7 + col * 3) % 5 < 3) x.fillRect(588 + col * 4, 200 + r * 4, 4, 4)
+          x.font = '16px sans-serif'
+          x.fillText('编号:261026000030001', 54, 220)
+        }
+        const rows = answer
+          ? [
+              ['1. 选择题(请清晰填涂)', '1. [ A ] [ B ] [ ● ] [ D ]', '2. [ ● ] [ B ] [ C ] [ D ]'],
+              ['2. 填空题', '交点坐标:(1, 0) 和 (3, 0)', '请保留完整作答区域'],
+              ['3. 解答题(保留步骤)', '解:设 y = a(x − 1)² + 4', '代入 (0, 3),得 a = −1', '所以 y = −(x − 1)² + 4']
+            ]
+          : [
+              [
+                '1. 函数图像平移(选择题)',
+                '将 y = −(x − 2)² + 3 平移至 y = −(x + 1)² − 2。',
+                'A. 右移3,上移5    B. 左移3,上移5',
+                'C. 左移3,下移5    D. 右移3,下移5'
+              ],
+              [
+                '2. 二次函数与坐标轴(填空题)',
+                '求 y = x² − 4x + 3 与 x 轴的交点坐标。',
+                '答:________________________________'
+              ],
+              [
+                '3. 抛物线解析式(解答题)',
+                '已知顶点 (1, 4),且过点 (0, 3)。',
+                '求抛物线的解析式及递减区间。',
+                '请写出解题步骤。'
+              ]
+            ]
+        rows.forEach((lines, n) => {
+          const top = 280 + n * 195
+          x.strokeStyle = '#ded6e7'
+          x.strokeRect(48, top - 28, 624, 175)
+          lines.forEach((line, i) => {
+            x.font = i === 0 ? 'bold 21px sans-serif' : '18px sans-serif'
+            x.fillStyle = answer && i > 0 ? '#33598a' : '#493e53'
+            x.fillText(line, 64, top + i * 37)
+          })
+        })
+        x.font = '17px sans-serif'
+        x.fillStyle = '#95859f'
+        x.fillText('■ 保留四周边缘及完整题目', 52, 914)
+        x.fillText('第 1 页 · 页面示例', 490, 914)
+        return c.toDataURL('image/png').split(',')[1]
+      },
+      { answer }
+    )
+    await writeFile(`src/assets/sample-${answer ? 'answer' : 'paper'}.png`, Buffer.from(png, 'base64'))
+  }
+  const clips = [
+    [
+      ['函数图像平移', '顶点 (2, 3) → (−1, −2)'],
+      ['横坐标减少 3', '向左平移 3 个单位'],
+      ['纵坐标减少 5', '向下平移 5 个单位'],
+      ['参考答案:C', '向左平移3个单位,向下平移5个单位']
+    ],
+    [
+      ['二次函数交点问题', 'y = x² − 4x + 3'],
+      ['令 y = 0', 'x² − 4x + 3 = 0'],
+      ['因式分解', '(x − 1)(x − 3) = 0'],
+      ['参考答案', '(1, 0) 和 (3, 0)']
+    ],
+    [
+      ['已知顶点 (1, 4)', '设 y = a(x − 1)² + 4'],
+      ['代入 (0, 3)', '3 = a + 4,所以 a = −1'],
+      ['得到解析式', 'y = −(x − 1)² + 4'],
+      ['开口向下,对称轴 x = 1', '当 x > 1 时,函数递减']
+    ],
+    [
+      ['一元二次方程', 'x² − 5x + 6 = 0'],
+      ['因式分解', '(x − 2)(x − 3) = 0'],
+      ['分别求解', 'x − 2 = 0 或 x − 3 = 0'],
+      ['参考答案:A', 'x₁ = 2,x₂ = 3']
+    ]
+  ]
+  for (let i = 0; i < clips.length; i++) {
+    const data = await page.evaluate(async (steps) => {
+      const mime = ['video/mp4;codecs=avc1.42001E', 'video/mp4'].find((v) => MediaRecorder.isTypeSupported(v))
+      if (!mime) throw new Error('Chrome does not support MP4 recording')
+      const canvas = document.createElement('canvas')
+      canvas.width = 800
+      canvas.height = 500
+      const x = canvas.getContext('2d')
+      const stream = canvas.captureStream(10)
+      const recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 200000 })
+      const chunks = []
+      const start = performance.now()
+      const draw = () => {
+        const seconds = (performance.now() - start) / 1000
+        const step = steps[Math.min(3, Math.floor(seconds / 2))]
+        x.fillStyle = '#271a3b'
+        x.fillRect(0, 0, 800, 500)
+        x.fillStyle = '#bba1e5'
+        x.font = '22px sans-serif'
+        x.fillText('知了数学 · 题目讲解示范', 42, 62)
+        x.fillStyle = '#fff'
+        x.font = 'bold 30px sans-serif'
+        x.fillText(step[0], 42, 170)
+        x.font = '30px sans-serif'
+        x.fillText(step[1], 42, 270)
+        x.fillStyle = '#bdacce'
+        x.font = '20px sans-serif'
+        x.fillText(`第 ${Math.min(4, Math.floor(seconds / 2) + 1)} 步 / 共 4 步`, 42, 390)
+        x.fillStyle = '#a479ea'
+        x.fillRect(42, 440, Math.min(716, (seconds / 8) * 716), 6)
+      }
+      draw()
+      const done = new Promise((resolve) => {
+        recorder.onstop = () => resolve(new Blob(chunks, { type: mime }))
+        recorder.ondataavailable = (e) => {
+          if (e.data.size) chunks.push(e.data)
+        }
+      })
+      recorder.start()
+      const interval = setInterval(draw, 100)
+      await new Promise((resolve) => setTimeout(resolve, 8100))
+      recorder.stop()
+      clearInterval(interval)
+      const blob = await done
+      stream.getTracks().forEach((t) => t.stop())
+      return await new Promise((resolve) => {
+        const reader = new FileReader()
+        reader.onload = () => resolve(String(reader.result).split(',')[1])
+        reader.readAsDataURL(blob)
+      })
+    }, clips[i])
+    await writeFile(`src/assets/explanation-${i}.mp4`, Buffer.from(data, 'base64'))
+  }
+} finally {
+  await browser.close()
+}

+ 1 - 1
src/app.config.ts

@@ -1,5 +1,5 @@
 export default defineAppConfig({
-  pages: ['pages/home/index', 'pages/profile/index'],
+  pages: ['pages/home/index', 'pages/profile/index', 'pages/upload/index', 'pages/messages/index', 'pages/explanations/index', 'pages/library/index'],
   window: {
     navigationBarTitleText: '知了数学',
     navigationBarTextStyle: 'black',

+ 1 - 1
src/app.scss

@@ -1,5 +1,5 @@
 page, body { margin: 0; background: #fff; color: #1b1d30; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; }
-view, button, input, scroll-view, taro-view-core, taro-button-core, taro-input-core { box-sizing: border-box; }
+view, button, input, textarea, scroll-view, taro-view-core, taro-button-core, taro-input-core, taro-textarea-core { box-sizing: border-box; }
 button::after, taro-button-core::after { border: 0; }
 .workbench-card::after, .settings-row::after, .notification-button::after,
 .sheet-close::after, .primary-button::after, .logout-button::after { border: 0; pointer-events: none; }

+ 2 - 1
src/app.tsx

@@ -1,7 +1,8 @@
 import type { PropsWithChildren } from 'react'
 import { DemoProvider } from './data/demo-session'
+import { WorkbenchProvider } from './data/workbench-store'
 import './app.scss'
 
 export default function App({ children }: PropsWithChildren) {
-  return <DemoProvider>{children}</DemoProvider>
+  return <DemoProvider><WorkbenchProvider>{children}</WorkbenchProvider></DemoProvider>
 }

BIN
src/assets/answer-capture-reference.png


BIN
src/assets/explanation-0.mp4


BIN
src/assets/explanation-1.mp4


BIN
src/assets/explanation-2.mp4


BIN
src/assets/explanation-3.mp4


BIN
src/assets/paper-capture-reference.png


BIN
src/assets/sample-answer.png


BIN
src/assets/sample-paper.png


+ 6 - 5
src/components/DemoSheet.tsx

@@ -2,13 +2,14 @@ import { useEffect, type PropsWithChildren } from 'react'
 import { Button, ScrollView, Text, View } from '@tarojs/components'
 import Taro from '@tarojs/taro'
 
-type Props = PropsWithChildren<{ title: string; onClose: () => void }>
+type Props = PropsWithChildren<{ title: string; onClose: () => void; hideTabBar?: boolean }>
 
-export default function DemoSheet({ title, children, onClose }: Props) {
+export default function DemoSheet({ title, children, onClose, hideTabBar = true }: Props) {
   useEffect(() => {
-    void Taro.hideTabBar({ animation: false })
-    return () => { void Taro.showTabBar({ animation: false }) }
-  }, [])
+    if (!hideTabBar) return
+    void Taro.hideTabBar({ animation: false }).catch(() => {})
+    return () => { void Taro.showTabBar({ animation: false }).catch(() => {}) }
+  }, [hideTabBar])
 
   return (
     <View className="sheet-layer">

+ 13 - 0
src/components/PhotoImage.tsx

@@ -0,0 +1,13 @@
+import { Image, Text, View, type ImageProps } from '@tarojs/components'
+
+export default function PhotoImage({ src, ...props }: ImageProps) {
+  if (src?.startsWith('document:'))
+    return (
+      <View className={`document-preview ${props.className || ''}`}>
+        <Text className="capture-symbol">📄</Text>
+        <Text>{decodeURIComponent(src.slice('document:'.length))}</Text>
+        <Text className="panel-description">文档文件,暂不提供原图预览</Text>
+      </View>
+    )
+  return <Image src={src} {...props} />
+}

+ 122 - 0
src/components/StudentPicker.scss

@@ -0,0 +1,122 @@
+.student-picker-layer {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.student-dialog {
+  position: relative;
+  width: calc(100% - 48px);
+  max-width: 750px;
+  max-height: 90vh;
+  padding: 48px;
+  border-radius: 24px;
+  background: #fff;
+  color: #1a1a2e;
+}
+.student-dialog-title {
+  display: block;
+  margin-bottom: 36px;
+  text-align: center;
+  font-size: 36px;
+  font-weight: 700;
+  line-height: 1.5;
+}
+.student-dialog-body {
+  display: block;
+  max-height: 64vh;
+}
+.student-dialog-hint {
+  display: block;
+  margin-bottom: 28px;
+  color: #999;
+  font-size: 24px;
+  line-height: 1.7;
+}
+.student-dialog-field { margin-bottom: 30px; }
+.student-dialog-label { display: block; margin-bottom: 14px; font-size: 24px; color: #999; line-height: 1.5; }
+.student-select-trigger {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin: 0;
+  padding: 22px 30px;
+  border: 1px solid #ddd;
+  border-radius: 20px;
+  background: #f9fafb;
+  color: #1a1a2e;
+  text-align: left;
+  font-size: 30px;
+  line-height: 1.5;
+}
+.student-select-arrow { flex-shrink: 0; font-size: 18px; color: #1a1a2e; }
+.student-select-placeholder { color: #999; }
+.student-dropdown {
+  padding: 8px;
+  margin-top: 8px;
+  border: 1px solid #e4deec;
+  border-radius: 16px;
+  background: #fff;
+}
+.student-class-option,
+.student-dialog .student-option {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  margin: 0;
+  padding: 18px 22px;
+  border: 0;
+  border-radius: 10px;
+  background: #fff;
+  color: #51455f;
+  text-align: left;
+  font-size: 28px;
+  line-height: 1.5;
+}
+.student-class-option.selected,
+.student-dialog .student-option.selected { background: #f4eeff; color: #7c3aed; }
+.student-selected-count { display: block; font-size: 24px; color: #888; margin-bottom: 18px; line-height: 1.5; }
+.student-selected-tags { display: flex; flex-wrap: wrap; gap: 12px; min-height: 64px; }
+.student-selected-tag {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  width: auto;
+  margin: 0;
+  padding: 14px 18px;
+  border: 0;
+  border-radius: 16px;
+  background: #f3eeff;
+  color: #7c3aed;
+  font-size: 23px;
+  line-height: 1.5;
+}
+.student-tag-remove { font-size: 27px; color: #888; line-height: 1; }
+.student-dialog-actions { display: flex; gap: 22px; margin-top: 48px; }
+.student-cancel-button,
+.student-confirm-button {
+  flex: 1;
+  min-width: 0;
+  margin: 0;
+  padding: 24px 10px;
+  border: 0;
+  border-radius: 22px;
+  font-size: 30px;
+  line-height: 1.4;
+  white-space: nowrap;
+}
+.student-cancel-button { background: #f5f5f5; color: #666; }
+.student-confirm-button { background: #7c3aed; color: #fff; font-weight: 600; }
+.student-dialog .student-confirm-button { color: #fff; }
+.student-confirm-button.is-disabled { background: #c6abea; color: #fff; }
+.student-select-trigger::after,
+.student-class-option::after,
+.student-selected-tag::after,
+.student-cancel-button::after,
+.student-confirm-button::after { border: 0; }
+
+@media (max-width: 360px) {
+  .student-confirm-button { font-size: 26px; }
+}

+ 77 - 0
src/components/StudentPicker.tsx

@@ -0,0 +1,77 @@
+import { useState } from 'react'
+import { Button, ScrollView, Text, View } from '@tarojs/components'
+import { classes, students, type Student } from '../data/workbench'
+import './StudentPicker.scss'
+
+export default function StudentPicker({
+  selected,
+  onConfirm,
+  onClose
+}: {
+  selected: Student[]
+  onConfirm: (students: Student[]) => void
+  onClose: () => void
+}) {
+  const [className, setClassName] = useState(selected[0]?.className || '')
+  const [draft, setDraft] = useState(selected)
+  const [open, setOpen] = useState<'class' | 'student' | null>(null)
+  const classStudents = students.filter((student) => student.className === className)
+  const selectedNames = draft.filter((student) => student.className === className).map((student) => student.name)
+  const toggle = (student: Student) =>
+    setDraft((current) =>
+      current.some((s) => s.id === student.id) ? current.filter((s) => s.id !== student.id) : [...current, student]
+    )
+  return (
+    <View className="sheet-layer student-picker-layer">
+      <View className="sheet-backdrop" onClick={onClose} />
+      <View className="student-dialog">
+        <Text className="student-dialog-title">👥 关联学生</Text>
+        <ScrollView scrollY className="student-dialog-body">
+          <Text className="student-dialog-hint">可选择不同班级的学生,切换班级会保留已选名单。</Text>
+          <View className="student-dialog-field">
+            <Text className="student-dialog-label">选择班级</Text>
+            <Button className="student-select-trigger student-class-trigger" onClick={() => setOpen(open === 'class' ? null : 'class')} aria-expanded={open === 'class'}>
+              <Text className={className ? '' : 'student-select-placeholder'}>{className || '请选择班级'}</Text><Text className="student-select-arrow">▾</Text>
+            </Button>
+            {open === 'class' && (
+              <View className="student-dropdown">
+                {classes.map((name) => (
+                  <Button key={name} className={`student-class-option ${name === className ? 'selected' : ''}`} onClick={() => { setClassName(name); setOpen(null) }}>
+                    <Text>{name}</Text><Text>{name === className ? '✓' : ''}</Text>
+                  </Button>
+                ))}
+              </View>
+            )}
+          </View>
+          <View className="student-dialog-field">
+            <Text className="student-dialog-label">选择学生(可多选)</Text>
+            <Button className="student-select-trigger student-names-trigger" onClick={() => setOpen(className ? (open === 'student' ? null : 'student') : 'class')} aria-expanded={open === 'student'}>
+              <Text className={selectedNames.length ? '' : 'student-select-placeholder'}>{selectedNames.join('、') || '请选择学生'}</Text><Text className="student-select-arrow">▾</Text>
+            </Button>
+            {open === 'student' && (
+              <View className="student-dropdown">
+                {classStudents.map((student) => (
+                  <Button key={student.id} className={`student-option ${draft.some((s) => s.id === student.id) ? 'selected' : ''}`} onClick={() => toggle(student)}>
+                    <Text>{student.name}</Text><Text>{draft.some((s) => s.id === student.id) ? '✓' : '○'}</Text>
+                  </Button>
+                ))}
+              </View>
+            )}
+          </View>
+          <Text className="student-selected-count">已选 {draft.length} 名学生</Text>
+          <View className="student-selected-tags">
+            {draft.map((student) => (
+              <Button key={student.id} className="student-selected-tag" aria-label={`移除${student.className} ${student.name}`} onClick={() => toggle(student)}>
+                <Text>{student.className} · {student.name}</Text><Text className="student-tag-remove">×</Text>
+              </Button>
+            ))}
+          </View>
+        </ScrollView>
+        <View className="student-dialog-actions">
+          <Button className="student-cancel-button" onClick={onClose}>取消</Button>
+          <Button className={`student-confirm-button${draft.length ? '' : ' is-disabled'}`} disabled={!draft.length} aria-disabled={!draft.length} onClick={() => { if (draft.length) onConfirm(draft) }}>确认关联({draft.length}人)</Button>
+        </View>
+      </View>
+    </View>
+  )
+}

+ 222 - 0
src/components/WorkbenchUI.tsx

@@ -0,0 +1,222 @@
+import { useRef, type PropsWithChildren } from 'react'
+import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components'
+import Taro from '@tarojs/taro'
+import type { ITouchEvent } from '@tarojs/components/types/common'
+import SafeTop from './SafeTop'
+import type { Question } from '../data/workbench'
+import './workbench.scss'
+
+export function Page({
+  title,
+  children,
+  onBack,
+  action,
+  className = ''
+}: PropsWithChildren<{ title: string; onBack?: () => void; action?: React.ReactNode; className?: string }>) {
+  return (
+    <View className={`workflow-page ${className}`}>
+      <SafeTop />
+      <View className="workflow-nav">
+        <Button className="back-button" aria-label="返回" onClick={onBack ?? (() => void Taro.navigateBack())}>
+          ←
+        </Button>
+        <Text>{title}</Text>
+        <View className="nav-action">{action}</View>
+      </View>
+      {children}
+    </View>
+  )
+}
+export function Field({
+  label,
+  value,
+  onChange,
+  placeholder,
+  multiline = false
+}: {
+  label: string
+  value: string
+  onChange: (value: string) => void
+  placeholder?: string
+  multiline?: boolean
+}) {
+  return (
+    <View className="form-field">
+      <Text className="field-label">{label}</Text>
+      {multiline ? (
+        <Textarea
+          className="field-input field-textarea"
+          aria-label={label}
+          value={value}
+          maxlength={6000}
+          autoHeight
+          onInput={(e) => onChange(e.detail.value)}
+        />
+      ) : (
+        <Input
+          className="field-input"
+          aria-label={label}
+          value={value}
+          placeholder={placeholder}
+          maxlength={100}
+          onInput={(e) => onChange(e.detail.value)}
+        />
+      )}
+    </View>
+  )
+}
+export function Select({
+  label,
+  value,
+  choices,
+  onChange
+}: {
+  label: string
+  value: string
+  choices: string[]
+  onChange: (value: string) => void
+}) {
+  return (
+    <Picker
+      mode="selector"
+      range={choices}
+      value={Math.max(0, choices.indexOf(value))}
+      onChange={(e) => onChange(choices[Number(e.detail.value)])}
+    >
+      <View className="select-field">
+        <Text>{label}</Text>
+        <Text className={value ? '' : 'muted-copy'}>{value || `请选择${label}`} ⌄</Text>
+      </View>
+    </Picker>
+  )
+}
+export function Chips({
+  values,
+  selected,
+  onChange
+}: {
+  values: string[]
+  selected: string
+  onChange: (value: string) => void
+}) {
+  return (
+    <View className="chip-row">
+      {values.map((value) => (
+        <Button className={`chip ${selected === value ? 'selected' : ''}`} key={value} onClick={() => onChange(value)}>
+          {value}
+        </Button>
+      ))}
+    </View>
+  )
+}
+export function Empty({ children }: PropsWithChildren) {
+  return (
+    <View className="empty-state">
+      <Text className="empty-symbol">◇</Text>
+      <Text>{children}</Text>
+    </View>
+  )
+}
+export function Actions({ children }: PropsWithChildren) {
+  return <View className="workflow-actions">{children}</View>
+}
+export function QuestionCard({
+  question: q,
+  onChange,
+  wrong = false
+}: {
+  question: Question
+  onChange?: (q: Question) => void
+  wrong?: boolean
+}) {
+  return (
+    <View className="question-card">
+      <View className="row-between">
+        <Text className="tag">{q.type}</Text>
+        <Text className="muted-copy">
+          第 {q.num} 题 · 第 {q.page} 页
+        </Text>
+      </View>
+      {wrong && onChange && (
+        <Button
+          className={`wrong-toggle ${q.isWrong ? 'marked' : ''}`}
+          aria-label="标记错题"
+          onClick={() => onChange({ ...q, isWrong: !q.isWrong })}
+        >
+          <Text>标记错题</Text>
+          <Text>{q.isWrong ? '● 已标记' : '○ 未标记'}</Text>
+        </Button>
+      )}
+      {onChange ? (
+        <>
+          <Field label="题目" value={q.content} multiline onChange={(content) => onChange({ ...q, content })} />
+          {q.options.map((option, i) => (
+            <Field
+              key={i}
+              label={`选项 ${String.fromCharCode(65 + i)}`}
+              value={option}
+              onChange={(value) => onChange({ ...q, options: q.options.map((v, n) => (n === i ? value : v)) })}
+            />
+          ))}
+        </>
+      ) : (
+        <>
+          <Text className="question-content">{q.content}</Text>
+          {q.options.map((option, i) => (
+            <View className="question-option" key={i}>
+              <Text>{String.fromCharCode(65 + i)}.</Text>
+              <Text>{option}</Text>
+            </View>
+          ))}
+        </>
+      )}
+      <View className="chip-row">
+        {q.tags.map((tag) => (
+          <Text className="tag" key={tag}>
+            {tag}
+          </Text>
+        ))}
+        <Text className="tag difficulty">{q.diff}</Text>
+        {q.isWrong && <Text className="tag wrong">错题</Text>}
+      </View>
+      {onChange ? (
+        <>
+          <Field label="参考答案" value={q.answer} multiline onChange={(answer) => onChange({ ...q, answer })} />
+          <Field label="解析" value={q.analysis} multiline onChange={(analysis) => onChange({ ...q, analysis })} />
+        </>
+      ) : (
+        <>
+          <Text className="field-label">参考答案</Text>
+          <Text className="answer-content">{q.answer}</Text>
+          <Text className="field-label">解析</Text>
+          <Text className="analysis-content">{q.analysis}</Text>
+        </>
+      )}
+    </View>
+  )
+}
+export function Swipe({ children, onSwipe }: PropsWithChildren<{ onSwipe: (direction: number) => void }>) {
+  const start = useRef<{ x: number; y: number } | null>(null)
+  return (
+    <View
+      onTouchStart={(e) => {
+        const t = (e as ITouchEvent).touches[0]
+        if (t) start.current = { x: t.clientX, y: t.clientY }
+      }}
+      onTouchEnd={(e) => {
+        const t = (e as ITouchEvent).changedTouches[0],
+          s = start.current
+        start.current = null
+        if (s && t) {
+          const dx = t.clientX - s.x
+          if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(t.clientY - s.y) * 1.3) onSwipe(dx < 0 ? 1 : -1)
+        }
+      }}
+      onTouchCancel={() => {
+        start.current = null
+      }}
+    >
+      {children}
+    </View>
+  )
+}

+ 522 - 0
src/components/workbench.scss

@@ -0,0 +1,522 @@
+.workflow-page {
+  min-height: 100vh;
+  padding-bottom: calc(40px + env(safe-area-inset-bottom));
+  background: #f7f6fa;
+  font-size: 28px;
+}
+.workflow-page button::after,
+.workflow-page taro-button-core::after,
+.chip::after,
+.student-option::after {
+  border: 0;
+  pointer-events: none;
+}
+.workflow-nav {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 112px;
+  padding: 16px 28px;
+  border-bottom: 1px solid #eee9f4;
+  background: white;
+  font-size: 34px;
+  font-weight: 600;
+}
+.back-button {
+  flex-shrink: 0;
+  width: 72px;
+  height: 72px;
+  margin: 0;
+  padding: 0;
+  line-height: 72px;
+  border-radius: 50%;
+  font-size: 38px;
+  background: #f6f3fa;
+  color: #544466;
+}
+.nav-action {
+  min-width: 72px;
+  max-width: 175px;
+}
+.text-button {
+  margin: 0;
+  padding: 8px 0;
+  border: 0;
+  box-shadow: none;
+  background: transparent;
+  color: #7c3aed;
+  font-size: 25px;
+  line-height: 1.5;
+}
+.text-button::after {
+  border: 0;
+  box-shadow: none;
+}
+.workflow-body {
+  padding: 28px 30px;
+}
+.panel-card,
+.question-card {
+  margin-bottom: 24px;
+  padding: 28px;
+  border-radius: 24px;
+  background: #fff;
+  border: 1px solid #eee9f4;
+  overflow-wrap: anywhere;
+}
+.panel-title {
+  display: block;
+  margin-bottom: 16px;
+  font-size: 31px;
+  font-weight: 600;
+  line-height: 1.5;
+}
+.panel-description {
+  display: block;
+  color: #968b9f;
+  font-size: 25px;
+  line-height: 1.8;
+}
+.row-between {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 16px;
+}
+.workflow-actions {
+  display: flex;
+  align-items: stretch;
+  gap: 18px;
+  margin: 28px 0;
+}
+.workflow-actions > button,
+.workflow-actions > taro-button-core {
+  flex: 1;
+  min-width: 0;
+  margin: 0;
+}
+.secondary-button {
+  padding: 23px 16px;
+  border: 1px solid #dcd0ed;
+  border-radius: 20px;
+  background: #fff;
+  color: #7c3aed;
+  font-size: 27px;
+  line-height: 1.5;
+}
+.primary-button[disabled] {
+  background: #d6c4ee;
+  color: #fff;
+}
+.field-label {
+  display: block;
+  font-size: 25px;
+  font-weight: 600;
+  color: #655274;
+  margin: 22px 0 12px;
+}
+.field-input {
+  display: block;
+  width: 100%;
+  height: 84px;
+  padding: 18px 20px;
+  border: 1px solid #ddd4e9;
+  border-radius: 14px;
+  background: #fcfbfe;
+  color: #362643;
+  font-size: 27px;
+  line-height: 1.7;
+}
+.field-textarea {
+  min-height: 110px;
+  height: auto;
+}
+.select-field {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 24px 0;
+  border-bottom: 1px solid #f2edf7;
+  font-size: 26px;
+}
+.chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin: 20px 0;
+}
+.chip {
+  width: auto;
+  min-width: 0;
+  flex-shrink: 0;
+  margin: 0;
+  padding: 10px 20px;
+  border-radius: 28px;
+  font-size: 24px;
+  line-height: 1.5;
+  background: #eeeaf3;
+  color: #8a7c96;
+}
+.chip.selected,
+.student-option.selected {
+  background: #f0e7ff;
+  color: #7c3aed;
+  border-color: #bca0e8;
+}
+.student-options {
+  margin: 24px 0;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+.student-option {
+  margin: 0;
+  padding: 22px 24px;
+  display: flex;
+  justify-content: space-between;
+  border: 1px solid #e9e1f2;
+  border-radius: 16px;
+  font-size: 28px;
+  background: #faf8fd;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  gap: 22px;
+  align-items: center;
+  padding: 90px 20px;
+  text-align: center;
+  color: #aa9db5;
+  font-size: 27px;
+}
+.empty-symbol {
+  font-size: 70px;
+  color: #cbb8e6;
+}
+.question-content {
+  display: block;
+  white-space: pre-wrap;
+  margin: 26px 0;
+  line-height: 1.85;
+  font-size: 28px;
+}
+.question-option {
+  display: flex;
+  gap: 18px;
+  padding: 17px 20px;
+  margin-bottom: 12px;
+  background: #faf8fd;
+  border-radius: 12px;
+  line-height: 1.6;
+  font-size: 26px;
+}
+.tag {
+  padding: 5px 16px;
+  border-radius: 22px;
+  background: #f0ebf7;
+  color: #8d70aa;
+  font-size: 22px;
+}
+.tag.difficulty {
+  background: #fff3dd;
+  color: #c78b35;
+}
+.tag.wrong {
+  background: #ffeded;
+  color: #d55353;
+}
+.wrong-toggle {
+  display: flex;
+  justify-content: space-between;
+  margin: 24px 0;
+  padding: 18px;
+  font-size: 25px;
+  line-height: 1.6;
+  color: #a192a7;
+  border-radius: 16px;
+  background: #faf7fc;
+  border: 1px dashed #ded4e7;
+}
+.wrong-toggle.marked {
+  background: #fff0ef;
+  color: #d75555;
+  border-color: #f5b4ae;
+}
+.answer-content {
+  display: block;
+  padding: 20px;
+  background: #eefbf3;
+  border-radius: 12px;
+  color: #279d6e;
+  font-size: 27px;
+  line-height: 1.7;
+  white-space: pre-wrap;
+}
+.analysis-content {
+  display: block;
+  line-height: 1.9;
+  font-size: 25px;
+  color: #92849c;
+  white-space: pre-wrap;
+}
+.number-row {
+  display: flex;
+  gap: 14px;
+  width: 100%;
+  padding: 20px 0;
+  overflow-x: auto;
+}
+.number-chip {
+  flex-shrink: 0;
+  width: 66px;
+  height: 66px;
+  padding: 0;
+  margin: 0;
+  line-height: 66px;
+  border-radius: 50%;
+  background: #fff;
+  color: #9984ab;
+  border: 1px solid #e4d7f2;
+  font-size: 28px;
+}
+.number-chip.selected {
+  background: #7c3aed;
+  color: white;
+  border-color: #7c3aed;
+}
+.original-image {
+  width: 100%;
+  display: block;
+  border-radius: 16px;
+  background: #eeebf3;
+}
+.document-preview {
+  min-height: 240px;
+  padding: 36px 20px;
+  text-align: center;
+  overflow-wrap: anywhere;
+  background: #f0eaf7;
+}
+.review-original {
+  position: relative;
+  height: 350px;
+  overflow: hidden;
+  border-radius: 20px;
+  background: #eeebf3;
+}
+.review-original .original-image {
+  height: 350px;
+  opacity: 0.45;
+}
+.split-boxes {
+  position: absolute;
+  inset: 25px 30px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+.split-box {
+  flex: 1;
+  width: 100%;
+  padding: 10px 20px;
+  margin: 0;
+  border: 2px solid #c4b3d9;
+  border-radius: 10px;
+  color: #785893;
+  background: rgba(255, 255, 255, 0.6);
+  font-size: 24px;
+  line-height: 1.4;
+  text-align: left;
+}
+.split-box.selected {
+  background: #7c3aed22;
+  border-color: #7c3aed;
+  color: #7c3aed;
+}
+.photo-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 18px;
+  margin: 24px 0;
+}
+.photo-card {
+  padding: 14px;
+  border: 1px solid #e5dcec;
+  background: white;
+  border-radius: 18px;
+  min-width: 0;
+}
+.photo-thumb {
+  display: block;
+  width: 100%;
+  height: 220px;
+  border-radius: 12px;
+  background: #f0edf5;
+}
+.photo-name {
+  display: block;
+  color: #94829f;
+  font-size: 22px;
+  margin-top: 10px;
+}
+.failure-copy {
+  display: block;
+  padding: 18px;
+  margin: 14px 0;
+  background: #fff0ef;
+  color: #d25454;
+  border-radius: 12px;
+  font-size: 25px;
+  line-height: 1.7;
+}
+.result-mark {
+  text-align: center;
+  padding-top: 48px;
+  font-size: 92px;
+}
+.result-heading {
+  text-align: center;
+  margin: 24px 0;
+  font-size: 39px;
+  font-weight: 700;
+}
+.result-caption {
+  display: block;
+  text-align: center;
+  line-height: 1.9;
+  color: #8c7f98;
+  font-size: 27px;
+  margin-bottom: 36px;
+}
+.capture-zone {
+  padding: 64px 30px;
+  border: 2px dashed #d3c6e4;
+  border-radius: 24px;
+  text-align: center;
+  background: #faf7ff;
+}
+.capture-symbol {
+  display: block;
+  font-size: 78px;
+  margin-bottom: 18px;
+}
+.rules-row {
+  display: flex;
+  gap: 12px;
+  margin: 24px 0;
+}
+.rules-row > view,
+.rules-row > taro-view-core {
+  flex: 1;
+  font-size: 23px;
+  line-height: 1.8;
+  padding: 20px 10px;
+  background: #f2edf9;
+  border-radius: 16px;
+  text-align: center;
+}
+.guide-paper {
+  background: white;
+  padding: 32px;
+  border: 1px solid #ded7e5;
+  line-height: 2;
+  font-size: 24px;
+}
+.guide-paper .panel-title {
+  text-align: center;
+}
+.notice-row {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  text-align: left;
+  background: #fff;
+  border: 1px solid #ebe4f3;
+  padding: 28px;
+  margin: 0 0 20px;
+  border-radius: 20px;
+  line-height: 1.7;
+  font-size: 28px;
+}
+.notice-row.unread {
+  border-color: #cdb3f2;
+  background: #fbf8ff;
+}
+.unread-dot {
+  color: #ef5350;
+  font-size: 24px;
+}
+.notice-meta {
+  font-size: 23px;
+  color: #9e8faa;
+}
+.notice-summary {
+  font-size: 25px;
+  color: #8b7b98;
+}
+.explanation-stage {
+  padding: 42px 28px;
+  min-height: 340px;
+  background: #271a3b;
+  border-radius: 20px;
+  color: #fff;
+  text-align: center;
+  line-height: 2;
+}
+.explanation-stage .panel-description {
+  color: #ccbbe0;
+  white-space: pre-wrap;
+}
+.explanation-step {
+  display: block;
+  font-size: 39px;
+  margin: 26px 0;
+}
+.progress-track {
+  height: 10px;
+  border-radius: 8px;
+  background: #e5dbf0;
+  overflow: hidden;
+  margin: 24px 0;
+}
+.progress-fill {
+  height: 100%;
+  background: #9361dc;
+}
+.demo-banner {
+  padding: 14px 24px;
+  background: #f1eafa;
+  color: #9a80b2;
+  font-size: 22px;
+  line-height: 1.7;
+}
+.explanation-video {
+  width: 100%;
+  height: 430px;
+  border-radius: 20px;
+  overflow: hidden;
+  background: #271a3b;
+}
+.busy-layer {
+  position: fixed;
+  inset: 0;
+  z-index: 10000;
+  background: #24152e66;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.busy-box {
+  width: 75%;
+  padding: 42px;
+  text-align: center;
+  background: white;
+  border-radius: 28px;
+  line-height: 2;
+}
+.secondary-button::after { border: 0; pointer-events: none; }

+ 116 - 0
src/data/local-media.ts

@@ -0,0 +1,116 @@
+import Taro from '@tarojs/taro'
+import paperImage from '../assets/sample-paper.png'
+import answerImage from '../assets/sample-answer.png'
+import type { Photo, UploadKind } from './workbench'
+
+let sequence = 0
+const savedFiles = new Map<string, string>()
+export function newId(prefix: string) {
+  return `${prefix}-${Date.now()}-${++sequence}`
+}
+export function photoSource(photo: Photo, kind: UploadKind = 'papers') {
+  return photo.document
+    ? `document:${encodeURIComponent(photo.name)}`
+    : photo.demo
+    ? kind === 'answers'
+      ? answerImage
+      : paperImage
+    : photo.path
+}
+export function demoPhoto(): Photo {
+  return { id: newId('photo'), name: '示例照片', path: '', demo: true }
+}
+export async function pickDocuments(): Promise<Photo[]> {
+  try {
+    if (process.env.TARO_ENV === 'h5') {
+      // Isolated browser adapter for WeChat's chooseMessageFile API.
+      return await new Promise((resolve, reject) => {
+        const input = document.createElement('input')
+        input.type = 'file'
+        input.accept = '.doc,.docx,.pdf'
+        input.multiple = true
+        input.style.display = 'none'
+        document.body.appendChild(input)
+        input.oncancel = () => {
+          input.remove()
+          resolve([])
+        }
+        input.onchange = async () => {
+          try {
+            const result: Photo[] = []
+            for (const file of Array.from(input.files || [])) {
+              if (!/\.(docx?|pdf)$/i.test(file.name)) throw new Error('请选择 Word 或 PDF 文件')
+              if (file.size > 10 * 1024 * 1024) throw new Error('单个文件不能超过 10MB')
+              const path = await new Promise<string>((resolve, reject) => {
+                const reader = new FileReader()
+                reader.onload = () => resolve(String(reader.result))
+                reader.onerror = () => reject(new Error('文件读取失败'))
+                reader.readAsDataURL(file)
+              })
+              result.push({ id: newId('document'), name: file.name, path, document: true })
+            }
+            resolve(result)
+          } catch (error) {
+            reject(error)
+          } finally {
+            input.remove()
+          }
+        }
+        input.click()
+      })
+    }
+    const result = await Taro.chooseMessageFile({ count: 9, type: 'file', extension: ['doc', 'docx', 'pdf'] })
+    if (result.tempFiles.some((file) => file.size > 10 * 1024 * 1024)) throw new Error('单个文件不能超过 10MB')
+    return result.tempFiles.map((file) => ({ id: newId('document'), name: file.name, path: file.path, document: true }))
+  } catch (error) {
+    if (/cancel/i.test(String((error as { errMsg?: string }).errMsg))) return []
+    throw error
+  }
+}
+export async function pickPhotos(camera: boolean): Promise<Photo[]> {
+  try {
+    const result = await Taro.chooseImage({
+      count: camera ? 1 : 9,
+      sizeType: ['compressed'],
+      sourceType: [camera ? 'camera' : 'album']
+    })
+    const files = result.tempFiles as { path: string; size: number; originalFileObj?: { name: string } }[]
+    if (files.some((file) => file.size > 10 * 1024 * 1024)) throw new Error('单张照片不能超过 10MB,请压缩后重选')
+    return result.tempFilePaths.map((filePath, i) => ({ id: newId('photo'), name: files[i]?.originalFileObj?.name || `照片 ${i + 1}`, path: filePath }))
+  } catch (error) {
+    if (/cancel/i.test(String((error as { errMsg?: string }).errMsg))) return []
+    throw error
+  }
+}
+export async function preservePhotos(photos: Photo[]): Promise<Photo[]> {
+  const result: Photo[] = []
+  for (const photo of photos) {
+    if (photo.demo || photo.path.startsWith('data:')) {
+      result.push({ ...photo })
+      continue
+    }
+    if (process.env.TARO_ENV === 'h5') {
+      // Browser-only file adapter; no business request or upload is made.
+      const blob = await (await fetch(photo.path)).blob()
+      const path = await new Promise<string>((resolve, reject) => {
+        const reader = new FileReader()
+        reader.onload = () => resolve(String(reader.result))
+        reader.onerror = () => reject(new Error('照片读取失败,请重新选择'))
+        reader.readAsDataURL(blob)
+      })
+      result.push({ ...photo, path })
+    } else {
+      let path = savedFiles.get(photo.path)
+      if (!path) {
+        const saved = await Taro.saveFile({ tempFilePath: photo.path })
+        if (!('savedFilePath' in saved)) throw new Error('照片保存失败,请重新选择')
+        path = saved.savedFilePath
+        // saveFile moves the temporary file. Retain its saved path even if a
+        // subsequent file or storage write fails so the same draft can retry.
+        savedFiles.set(photo.path, path)
+      }
+      result.push({ ...photo, path })
+    }
+  }
+  return result
+}

+ 58 - 0
src/data/workbench-store.tsx

@@ -0,0 +1,58 @@
+import { createContext, useCallback, useContext, useEffect, useRef, useState, type PropsWithChildren } from 'react'
+import Taro from '@tarojs/taro'
+import { advanceMockResults, createInitialState, type WorkbenchState } from './workbench'
+
+const storageKey = 'zhiliao-miniapp-workbench-v1'
+function readState(): WorkbenchState {
+  try {
+    const saved = Taro.getStorageSync(storageKey) as WorkbenchState | undefined
+    if (
+      saved?.version === 1 &&
+      Array.isArray(saved.records) &&
+      Array.isArray(saved.notices) &&
+      Array.isArray(saved.explanations) &&
+      Array.isArray(saved.students) &&
+      saved.teacher
+    )
+      return saved
+  } catch {
+    /* Storage may be disabled; allow the page to open and report save failures. */
+  }
+  return createInitialState()
+}
+type Store = { state: WorkbenchState; update: (change: (current: WorkbenchState) => WorkbenchState) => void }
+const Context = createContext<Store | null>(null)
+export function WorkbenchProvider({ children }: PropsWithChildren) {
+  const [state, setState] = useState(readState)
+  const current = useRef(state)
+  const update = useCallback((change: (previous: WorkbenchState) => WorkbenchState) => {
+    const next = change(current.current)
+    if (next === current.current) return
+    // Write before updating the UI: failed local submissions remain retryable.
+    Taro.setStorageSync(storageKey, next)
+    current.current = next
+    setState(next)
+  }, [])
+  useEffect(() => {
+    const timer = setInterval(() => {
+      try {
+        update((previous) => advanceMockResults(previous))
+      } catch {
+        /* Retry when storage is available again. */
+      }
+    }, 1000)
+    return () => clearInterval(timer)
+  }, [update])
+  return <Context.Provider value={{ state, update }}>{children}</Context.Provider>
+}
+export function useWorkbench() {
+  const store = useContext(Context)
+  if (!store) throw new Error('WorkbenchProvider is required')
+  return store
+}
+export function showError(error: unknown) {
+  void Taro.showToast({
+    title: error instanceof Error ? error.message : '本地保存失败,请检查存储空间后重试',
+    icon: 'none'
+  })
+}

+ 338 - 0
src/data/workbench.ts

@@ -0,0 +1,338 @@
+export type Student = { id: string; name: string; className: string }
+export type Question = {
+  id: string
+  page: number
+  num: number
+  type: string
+  content: string
+  options: string[]
+  grade: string
+  tags: string[]
+  diff: string
+  answer: string
+  analysis: string
+  isWrong: boolean
+}
+export type Photo = {
+  id: string
+  path: string
+  name: string
+  demo?: boolean
+  document?: boolean
+  failure?: string
+  uploaded?: boolean
+}
+export type UploadKind = 'mistakes' | 'papers' | 'answers'
+export type RecordItem = {
+  id: string
+  kind: UploadKind
+  name: string
+  year: string
+  paperType: string
+  province: string
+  photos: Photo[]
+  questions: Question[]
+  students: Student[]
+  submittedAt: string
+  reviewStatus?: 'pending' | 'approved'
+  approvedAt?: string
+}
+export type Explanation = {
+  id: string
+  title: string
+  question: Question
+  student: Student
+  requestedAt: string
+  estimatedAt: string
+  receivedAt?: string
+  status: 'waiting' | 'ready'
+  unread: boolean
+  progress: number
+}
+export type Notice = {
+  id: string
+  kind: UploadKind | 'videos'
+  title: string
+  targetId: string
+  createdAt: string
+  unread: boolean
+}
+export type WorkbenchState = {
+  version: 1
+  records: RecordItem[]
+  explanations: Explanation[]
+  notices: Notice[]
+  students: Student[]
+  teacher: { name: string; phone: string; email: string }
+}
+
+export const classes = ['初一', '初二', '初三']
+export const students: Student[] = classes.flatMap((className, index) =>
+  ['李一', '李二', '李三'].map((name, n) => ({ id: `${index}-${n}`, name, className }))
+)
+export const topics = [
+  '二次函数',
+  '一次函数',
+  '几何',
+  '圆',
+  '全等三角形',
+  '相似三角形',
+  '概率',
+  '统计',
+  '方程',
+  '不等式'
+]
+export const difficulties = ['基础', '中等', '困难']
+export const provinces = [
+  '北京市',
+  '天津市',
+  '河北省',
+  '山西省',
+  '内蒙古自治区',
+  '辽宁省',
+  '吉林省',
+  '黑龙江省',
+  '上海市',
+  '江苏省',
+  '浙江省',
+  '安徽省',
+  '福建省',
+  '江西省',
+  '山东省',
+  '河南省',
+  '湖北省',
+  '湖南省',
+  '广东省',
+  '广西壮族自治区',
+  '海南省',
+  '重庆市',
+  '四川省',
+  '贵州省',
+  '云南省',
+  '西藏自治区',
+  '陕西省',
+  '甘肃省',
+  '青海省',
+  '宁夏回族自治区',
+  '新疆维吾尔自治区',
+  '香港特别行政区',
+  '澳门特别行政区',
+  '台湾省'
+]
+export const paperTypes = ['中考', '高考', '期中', '期末', '模拟', '月考', '其他']
+export const examples: Omit<Question, 'id' | 'page' | 'num'>[] = [
+  {
+    type: '选择题',
+    content: '将二次函数 y = −(x − 2)² + 3 的图像平移后得到 y = −(x + 1)² − 2,平移方式是( )',
+    options: [
+      '向右平移3个单位,向上平移5个单位',
+      '向左平移3个单位,向上平移5个单位',
+      '向左平移3个单位,向下平移5个单位',
+      '向右平移3个单位,向下平移5个单位'
+    ],
+    grade: '初二',
+    tags: ['二次函数', '函数平移'],
+    diff: '基础',
+    answer: 'C',
+    analysis: '顶点由 (2, 3) 移至 (−1, −2),所以向左平移3个单位,向下平移5个单位。',
+    isWrong: false
+  },
+  {
+    type: '填空题',
+    content: '抛物线 y = x² − 4x + 3 与 x 轴的交点坐标为______。',
+    options: [],
+    grade: '初二',
+    tags: ['二次函数', '交点式'],
+    diff: '基础',
+    answer: '(1, 0) 和 (3, 0)',
+    analysis: '令 y = 0,得 (x − 1)(x − 3) = 0,所以 x = 1 或 x = 3。',
+    isWrong: false
+  },
+  {
+    type: '解答题',
+    content: '已知抛物线的顶点为 (1, 4),且过点 (0, 3),求解析式及递减区间。',
+    options: [],
+    grade: '初二',
+    tags: ['二次函数', '顶点式'],
+    diff: '中等',
+    answer: 'y = −(x − 1)² + 4;当 x > 1 时递减。',
+    analysis: '设 y = a(x − 1)² + 4。\n代入 (0, 3),得 a = −1。\n抛物线开口向下,对称轴为 x = 1,所以 x > 1 时递减。',
+    isWrong: false
+  },
+  {
+    type: '选择题',
+    content: '方程 x² − 5x + 6 = 0 的两个根是( )',
+    options: ['x₁ = 2,x₂ = 3', 'x₁ = −2,x₂ = −3', 'x₁ = 1,x₂ = 6', 'x₁ = −1,x₂ = −6'],
+    grade: '初三',
+    tags: ['方程', '因式分解'],
+    diff: '基础',
+    answer: 'A',
+    analysis: '(x − 2)(x − 3) = 0,所以 x = 2 或 x = 3。',
+    isWrong: false
+  }
+]
+export function recognizePhotos(photos: Photo[], previous: Question[] = []): Question[] {
+  const pages = new Set(previous.map((q) => q.id.split(':')[0]))
+  const retained = previous
+    .filter((q) => photos.some((p) => q.id.startsWith(`${p.id}:`)))
+    .map((q) => ({ ...q, page: photos.findIndex((p) => q.id.startsWith(`${p.id}:`)) + 1 }))
+  const added = photos.flatMap((photo, index) =>
+    pages.has(photo.id)
+      ? []
+      : Array.from({ length: 3 }, (_, n) => {
+          const template = examples[(index * 3 + n) % examples.length]
+          return {
+            ...template,
+            options: [...template.options],
+            tags: [...template.tags],
+            id: `${photo.id}:${n}`,
+            page: index + 1,
+            num: 0
+          }
+        })
+  )
+  return [...retained, ...added]
+    .sort((a, b) => a.page - b.page || a.id.localeCompare(b.id))
+    .map((q, index) => ({ ...q, num: index + 1 }))
+}
+export function createInitialState(): WorkbenchState {
+  const explanations: Explanation[] = [0, 1, 2, 3].map((n) => ({
+    id: `explanation-${n}`,
+    title: ['函数图像平移', '二次函数交点问题', '抛物线解析式', '一元二次方程求解'][n],
+    question: {
+      ...examples[n],
+      options: [...examples[n].options],
+      tags: [...examples[n].tags],
+      id: `example-${n}`,
+      page: 1,
+      num: 1
+    },
+    student: students[n],
+    requestedAt: '2026-10-26T09:00:00+08:00',
+    estimatedAt: new Date(Date.now() + 12000).toISOString(),
+    receivedAt: n < 3 ? '2026-10-26T09:10:00+08:00' : undefined,
+    status: n < 3 ? 'ready' : 'waiting',
+    unread: n < 3,
+    progress: 0
+  }))
+  const records: RecordItem[] = ['一', '二', '三'].map((n, i) => {
+    const photos = Array.from({ length: i + 2 }, (_, p) => ({
+      id: `sample${i}-${p}`,
+      path: '',
+      name: `第 ${p + 1} 页`,
+      demo: true
+    }))
+    return {
+      id: `sample-${i}`,
+      kind: 'papers',
+      name: `数学练习卷(${n})`,
+      year: '2026',
+      paperType: '模拟',
+      province: '浙江省',
+      photos,
+      questions: recognizePhotos(photos),
+      students: [],
+      submittedAt: '2026-10-23T10:00:00+08:00',
+      reviewStatus: 'approved',
+      approvedAt: '2026-10-23T10:20:00+08:00'
+    }
+  })
+  return {
+    version: 1,
+    records,
+    students: [],
+    teacher: { name: '张老师', phone: '', email: '' },
+    explanations,
+    notices: explanations
+      .filter((v) => v.status === 'ready')
+      .map((v) => ({
+        id: `video-${v.id}`,
+        kind: 'videos',
+        title: '视频讲解已生成',
+        targetId: v.id,
+        createdAt: v.receivedAt!,
+        unread: true
+      }))
+  }
+}
+export function addRecord(state: WorkbenchState, record: RecordItem): WorkbenchState {
+  if (state.records.some((r) => r.id === record.id)) return state
+  const title =
+    record.kind === 'papers' ? '试卷上传成功,待审核' : record.kind === 'mistakes' ? '题目上传成功' : '答题卡上传成功'
+  return {
+    ...state,
+    records: [record, ...state.records],
+    notices: [
+      {
+        id: `submitted-${record.id}`,
+        kind: record.kind,
+        title,
+        targetId: record.id,
+        createdAt: record.submittedAt,
+        unread: true
+      },
+      ...state.notices
+    ]
+  }
+}
+export function advanceMockResults(state: WorkbenchState, now = Date.now()): WorkbenchState {
+  const notices: Notice[] = []
+  const records = state.records.map((r) => {
+    if (r.reviewStatus !== 'pending' || now - Date.parse(r.submittedAt) < 20000) return r
+    notices.push({
+      id: `approved-${r.id}`,
+      kind: 'papers',
+      title: '试卷审核通过',
+      targetId: r.id,
+      createdAt: new Date(now).toISOString(),
+      unread: true
+    })
+    return { ...r, reviewStatus: 'approved' as const, approvedAt: new Date(now).toISOString() }
+  })
+  const explanations = state.explanations.map((v) => {
+    if (v.status !== 'waiting' || now < Date.parse(v.estimatedAt)) return v
+    notices.push({
+      id: `video-${v.id}`,
+      kind: 'videos',
+      title: '视频讲解已生成',
+      targetId: v.id,
+      createdAt: new Date(now).toISOString(),
+      unread: true
+    })
+    return { ...v, status: 'ready' as const, unread: true, receivedAt: new Date(now).toISOString() }
+  })
+  return notices.length ? { ...state, records, explanations, notices: [...notices, ...state.notices] } : state
+}
+export function markRead(state: WorkbenchState, targetId?: string): WorkbenchState {
+  return {
+    ...state,
+    notices: state.notices.map((n) => (!targetId || n.targetId === targetId ? { ...n, unread: false } : n)),
+    explanations: state.explanations.map((v) => (!targetId || v.id === targetId ? { ...v, unread: false } : v))
+  }
+}
+export function validateUpload(
+  kind: UploadKind,
+  photos: Photo[],
+  selected: Student[],
+  metadata: { name: string; year: string; paperType: string; province: string }
+) {
+  if (kind === 'mistakes' && !selected.length) return '请先关联学生'
+  if (kind === 'papers') {
+    if (!metadata.name.trim()) return '请输入试卷名称'
+    if (!metadata.year) return '请选择年份'
+    if (!metadata.paperType) return '请选择试卷类型'
+    if (!metadata.province) return '请选择省份'
+  }
+  if (!photos.length) return '请先添加照片'
+  return ''
+}
+export function formatTime(value?: string) {
+  if (!value) return '待同步'
+  const date = new Date(value)
+  if (!Number.isFinite(date.getTime())) return '待同步'
+  // Keep the same display in WeChat and browsers, independent of device timezone.
+  const china = new Date(date.getTime() + 8 * 3600000)
+  return `${china.getUTCFullYear()}/${String(china.getUTCMonth() + 1).padStart(2, '0')}/${String(
+    china.getUTCDate()
+  ).padStart(2, '0')} ${String(china.getUTCHours()).padStart(2, '0')}:${String(china.getUTCMinutes()).padStart(2, '0')}`
+}

+ 1 - 0
src/pages/explanations/index.config.ts

@@ -0,0 +1 @@
+export default definePageConfig({ navigationBarTitleText: '题目讲解' })

+ 4 - 0
src/pages/explanations/index.scss

@@ -0,0 +1,4 @@
+// Keep a page style entry so WeChat emits and compiles this page's WXSS.
+.explanations-page {
+  min-height: 100vh;
+}

+ 172 - 0
src/pages/explanations/index.tsx

@@ -0,0 +1,172 @@
+import { useEffect, useState } from 'react'
+import { Button, Text, Video, View } from '@tarojs/components'
+import Taro, { useRouter } from '@tarojs/taro'
+import { Chips, Empty, Page, QuestionCard } from '../../components/WorkbenchUI'
+import { formatTime, markRead, type Explanation } from '../../data/workbench'
+import { showError, useWorkbench } from '../../data/workbench-store'
+import video0 from '../../assets/explanation-0.mp4'
+import video1 from '../../assets/explanation-1.mp4'
+import video2 from '../../assets/explanation-2.mp4'
+import video3 from '../../assets/explanation-3.mp4'
+import './index.scss'
+
+const demoSources = [video0, video1, video2, video3]
+export default function Explanations() {
+  const { params } = useRouter()
+  const { state, update } = useWorkbench()
+  const [target, setTarget] = useState(params.id || '')
+  const [filter, setFilter] = useState('全部')
+  const [videoError, setVideoError] = useState(false)
+  const [playing, setPlaying] = useState(false)
+  useEffect(() => {
+    if (!params.id) return
+    try {
+      update((previous) => markRead(previous, params.id))
+    } catch (error) {
+      showError(error)
+    }
+  }, [params.id, update])
+  const explanation = state.explanations.find((v) => v.id === target && v.status === 'ready')
+  const safeUpdate: typeof update = (change) => {
+    try {
+      update(change)
+    } catch (error) {
+      showError(error)
+    }
+  }
+  const open = (v: Explanation) => {
+    safeUpdate((previous) => markRead(previous, v.id))
+    setTarget(v.id)
+    setPlaying(false)
+    setVideoError(false)
+  }
+  const saveProgress = (progress: number) => {
+    // Persist rounded progress only, to avoid writing storage on every video frame.
+    if (!explanation || Math.floor(progress) === Math.floor(explanation.progress)) return
+    safeUpdate((previous) => ({
+      ...previous,
+      explanations: previous.explanations.map((v) => (v.id === target ? { ...v, progress: Math.floor(progress) } : v))
+    }))
+  }
+  const metadata = (v: Explanation) => (
+    <View className="panel-card">
+      <Text className="panel-description">
+        关联学生:{v.student.className} · {v.student.name}
+      </Text>
+      <Text className="panel-description">请求时间:{formatTime(v.requestedAt)}</Text>
+      <Text className="panel-description">
+        {v.status === 'ready' ? '生成时间' : '预计生成'}:
+        {formatTime(v.status === 'ready' ? v.receivedAt : v.estimatedAt)}
+      </Text>
+    </View>
+  )
+  return (
+    <Page
+      className="explanations-page"
+      title={explanation ? '观看讲解' : '题目讲解'}
+      onBack={
+        explanation
+          ? () => {
+              setTarget('')
+              setPlaying(false)
+            }
+          : undefined
+      }
+      action={
+        !explanation ? (
+          <Button className="text-button" onClick={() => void Taro.navigateTo({ url: '/pages/messages/index?filter=videos' })}>
+            通知({state.explanations.filter(v => v.unread).length})
+          </Button>
+        ) : undefined
+      }
+    >
+      <View className="workflow-body">
+        {explanation ? (
+          <>
+            <Text className="panel-title">{explanation.title}</Text>
+            {metadata(explanation)}
+            <Video
+              id="explanation-video"
+              className="explanation-video"
+              src={demoSources[state.explanations.findIndex((v) => v.id === explanation.id) % demoSources.length]}
+              controls
+              initialTime={explanation.progress}
+              showCenterPlayBtn
+              onPlay={() => {
+                setPlaying(true)
+                safeUpdate((previous) => markRead(previous, target))
+              }}
+              onPause={() => setPlaying(false)}
+              onEnded={() => {
+                setPlaying(false)
+                saveProgress(0)
+              }}
+              onTimeUpdate={(e) => saveProgress(e.detail.currentTime)}
+              onError={() => {
+                setVideoError(true)
+                setPlaying(false)
+              }}
+            />
+            <Text className="panel-description">
+              本地讲解示范 ·{' '}
+              {playing
+                ? '正在播放'
+                : explanation.progress
+                ? `已观看 ${explanation.progress} 秒,可继续观看`
+                : '点击播放观看解题过程'}
+            </Text>
+            {videoError && (
+              <>
+                <Text className="failure-copy">视频加载失败,请重新打开后重试。</Text>
+                <Button
+                  className="secondary-button"
+                  onClick={() => {
+                    setVideoError(false)
+                    void Taro.createVideoContext('explanation-video').play()
+                  }}
+                >
+                  重试播放
+                </Button>
+              </>
+            )}
+            <QuestionCard question={explanation.question} />
+          </>
+        ) : (
+          <>
+            <Chips values={['全部', '待查看', '处理中']} selected={filter} onChange={setFilter} />
+            {state.explanations
+              .filter(
+                (v) =>
+                  filter === '全部' || (filter === '待查看' ? v.status === 'ready' && v.unread : v.status === 'waiting')
+              )
+              .map((v) => (
+                <View className="panel-card" key={v.id}>
+                  <View className="row-between">
+                    <Text className="panel-title">{v.status === 'ready' ? '▶ 讲解已就绪' : '◷ 讲解处理中'}</Text>
+                    {v.unread && <Text className="unread-dot">● 未读</Text>}
+                  </View>
+                  <Text className="panel-title">{v.title}</Text>
+                  <Text className="panel-description">{v.question.content}</Text>
+                  {metadata(v)}
+                  <View className="row-between">
+                    <Text className="muted-copy">学习平台</Text>
+                    {v.status === 'ready' ? (
+                      <Button className="text-button" onClick={() => open(v)}>
+                        {v.progress > 0 ? '继续观看' : '观看讲解'} →
+                      </Button>
+                    ) : (
+                      <Text className="muted-copy">完成后通知</Text>
+                    )}
+                  </View>
+                </View>
+              ))}
+            {!state.explanations.some(
+              (v) =>
+                filter === '全部' || (filter === '待查看' ? v.status === 'ready' && v.unread : v.status === 'waiting')
+            ) && <Empty>暂无相关讲解</Empty>}
+          </>
+        )}
+      </View>
+    </Page>
+  )
+}

+ 2 - 1
src/pages/home/index.scss

@@ -13,7 +13,8 @@
 .notification-button { position: relative; flex-shrink: 0; width: 60px; height: 64px; padding: 0; margin: 0 0 0 auto; display: flex; align-items: center; justify-content: center; background: transparent; overflow: visible; }
 .bell-icon { display: block; width: 44px; height: 44px; }
 .notification-dot { position: absolute; top: 7px; right: 4px; width: 13px; height: 13px; background: #ff6579; border: 1px solid #8341ee; border-radius: 50%; }
-.workbench-list { padding: 36px 32px 60px; display: flex; flex-direction: column; gap: 22px; }
+.workbench-list { padding: 36px 32px 28px; display: flex; flex-direction: column; gap: 22px; }
+.home-page { padding-bottom: 40px; }
 .workbench-card { width: 100%; min-height: 198px; padding: 36px; display: flex; align-items: center; gap: 28px; background: #fff; border: 1px solid #e8dcff; border-radius: 36px; box-shadow: 0 10px 36px rgba(107, 57, 184, .055); text-align: left; line-height: normal; }
 .workbench-card:active { background: #fcfaff; transform: translateY(1px); }
 .entry-icon { width: 108px; height: 108px; border-radius: 28px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 52px; background: linear-gradient(135deg, #8034f2, #a47aff); }

+ 15 - 36
src/pages/home/index.tsx

@@ -1,21 +1,22 @@
-import { useState } from 'react'
 import { Button, Image, Text, View } from '@tarojs/components'
 import Taro from '@tarojs/taro'
 import logo from '../../assets/logo.png'
 import notificationIcon from '../../assets/notification.png'
 import SafeTop from '../../components/SafeTop'
-import DemoSheet from '../../components/DemoSheet'
-import { demoVideos, workbenchEntries, type WorkbenchEntry } from '../../data/demo'
+import { workbenchEntries } from '../../data/demo'
+import { useWorkbench } from '../../data/workbench-store'
+import { useDemoSession } from '../../data/demo-session'
+import '../../components/workbench.scss'
 import './index.scss'
 
-type Panel = WorkbenchEntry['id'] | 'notifications' | null
-
 export default function Home() {
-  const [panel, setPanel] = useState<Panel>(null)
-  const [selected, setSelected] = useState(false)
-  const entry = workbenchEntries.find((item) => item.id === panel)
-  const close = () => { setPanel(null); setSelected(false) }
-  const title = panel === 'notifications' ? '消息通知' : entry?.title ?? ''
+  const { state } = useWorkbench()
+  const { signedIn } = useDemoSession()
+  const unread = state.explanations.filter(v => v.status === 'ready' && v.unread).length
+  const navigate = (url: string) => {
+    if (!signedIn) { void Taro.showToast({ title: '请先在“我的”进入演示账号', icon: 'none' }); return }
+    void Taro.navigateTo({ url })
+  }
 
   return (
     <View className="home-page">
@@ -23,45 +24,23 @@ export default function Home() {
       <View className="home-header purple-header">
         <View className="brand-logo"><Image src={logo} className="brand-image" mode="aspectFit" /></View>
         <View className="brand-copy"><Text className="brand-name">知了数学</Text><Text className="brand-caption">教师工作台</Text></View>
-        <Button className="notification-button" aria-label="消息通知" onClick={() => setPanel('notifications')}>
-          <Image src={notificationIcon} className="bell-icon" mode="aspectFit" /><View className="notification-dot" />
+        <Button className="notification-button" aria-label="消息通知" onClick={() => navigate('/pages/messages/index')}>
+          <Image src={notificationIcon} className="bell-icon" mode="aspectFit" />{state.notices.some(n => n.unread) && <View className="notification-dot" />}
         </Button>
       </View>
 
       <View className="workbench-list">
         {workbenchEntries.map((item) => (
-          <Button key={item.id} className="workbench-card" onClick={() => setPanel(item.id)}>
+          <Button key={item.id} className="workbench-card" onClick={() => navigate(item.id === 'videos' ? '/pages/explanations/index' : `/pages/upload/index?kind=${item.id}`)}>
             <View className="entry-icon">{item.icon === 'play' ? <View className="play-icon" /> : <Text>{item.icon}</Text>}</View>
             <View className="entry-copy">
-              <View className="entry-heading"><Text className="entry-title">{item.title}</Text>{'badge' in item && <Text className="entry-badge">{item.badge}</Text>}</View>
+              <View className="entry-heading"><Text className="entry-title">{item.title}</Text>{item.id === 'videos' && unread > 0 && <Text className="entry-badge">{unread}条视频待查看</Text>}</View>
               <Text className="entry-description">{item.description}</Text>
             </View>
             <Text className="entry-arrow">→</Text>
           </Button>
         ))}
       </View>
-
-      {panel && (
-        <DemoSheet title={title} onClose={close}>
-          {panel === 'notifications' ? (
-            <View className="notice-card"><Text className="notice-icon">🔔</Text><Text className="detail-title">有 3 条题目讲解待查看</Text><Text className="muted-copy">在首页「题目讲解」中查看演示列表。</Text><Text className="demo-note">当前为本地演示消息</Text></View>
-          ) : panel === 'videos' ? (
-            <View>
-              <Text className="demo-note">本地演示列表,暂不播放真实视频</Text>
-              {demoVideos.map((video) => <View className="video-row" key={video.title}><View className="video-thumb"><View className="play-icon" /></View><View className="video-copy"><Text className="detail-title">{video.title}</Text><Text className="muted-copy">{video.topic} · {video.duration}</Text></View><Text className="video-status">{video.status}</Text></View>)}
-            </View>
-          ) : (
-            <View>
-              <Text className="muted-copy">{entry?.description}</Text>
-              {panel === 'mistakes' && <View className="local-student"><Text>关联学生</Text><Text className="muted-copy">小明(演示) · 七年级</Text></View>}
-              <View className={`upload-preview${selected ? ' is-selected' : ''}`}>
-                {selected ? <><Text className="upload-symbol">📄</Text><Text className="detail-title">演示图片已选择</Text><Text className="muted-copy">仅用于查看页面效果,不读取或上传文件</Text></> : <><Text className="upload-symbol">+</Text><Text className="detail-title">{panel === 'answers' ? '支持连续选择多张答题卡' : '清晰拍摄,保留完整题目'}</Text><Text className="muted-copy">当前为前端展示模式</Text></>}
-              </View>
-              <Button className="primary-button" onClick={() => { if (selected) { close(); void Taro.showToast({ title: '演示完成,未上传文件', icon: 'none' }) } else setSelected(true) }}>{selected ? '完成演示' : '选择演示图片'}</Button>
-            </View>
-          )}
-        </DemoSheet>
-      )}
     </View>
   )
 }

+ 1 - 0
src/pages/library/index.config.ts

@@ -0,0 +1 @@
+export default definePageConfig({ navigationBarTitleText: '题库管理' })

+ 4 - 0
src/pages/library/index.scss

@@ -0,0 +1,4 @@
+// Keep a page style entry so WeChat emits and compiles this page's WXSS.
+.library-page {
+  min-height: 100vh;
+}

+ 224 - 0
src/pages/library/index.tsx

@@ -0,0 +1,224 @@
+import { useState } from 'react'
+import { Button, Input, Text, View } from '@tarojs/components'
+import Image from '../../components/PhotoImage'
+import Taro from '@tarojs/taro'
+import { Chips, Empty, Page, QuestionCard, Swipe } from '../../components/WorkbenchUI'
+import { formatTime } from '../../data/workbench'
+import { photoSource } from '../../data/local-media'
+import { useWorkbench } from '../../data/workbench-store'
+import './index.scss'
+
+export default function Library() {
+  const { state } = useWorkbench()
+  const [target, setTarget] = useState('')
+  const [search, setSearch] = useState('')
+  const [grade, setGrade] = useState('全部')
+  const [topic, setTopic] = useState('全部')
+  const [tab, setTab] = useState('题目与解析')
+  const [pageIndex, setPageIndex] = useState(0)
+  const [stats, setStats] = useState(false)
+  const record = state.records.find((r) => r.id === target)
+  const allQuestions = state.records.flatMap((r) => r.questions)
+  const filtered = state.records
+    .filter((r) => r.kind !== 'answers')
+    .map((r) => ({
+      ...r,
+      questions: r.questions.filter(
+        (q) =>
+          (grade === '全部' || q.grade === grade) &&
+          (topic === '全部' || q.tags.includes(topic)) &&
+          (!search.trim() ||
+            [r.name, q.content, ...q.tags, ...r.students.map((s) => `${s.className} ${s.name}`)]
+              .join(' ')
+              .includes(search.trim()))
+      )
+    }))
+    .filter((r) => r.questions.length)
+  const photo = record?.photos[pageIndex]
+  return (
+    <Page
+      className="library-page"
+      title={record ? '试卷详情' : stats ? '数据统计' : '题库管理'}
+      onBack={record ? () => setTarget('') : stats ? () => setStats(false) : undefined}
+      action={
+        !record && !stats ? (
+          <Button className="text-button" onClick={() => setStats(true)}>
+            数据统计
+          </Button>
+        ) : undefined
+      }
+    >
+      <View className="workflow-body">
+        {record ? (
+          <>
+            <View className="panel-card">
+              <Text className="panel-title">{record.name}</Text>
+              <Text className="panel-description">
+                {record.year}年 · {record.paperType} · {record.province}
+              </Text>
+              <Text className="panel-description">
+                共 {record.photos.length} 页 · {record.questions.length} 道题
+              </Text>
+            </View>
+            <Chips values={['题目与解析', '查看原图']} selected={tab} onChange={setTab} />
+            {tab === '题目与解析' ? (
+              record.questions.map((q) => <QuestionCard key={q.id} question={q} />)
+            ) : photo ? (
+              <>
+                <Swipe
+                  onSwipe={(direction) =>
+                    setPageIndex((i) => Math.max(0, Math.min(i + direction, record.photos.length - 1)))
+                  }
+                >
+                  <Image
+                    className="original-image"
+                    src={photoSource(photo, record.kind)}
+                    mode="widthFix"
+                    onClick={() =>
+                      void Taro.previewImage({
+                        current: photoSource(photo, record.kind),
+                        urls: record.photos.map((p) => photoSource(p, record.kind))
+                      })
+                    }
+                  />
+                </Swipe>
+                <Chips
+                  values={record.photos.map((_, i) => `第 ${i + 1} 页`)}
+                  selected={`第 ${pageIndex + 1} 页`}
+                  onChange={(value) => setPageIndex(Number(value.match(/\d+/)?.[0]) - 1)}
+                />
+              </>
+            ) : (
+              <Empty>暂无原图</Empty>
+            )}
+          </>
+        ) : stats ? (
+          <>
+            <View className="panel-card">
+              <Text className="panel-title">题库数据概览</Text>
+              <Text className="panel-description">根据当前设备的本地记录统计</Text>
+              <View className="rules-row">
+                <View>
+                  {allQuestions.length}
+                  <br />
+                  总题目数
+                </View>
+                <View>
+                  {allQuestions.filter((q) => q.isWrong).length}
+                  <br />
+                  错题总数
+                </View>
+                <View>
+                  {new Set(state.records.flatMap((r) => r.students.map((s) => s.id))).size}
+                  <br />
+                  关联学生
+                </View>
+              </View>
+            </View>
+            {[
+              {
+                title: '年级分布',
+                labels: ['初一', '初二', '初三'],
+                counts: ['初一', '初二', '初三'].map((g) => allQuestions.filter((q) => q.grade === g).length)
+              },
+              {
+                title: '难度分布',
+                labels: ['基础', '中等', '困难'],
+                counts: ['基础', '中等', '困难'].map((d) => allQuestions.filter((q) => q.diff === d).length)
+              },
+              {
+                title: '知识点分布',
+                labels: [...new Set(allQuestions.flatMap((q) => q.tags))],
+                counts: [...new Set(allQuestions.flatMap((q) => q.tags))].map(
+                  (t) => allQuestions.filter((q) => q.tags.includes(t)).length
+                )
+              }
+            ].map((group) => (
+              <View className="panel-card" key={group.title}>
+                <Text className="panel-title">{group.title}</Text>
+                {group.labels.map((label, i) => (
+                  <View key={label}>
+                    <View className="row-between">
+                      <Text>{label}</Text>
+                      <Text className="muted-copy">{group.counts[i]} 题</Text>
+                    </View>
+                    <View className="progress-track">
+                      <View
+                        className="progress-fill"
+                        style={{ width: `${(group.counts[i] / Math.max(1, allQuestions.length)) * 100}%` }}
+                      />
+                    </View>
+                  </View>
+                ))}
+              </View>
+            ))}
+            <View className="panel-card">
+              <Text className="panel-title">近 7 天上传趋势</Text>
+              {Array.from({ length: 7 }, (_, i) => {
+                const date = new Date(Date.now() - (6 - i) * 86400000)
+                const label = formatTime(date.toISOString()).split(' ')[0]
+                const count = state.records
+                  .filter((r) => formatTime(r.submittedAt).startsWith(label))
+                  .reduce((sum, r) => sum + r.questions.length, 0)
+                return (
+                  <View className="select-field" key={label}>
+                    <Text>{label}</Text>
+                    <Text>{count} 题</Text>
+                  </View>
+                )
+              })}
+            </View>
+          </>
+        ) : (
+          <>
+            <Input
+              className="field-input"
+              placeholder="搜索题目、知识点或学生"
+              value={search}
+              onInput={(e) => setSearch(e.detail.value)}
+            />
+            <Text className="field-label">年级</Text>
+            <Chips values={['全部', '初一', '初二', '初三']} selected={grade} onChange={setGrade} />
+            <Text className="field-label">知识点</Text>
+            <Chips values={['全部', '二次函数', '方程', '几何', '圆']} selected={topic} onChange={setTopic} />
+            {filtered.map((r) =>
+              r.kind === 'papers' ? (
+                <Button
+                  key={r.id}
+                  className="notice-row"
+                  onClick={() => {
+                    setTarget(r.id)
+                    setPageIndex(0)
+                    setTab('题目与解析')
+                  }}
+                >
+                  <View className="row-between">
+                    <Text>{r.name}</Text>
+                    <Text className="tag">试卷</Text>
+                  </View>
+                  <Text className="notice-summary">
+                    {r.photos.length} 页 · {r.questions.length} 道匹配题目 ·{' '}
+                    {r.reviewStatus === 'pending' ? '待审核' : '审核通过'}
+                  </Text>
+                  <Text className="notice-meta">{formatTime(r.submittedAt)} · 查看试卷 →</Text>
+                </Button>
+              ) : (
+                <View key={r.id}>
+                  {r.questions.map((q) => (
+                    <View key={q.id}>
+                      <QuestionCard question={q} />
+                      <Text className="panel-description">
+                        关联:{r.students.map((s) => `${s.className} ${s.name}`).join('、')}
+                      </Text>
+                    </View>
+                  ))}
+                </View>
+              )
+            )}
+            {!filtered.length && <Empty>没有找到匹配的题目</Empty>}
+          </>
+        )}
+      </View>
+    </Page>
+  )
+}

+ 1 - 0
src/pages/messages/index.config.ts

@@ -0,0 +1 @@
+export default definePageConfig({ navigationBarTitleText: '消息通知' })

+ 4 - 0
src/pages/messages/index.scss

@@ -0,0 +1,4 @@
+// Keep a page style entry so WeChat emits and compiles this page's WXSS.
+.messages-page {
+  min-height: 100vh;
+}

+ 160 - 0
src/pages/messages/index.tsx

@@ -0,0 +1,160 @@
+import { useState } from 'react'
+import { Button, Text, View } from '@tarojs/components'
+import Image from '../../components/PhotoImage'
+import Taro, { useRouter } from '@tarojs/taro'
+import { Actions, Chips, Empty, Page, QuestionCard, Swipe } from '../../components/WorkbenchUI'
+import { formatTime, markRead, type Notice } from '../../data/workbench'
+import { photoSource } from '../../data/local-media'
+import { showError, useWorkbench } from '../../data/workbench-store'
+import './index.scss'
+
+const filters = ['全部', '视频讲解', '试卷', '题目', '答题卡']
+const kindNames = { videos: '视频讲解', papers: '试卷', mistakes: '题目', answers: '答题卡' }
+export default function Messages() {
+  const { params } = useRouter()
+  const { state, update } = useWorkbench()
+  const onlyVideos = params.filter === 'videos'
+  const [filter, setFilter] = useState(onlyVideos ? '视频讲解' : '全部')
+  const [targetId, setTargetId] = useState(params.record || '')
+  const [tab, setTab] = useState('题目与解析')
+  const [pageIndex, setPageIndex] = useState(0)
+  const record = state.records.find((r) => r.id === targetId)
+  const mark = (id?: string) => {
+    try {
+      update((previous) => !id && onlyVideos ? {
+        ...previous,
+        explanations: previous.explanations.map(v => ({ ...v, unread: false })),
+        notices: previous.notices.map(n => n.kind === 'videos' ? { ...n, unread: false } : n)
+      } : markRead(previous, id))
+    } catch (error) {
+      showError(error)
+    }
+  }
+  const open = (notice: Notice) => {
+    mark(notice.targetId)
+    if (notice.kind === 'videos') {
+      void Taro.navigateTo({ url: `/pages/explanations/index?id=${notice.targetId}` })
+      return
+    }
+    setTargetId(notice.targetId)
+    setTab('题目与解析')
+    setPageIndex(0)
+  }
+  const photo = record?.photos[pageIndex]
+  return (
+    <Page
+      className="messages-page"
+      title={record ? '上传详情' : onlyVideos ? '讲解通知' : '消息通知'}
+      onBack={record ? () => setTargetId('') : undefined}
+      action={
+        !record ? (
+          <Button className="text-button" onClick={() => mark()}>
+            全部已读
+          </Button>
+        ) : undefined
+      }
+    >
+      <View className="workflow-body">
+        {!record ? (
+          <>
+            {!onlyVideos && <Chips values={filters} selected={filter} onChange={setFilter} />}
+            {state.notices
+              .filter((n) => filter === '全部' || kindNames[n.kind] === filter)
+              .map((n) => (
+                <Button className={`notice-row ${n.unread ? 'unread' : ''}`} key={n.id} onClick={() => open(n)}>
+                  <View className="row-between">
+                    <Text>{n.title}</Text>
+                    {n.unread && <Text className="unread-dot">●</Text>}
+                  </View>
+                  <Text className="notice-summary">
+                    {n.kind === 'videos'
+                      ? state.explanations.find((v) => v.id === n.targetId)?.title
+                      : state.records.find((r) => r.id === n.targetId)?.name}
+                  </Text>
+                  <Text className="notice-meta">
+                    {formatTime(n.createdAt)} · {kindNames[n.kind]}
+                  </Text>
+                </Button>
+              ))}
+            {!state.notices.some((n) => filter === '全部' || kindNames[n.kind] === filter) && (
+              <Empty>暂无相关通知</Empty>
+            )}
+          </>
+        ) : (
+          <>
+            <View className="panel-card">
+              <Text className="panel-title">{record.name}</Text>
+              <Text className="panel-description">提交时间:{formatTime(record.submittedAt)}</Text>
+              {record.approvedAt && (
+                <Text className="panel-description">审核通过时间:{formatTime(record.approvedAt)}</Text>
+              )}
+              {record.kind === 'papers' && (
+                <>
+                  <Text className="panel-description">
+                    {record.year}年 · {record.paperType} · {record.province} · {record.photos.length} 页
+                  </Text>
+                  <Text className={record.reviewStatus === 'approved' ? 'answer-content' : 'panel-description'}>
+                    {record.reviewStatus === 'approved' ? '审核通过' : '待审核,审核通过后将发送通知'}
+                  </Text>
+                </>
+              )}
+              {record.kind === 'mistakes' && (
+                <Text className="panel-description">
+                  关联学生:{record.students.map((s) => `${s.className} ${s.name}`).join('、')}
+                </Text>
+              )}
+            </View>
+            {record.kind === 'answers' ? (
+              <View className="panel-card">
+                <Text className="panel-title">已上传 {record.photos.length} 张答题卡</Text>
+                <Text className="panel-description">解析结果请到客户端的【批量解析】中查看。</Text>
+              </View>
+            ) : (
+              <>
+                <Chips values={['题目与解析', '查看原图']} selected={tab} onChange={setTab} />
+                {tab === '题目与解析' ? (
+                  record.questions.map((q) => <QuestionCard key={q.id} question={q} />)
+                ) : photo ? (
+                  <>
+                    <Swipe
+                      onSwipe={(direction) =>
+                        setPageIndex((i) => Math.max(0, Math.min(i + direction, record.photos.length - 1)))
+                      }
+                    >
+                      <Image
+                        className="original-image"
+                        src={photoSource(photo, record.kind)}
+                        mode="widthFix"
+                        onClick={() =>
+                          void Taro.previewImage({
+                            current: photoSource(photo, record.kind),
+                            urls: record.photos.map((p) => photoSource(p, record.kind))
+                          })
+                        }
+                      />
+                    </Swipe>
+                    <Chips
+                      values={record.photos.map((_, i) => `第 ${i + 1} 页`)}
+                      selected={`第 ${pageIndex + 1} 页`}
+                      onChange={(value) => setPageIndex(Number(value.match(/\d+/)?.[0]) - 1)}
+                    />
+                  </>
+                ) : (
+                  <Empty>此记录未保留原图</Empty>
+                )}
+              </>
+            )}
+            <Actions>
+              <Button className="secondary-button" onClick={() => setTargetId('')}>
+                返回消息
+              </Button>
+              <Button className="primary-button" onClick={() => void Taro.switchTab({ url: '/pages/home/index' })}>
+                返回首页
+              </Button>
+            </Actions>
+          </>
+        )}
+      </View>
+    </Page>
+  )
+}

+ 174 - 18
src/pages/profile/index.tsx

@@ -6,58 +6,214 @@ import DemoSheet from '../../components/DemoSheet'
 import { demoTeacher } from '../../data/demo'
 import { useDemoSession } from '../../data/demo-session'
 import { appEnvironment } from '../../config/environment'
+import { Field } from '../../components/WorkbenchUI'
+import { showError, useWorkbench } from '../../data/workbench-store'
 import './index.scss'
 
 type Panel = 'account' | 'password' | 'about' | null
 
 export default function Profile() {
   const { signedIn, setSignedIn } = useDemoSession()
+  const { state, update } = useWorkbench()
+  const [teacher, setTeacher] = useState(state.teacher)
   const [panel, setPanel] = useState<Panel>(null)
   const [password, setPassword] = useState('')
+  const [currentPassword, setCurrentPassword] = useState('')
   const [confirmation, setConfirmation] = useState('')
-  const close = () => { setPanel(null); setPassword(''); setConfirmation('') }
+  const close = () => {
+    setPanel(null)
+    setPassword('')
+    setConfirmation('')
+    setCurrentPassword('')
+  }
   const open = (next: Panel) => {
-    if (next !== 'about' && !signedIn) { void Taro.showToast({ title: '请先进入演示账号', icon: 'none' }); return }
+    if (next !== 'about' && !signedIn) {
+      void Taro.showToast({ title: '请先进入演示账号', icon: 'none' })
+      return
+    }
+    setTeacher(state.teacher)
     setPanel(next)
   }
   const logout = async () => {
-    if (!signedIn) { setSignedIn(true); return }
-    const result = await Taro.showModal({ title: '退出演示账号', content: '退出后可随时重新进入,本次操作仅改变本地演示状态。', confirmText: '退出', confirmColor: '#fa555b' })
-    if (result.confirm) { setSignedIn(false); close() }
+    if (!signedIn) {
+      setSignedIn(true)
+      return
+    }
+    const result = await Taro.showModal({
+      title: '退出演示账号',
+      content: '退出后可随时重新进入,本次操作仅改变本地演示状态。',
+      confirmText: '退出',
+      confirmColor: '#fa555b'
+    })
+    if (result.confirm) {
+      setSignedIn(false)
+      close()
+    }
   }
   const previewPassword = () => {
-    const message = password.length < 8 ? '请输入至少 8 位演示密码' : password !== confirmation ? '两次输入的密码不一致' : '校验通过,展示模式不会修改密码'
+    const message = !currentPassword
+      ? '请输入当前演示密码'
+      : password.length < 8
+      ? '请输入至少 8 位演示密码'
+      : password !== confirmation
+      ? '两次输入的密码不一致'
+      : '校验通过,展示模式不会修改密码'
     void Taro.showToast({ title: message, icon: 'none' })
-    if (password.length >= 8 && password === confirmation) close()
+    if (currentPassword && password.length >= 8 && password === confirmation) close()
+  }
+  const saveAccount = () => {
+    if (!teacher.name.trim()) {
+      void Taro.showToast({ title: '请输入姓名', icon: 'none' })
+      return
+    }
+    if (teacher.phone && !/^1\d{10}$/.test(teacher.phone)) {
+      void Taro.showToast({ title: '请输入有效手机号', icon: 'none' })
+      return
+    }
+    if (teacher.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(teacher.email)) {
+      void Taro.showToast({ title: '请输入有效邮箱', icon: 'none' })
+      return
+    }
+    try {
+      update((previous) => ({ ...previous, teacher: { ...teacher, name: teacher.name.trim() } }))
+      close()
+      void Taro.showToast({ title: '资料已在本地保存', icon: 'none' })
+    } catch (error) {
+      showError(error)
+    }
   }
 
   return (
     <View className="profile-page">
       <SafeTop />
       <View className="profile-header purple-header">
-        <View className="profile-avatar"><Text>👤</Text></View>
-        <View className="teacher-copy"><Text className="teacher-name">{signedIn ? demoTeacher.name : '游客'}</Text><Text className="teacher-organization">{demoTeacher.organization}</Text><Text className="teacher-account">{signedIn ? `账号:${demoTeacher.account}` : '进入演示账号,体验教师工作台'}</Text></View>
+        <View className="profile-avatar">
+          <Text>👤</Text>
+        </View>
+        <View className="teacher-copy">
+          <Text className="teacher-name">{signedIn ? state.teacher.name : '游客'}</Text>
+          <Text className="teacher-organization">{demoTeacher.organization}</Text>
+          <Text className="teacher-account">
+            {signedIn ? `账号:${demoTeacher.account}` : '进入演示账号,体验教师工作台'}
+          </Text>
+        </View>
         <Text className="profile-watermark">知</Text>
       </View>
 
       <View className="profile-content">
         <View className="settings-group">
           <Text className="group-label">账号管理</Text>
-          <Button className="settings-row" onClick={() => open('account')}><View className="settings-icon icon-account">👤</View><View className="settings-copy"><Text className="settings-title">账号信息</Text><Text className="settings-description">管理个人资料与联系方式</Text></View><Text className="settings-chevron">›</Text></Button>
-          <Button className="settings-row" onClick={() => open('password')}><View className="settings-icon icon-password">🔒</View><View className="settings-copy"><Text className="settings-title">修改密码</Text><Text className="settings-description">更新账号登录密码</Text></View><Text className="settings-chevron">›</Text></Button>
+          <Button className="settings-row" onClick={() => open('account')}>
+            <View className="settings-icon icon-account">👤</View>
+            <View className="settings-copy">
+              <Text className="settings-title">账号信息</Text>
+              <Text className="settings-description">管理个人资料与联系方式</Text>
+            </View>
+            <Text className="settings-chevron">›</Text>
+          </Button>
+          <Button className="settings-row" onClick={() => open('password')}>
+            <View className="settings-icon icon-password">🔒</View>
+            <View className="settings-copy">
+              <Text className="settings-title">修改密码</Text>
+              <Text className="settings-description">更新账号登录密码</Text>
+            </View>
+            <Text className="settings-chevron">›</Text>
+          </Button>
         </View>
         <View className="settings-group support-group">
           <Text className="group-label">服务与支持</Text>
-          <Button className="settings-row" onClick={() => open('about')}><View className="settings-icon icon-about">ℹ️</View><View className="settings-copy"><Text className="settings-title">关于知了数学</Text></View><Text className="settings-chevron">›</Text></Button>
+          <Button className="settings-row" onClick={() => open('about')}>
+            <View className="settings-icon icon-about">ℹ️</View>
+            <View className="settings-copy">
+              <Text className="settings-title">关于知了数学</Text>
+            </View>
+            <Text className="settings-chevron">›</Text>
+          </Button>
         </View>
-        <Button className={`logout-button${signedIn ? '' : ' enter-demo'}`} onClick={() => void logout()}>{signedIn ? '退出登录' : '进入演示账号'}</Button>
+        <Button className={`logout-button${signedIn ? '' : ' enter-demo'}`} onClick={() => void logout()}>
+          {signedIn ? '退出登录' : '进入演示账号'}
+        </Button>
       </View>
 
-      {panel && <DemoSheet title={{ account: '账号信息', password: '修改密码', about: '关于知了数学' }[panel]} onClose={close}>
-        {panel === 'account' && <View><View className="information-row"><Text className="muted-copy">姓名</Text><Text>{demoTeacher.name}</Text></View><View className="information-row"><Text className="muted-copy">所属机构</Text><Text>{demoTeacher.organization}</Text></View><View className="information-row"><Text className="muted-copy">演示账号</Text><Text>{demoTeacher.account}</Text></View><Text className="demo-note">资料为本地演示数据,暂不提供保存功能。</Text></View>}
-        {panel === 'password' && <View><Text className="demo-note">仅演示表单交互,不保存或发送密码。请使用虚构内容。</Text><Text className="input-label">新密码</Text><Input className="password-input" password maxlength={32} placeholder="输入至少 8 位演示密码" value={password} onInput={(event) => setPassword(event.detail.value)} /><Text className="input-label">确认新密码</Text><Input className="password-input" password maxlength={32} placeholder="再次输入演示密码" value={confirmation} onInput={(event) => setConfirmation(event.detail.value)} /><Button className="primary-button" onClick={previewPassword}>演示确认</Button></View>}
-        {panel === 'about' && <View className="about-content"><Text className="about-name">知了数学</Text><Text className="muted-copy">教师工作台 · 让数学教学更轻松</Text><View className="about-version"><Text>版本 {appEnvironment.version}</Text><Text>{appEnvironment.label}</Text></View><Text className="demo-note">当前版本仅提供前端展示,所有内容均为本地演示数据。</Text></View>}
-      </DemoSheet>}
+      {panel && (
+        <DemoSheet title={{ account: '账号信息', password: '修改密码', about: '关于知了数学' }[panel]} onClose={close}>
+          {panel === 'account' && (
+            <View>
+              <View className="information-row">
+                <Text className="muted-copy">所属机构</Text>
+                <Text>{demoTeacher.organization}</Text>
+              </View>
+              <View className="information-row">
+                <Text className="muted-copy">演示账号</Text>
+                <Text>{demoTeacher.account}</Text>
+              </View>
+              <Field label="姓名" value={teacher.name} onChange={(name) => setTeacher((v) => ({ ...v, name }))} />
+              <Field
+                label="手机号"
+                value={teacher.phone}
+                placeholder="请输入手机号(可选)"
+                onChange={(phone) => setTeacher((v) => ({ ...v, phone }))}
+              />
+              <Field
+                label="邮箱"
+                value={teacher.email}
+                placeholder="请输入邮箱(可选)"
+                onChange={(email) => setTeacher((v) => ({ ...v, email }))}
+              />
+              <Text className="demo-note">仅保存在当前设备。</Text>
+              <Button className="primary-button" onClick={saveAccount}>
+                保存
+              </Button>
+            </View>
+          )}
+          {panel === 'password' && (
+            <View>
+              <Text className="demo-note">仅演示表单交互,不保存或发送密码。请使用虚构内容。</Text>
+              <Text className="input-label">当前密码</Text>
+              <Input
+                className="password-input"
+                password
+                maxlength={32}
+                placeholder="输入当前演示密码"
+                value={currentPassword}
+                onInput={(event) => setCurrentPassword(event.detail.value)}
+              />
+              <Text className="input-label">新密码</Text>
+              <Input
+                className="password-input"
+                password
+                maxlength={32}
+                placeholder="输入至少 8 位演示密码"
+                value={password}
+                onInput={(event) => setPassword(event.detail.value)}
+              />
+              <Text className="input-label">确认新密码</Text>
+              <Input
+                className="password-input"
+                password
+                maxlength={32}
+                placeholder="再次输入演示密码"
+                value={confirmation}
+                onInput={(event) => setConfirmation(event.detail.value)}
+              />
+              <Button className="primary-button" onClick={previewPassword}>
+                演示确认
+              </Button>
+            </View>
+          )}
+          {panel === 'about' && (
+            <View className="about-content">
+              <Text className="about-name">知了数学</Text>
+              <Text className="muted-copy">教师工作台 · 让数学教学更轻松</Text>
+              <View className="about-version">
+                <Text>版本 {appEnvironment.version}</Text>
+                <Text>{appEnvironment.label}</Text>
+              </View>
+              <Text className="demo-note">当前版本仅提供前端展示,所有内容均为本地演示数据。</Text>
+            </View>
+          )}
+        </DemoSheet>
+      )}
     </View>
   )
 }

+ 1 - 0
src/pages/upload/index.config.ts

@@ -0,0 +1 @@
+export default definePageConfig({ navigationBarTitleText: '上传', disableScroll: false })

+ 186 - 0
src/pages/upload/index.scss

@@ -0,0 +1,186 @@
+.mistake-capture-page,
+.paper-capture-page,
+.answer-capture-page,
+.answer-guide-page,
+.photo-guide-page,
+.photo-gallery-page {
+  background: #fff;
+  color: #1a1a2e;
+
+  .workflow-nav {
+    height: 116px;
+    min-height: 116px;
+    padding: 16px;
+    border: 0;
+    font-size: 36px;
+    color: #000;
+  }
+
+  .workflow-nav > text,
+  .workflow-nav > taro-text-core {
+    position: absolute;
+    left: 50%;
+    transform: translateX(-50%);
+    white-space: nowrap;
+  }
+
+  .back-button {
+    width: 76px;
+    height: 76px;
+    line-height: 76px;
+    background: #f5f5f5;
+    color: #111;
+    font-size: 32px;
+  }
+
+  .nav-action { max-width: none; }
+  .nav-action .text-button { font-weight: 400; }
+  .text-button { color: #8b3dff; font-size: 28px; }
+  .primary-button {
+    padding: 26px 18px;
+    border-radius: 24px;
+    font-size: 29px;
+    font-weight: 600;
+    background: linear-gradient(110deg, #7c3aed, #a78bfa);
+  }
+}
+
+.photo-gallery-page {
+  padding-bottom: calc(140px + env(safe-area-inset-bottom));
+  .workflow-nav { height: 128px; border-bottom: 1px solid #eee; }
+  .workflow-body { padding: 32px 18px 24px; }
+  .gallery-list { display: flex; flex-direction: column; gap: 22px; }
+  .gallery-row { display: flex; align-items: flex-start; gap: 26px; padding: 24px 26px; border-radius: 28px; background: #faf8fd; }
+  .gallery-thumbnail { display: block; flex-shrink: 0; width: 146px; height: 198px; background: #fff; }
+  .gallery-file-copy { flex: 1; min-width: 0; padding-top: 4px; }
+  .gallery-file-name { display: block; font-size: 26px; line-height: 1.7; color: #000; overflow-wrap: anywhere; }
+  .gallery-view { width: auto; padding: 0; margin-top: 18px; text-align: left; font-size: 30px; }
+  .gallery-delete { flex-shrink: 0; align-self: center; padding: 0; width: auto; color: #ff4d5e; font-size: 30px; }
+  .gallery-row.is-document { align-items: center; min-height: 134px; }
+  .is-document .gallery-file-copy { padding-top: 0; }
+  .gallery-document-icon { position: relative; flex-shrink: 0; width: 50px; height: 66px; margin: 12px 16px; padding: 16px 6px 0; border-radius: 2px; background: #e7e0ef; color: #aa9dbb; font-size: 40px; line-height: 1; }
+  .gallery-document-icon::before { position: absolute; top: 0; right: 0; content: ''; border-top: 18px solid #faf8fd; border-left: 18px solid #cfc5de; }
+  .gallery-footer { position: fixed; z-index: 10; bottom: 0; left: 0; right: 0; padding: 18px 18px calc(18px + env(safe-area-inset-bottom)); background: #fff; }
+}
+
+.mistake-capture-page {
+  .workflow-body { padding: 0 16px 32px; }
+  .student-link-bar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 20px;
+    padding: 32px 24px;
+    border-radius: 24px;
+    background: #faf7ff;
+  }
+  .student-link-copy { flex: 1; min-width: 0; }
+  .student-link-bar > .text-button { flex-shrink: 0; width: auto; }
+  .student-link-bar .panel-title {
+    font-size: 25px;
+    color: #6d00ed;
+    margin-bottom: 10px;
+  }
+  .student-link-bar .panel-description { font-size: 23px; color: #999; line-height: 1.5; }
+}
+
+.mistake-capture-page,
+.paper-capture-page {
+  .capture-zone {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    height: 418px;
+    margin: 32px 0;
+    padding: 30px;
+    border: 4px dashed #d0d5dd;
+    border-radius: 32px;
+    background: #fafbfc;
+  }
+  .capture-symbol { font-size: 80px; line-height: 1.3; margin-bottom: 26px; }
+  .capture-zone .panel-title { font-size: 32px; margin-bottom: 12px; }
+  .capture-zone .panel-description { font-size: 26px; color: #999; line-height: 1.5; }
+  .capture-batch { margin-bottom: 32px; }
+  .capture-count { width: fit-content; text-align: left; padding: 0; margin-bottom: 24px; font-size: 25px; }
+  .capture-imports { display: flex; gap: 24px; }
+  .capture-import-card {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    flex: 1;
+    min-width: 0;
+    margin: 0;
+    height: 212px;
+    padding: 24px 8px;
+    border: 0;
+    border-radius: 28px;
+    box-shadow: 0 4px 16px rgba(0, 0, 0, .06);
+    background: #fff;
+    color: #1a1a2e;
+    font-size: 28px;
+    line-height: 1.5;
+  }
+  .capture-import-icon { display: block; font-size: 56px; margin-bottom: 18px; line-height: 1.3; }
+}
+
+.paper-capture-page {
+  .workflow-nav { padding: 16px 26px; border-bottom: 1px solid #eee; height: 124px; }
+  .workflow-body { padding: 0 26px 32px; }
+  .paper-metadata { padding: 28px; border-radius: 28px; background: #faf7ff; }
+  .paper-metadata-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin: 28px 0; }
+  .paper-meta-select { display: block; min-width: 0; }
+  .paper-meta-field { display: block; padding: 0; border: 0; }
+  .field-label { margin: 0 0 10px; color: #655274; font-size: 26px; font-weight: 400; line-height: 1.3; }
+  .field-input,
+  .paper-select-control { width: 100%; height: 84px; padding: 20px 22px; border: 1px solid #ddd0f6; border-radius: 16px; background: #fff; color: #544466; font-size: 26px; line-height: 1.5; }
+  .paper-select-control { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+  .paper-select-arrow { flex-shrink: 0; }
+  .capture-zone { height: 408px; margin: 36px 0 32px; }
+}
+
+.answer-capture-page,
+.answer-guide-page {
+  padding-bottom: calc(24px + env(safe-area-inset-bottom));
+  .workflow-nav { height: 112px; min-height: 112px; padding: 12px 32px; border-bottom: 1px solid #eee; }
+  .workflow-body { padding: 0 0 28px; }
+  .answer-intro { padding: 8px 40px 28px; }
+  .answer-intro-title { display: block; font-size: 38px; font-weight: 700; color: #252039; line-height: 1.5; margin-bottom: 6px; }
+  .answer-intro-description { display: block; white-space: pre-line; font-size: 24px; line-height: 1.7; color: #8b8797; }
+  .answer-stage { margin: 0 32px; padding: 28px 32px 24px; border-radius: 32px; background: linear-gradient(145deg, #f0ebf8, #e7e3ef); }
+  .answer-stage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 24px; font-size: 22px; line-height: 1.5; color: #81778f; }
+  .answer-stage-label { padding: 6px 14px; border-radius: 10px; background: #fff; color: #8b3dff; }
+  .answer-reference { display: block; width: 408px; height: 622px; max-width: 100%; margin: 0 auto; box-shadow: 0 16px 40px #36204c18; }
+  .answer-caption { display: block; margin-top: 24px; text-align: center; font-size: 22px; line-height: 1.5; color: #736486; }
+  .answer-rules { display: flex; gap: 12px; margin: 24px 32px 0; }
+  .answer-rule { flex: 1; min-width: 0; padding: 20px 6px; border-radius: 18px; background: #faf8fd; text-align: center; font-size: 22px; line-height: 1.6; color: #797282; }
+  .answer-rule-title { display: block; font-weight: 600; color: #544466; }
+  .answer-buttons { display: flex; gap: 20px; margin: 28px 18px 0; }
+  .answer-album-button,
+  .answer-camera-button { display: flex; justify-content: center; align-items: center; flex: 1; min-width: 0; width: auto; min-height: 88px; margin: 0; padding: 22px 8px; border: 0; border-radius: 26px; font-size: 29px; font-weight: 600; line-height: 1.5; }
+  .answer-album-button { background: #f3edff; color: #8b3dff; }
+}
+
+.photo-guide-page {
+  .workflow-body { padding: 0 28px 32px; }
+  .photo-guide-intro { padding: 26px 8px 24px; }
+  .photo-guide-title { display: block; font-size: 38px; font-weight: 700; color: #252039; margin-bottom: 12px; }
+  .photo-guide-description { display: block; white-space: pre-line; font-size: 24px; line-height: 1.7; color: #8b8797; }
+  .photo-guide-stage { padding: 28px 32px 24px; border-radius: 32px; background: linear-gradient(145deg, #f0ebf8, #e7e3ef); }
+  .photo-guide-stage-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; font-size: 22px; color: #81778f; margin-bottom: 24px; }
+  .photo-guide-label { padding: 6px 14px; border-radius: 10px; background: #fff; color: #8b3dff; white-space: nowrap; }
+  .photo-guide-frame { position: relative; width: 464px; max-width: 100%; padding: 16px; margin: 0 auto; background: #e5dfed; border-radius: 8px; }
+  .photo-guide-reference { display: block; width: 100%; height: 576px; }
+  .photo-guide-corner { position: absolute; width: 34px; height: 34px; border-color: #7c3aed; border-style: solid; pointer-events: none; }
+  .photo-guide-corner.tl { top: 0; left: 0; border-width: 4px 0 0 4px; }
+  .photo-guide-corner.tr { top: 0; right: 0; border-width: 4px 4px 0 0; }
+  .photo-guide-corner.bl { bottom: 0; left: 0; border-width: 0 0 4px 4px; }
+  .photo-guide-corner.br { bottom: 0; right: 0; border-width: 0 4px 4px 0; }
+  .photo-guide-caption { display: block; text-align: center; margin-top: 24px; font-size: 22px; line-height: 1.5; color: #736486; }
+  .photo-guide-rules { display: flex; gap: 12px; margin: 24px 0; }
+  .photo-guide-rule { flex: 1; min-width: 0; padding: 20px 6px; border-radius: 18px; background: #faf8fd; text-align: center; font-size: 20px; line-height: 1.7; white-space: pre-line; color: #797282; }
+  .photo-guide-rule-title { display: block; font-size: 22px; font-weight: 600; color: #544466; }
+  .photo-guide-multipage { padding: 24px 28px; border-radius: 24px; background: #faf8fd; white-space: pre-line; font-size: 24px; line-height: 1.8; color: #797282; }
+  .photo-guide-multipage .photo-guide-rule-title { font-size: 25px; margin-bottom: 8px; }
+}

+ 782 - 0
src/pages/upload/index.tsx

@@ -0,0 +1,782 @@
+import { useEffect, useRef, useState } from 'react'
+import { Button, Picker, Text, View } from '@tarojs/components'
+import Image from '../../components/PhotoImage'
+import Taro, { useRouter } from '@tarojs/taro'
+import { Actions, Chips, Empty, Field, Page, QuestionCard, Select, Swipe } from '../../components/WorkbenchUI'
+import StudentPicker from '../../components/StudentPicker'
+import {
+  addRecord,
+  difficulties,
+  formatTime,
+  paperTypes,
+  provinces,
+  recognizePhotos,
+  topics,
+  validateUpload,
+  type Photo,
+  type Question,
+  type RecordItem,
+  type Student,
+  type UploadKind
+} from '../../data/workbench'
+import { demoPhoto, newId, photoSource, pickDocuments, pickPhotos, preservePhotos } from '../../data/local-media'
+import { showError, useWorkbench } from '../../data/workbench-store'
+import captureReference from '../../assets/paper-capture-reference.png'
+import answerReference from '../../assets/answer-capture-reference.png'
+import './index.scss'
+
+type Screen = 'capture' | 'guide' | 'gallery' | 'preview' | 'review' | 'success' | 'failure'
+const titles = { mistakes: '上传错题', papers: '上传试卷', answers: '上传答题卡' }
+function AnswerGuide() {
+  return (
+    <>
+      <View className="answer-intro">
+        <Text className="answer-intro-title">答题卡拍摄规范</Text>
+        <Text className="answer-intro-description">将答题卡平放,镜头正对纸面{'\n'}每张照片拍摄一页,四周边缘完整入镜</Text>
+      </View>
+      <View className="answer-stage">
+        <View className="answer-stage-heading"><Text className="answer-stage-label">A4 答题卡拍摄参考</Text><Text>正面 · 完整拍摄</Text></View>
+        <Image className="answer-reference" src={answerReference} mode="aspectFit" />
+        <Text className="answer-caption">{'✓ 姓名清晰\u3000✓ 二维码完整\u3000✓ 答案无遮挡'}</Text>
+      </View>
+      <View className="answer-rules">
+        <View className="answer-rule"><Text className="answer-rule-title">▣ 拍全纸张</Text><Text>不裁边、不缺角</Text></View>
+        <View className="answer-rule"><Text className="answer-rule-title">☀ 光线均匀</Text><Text>无反光、无阴影</Text></View>
+        <View className="answer-rule"><Text className="answer-rule-title">▤ 字迹清晰</Text><Text>正对拍摄、不模糊</Text></View>
+      </View>
+    </>
+  )
+}
+function PaperSelect({ label, value, choices, onChange }: {
+  label: string
+  value: string
+  choices: string[]
+  onChange: (value: string) => void
+}) {
+  return (
+    <Picker className="paper-meta-select" mode="selector" range={choices} value={Math.max(0, choices.indexOf(value))} onChange={(e) => onChange(choices[Number(e.detail.value)])}>
+      <View className="paper-meta-field select-field">
+        <Text className="field-label">{label}</Text>
+        <View className="paper-select-control">
+          <Text className={value ? '' : 'muted-copy'}>{value || `请选择${label}`}</Text>
+          <Text className="paper-select-arrow">⌄</Text>
+        </View>
+      </View>
+    </Picker>
+  )
+}
+export default function Upload() {
+  const route = useRouter()
+  const kind: UploadKind =
+    route.params.kind === 'papers' || route.params.kind === 'answers' ? route.params.kind : 'mistakes'
+  const { state, update } = useWorkbench()
+  const [screen, setScreen] = useState<Screen>('capture')
+  const [guideReturn, setGuideReturn] = useState<Screen>('capture')
+  const [photos, setPhotos] = useState<Photo[]>([])
+  const [questions, setQuestions] = useState<Question[]>([])
+  const [selectedStudents, setSelectedStudents] = useState<Student[]>([])
+  const [studentPicker, setStudentPicker] = useState(false)
+  const [name, setName] = useState('')
+  const [year, setYear] = useState('')
+  const [paperType, setPaperType] = useState('')
+  const [province, setProvince] = useState('')
+  const [index, setIndex] = useState(0)
+  const [photoIndex, setPhotoIndex] = useState(0)
+  const [busy, setBusy] = useState('')
+  const [savedId, setSavedId] = useState('')
+  const [failure, setFailure] = useState('')
+  const [retakeId, setRetakeId] = useState<string | null>(null)
+  const lock = useRef(false)
+  const timer = useRef<ReturnType<typeof setTimeout>>()
+  useEffect(() => {
+    void Taro.pageScrollTo({ scrollTop: 0, duration: 0 })
+  }, [screen])
+  useEffect(
+    () => () => {
+      if (timer.current) clearTimeout(timer.current)
+    },
+    []
+  )
+  const metadata = { name, year, paperType, province }
+  const current = questions[index]
+  const record = state.records.find((r) => r.id === savedId)
+  const home = () => void Taro.switchTab({ url: '/pages/home/index' })
+  const toast = (title: string) => void Taro.showToast({ title, icon: 'none' })
+  const check = () => {
+    const message = validateUpload(kind, photos, selectedStudents, metadata)
+    if (message) {
+      if (kind === 'mistakes' && !selectedStudents.length) setStudentPicker(true)
+      else toast(message)
+      return false
+    }
+    return true
+  }
+  const addPhotos = (added: Photo[], replacement = retakeId) => {
+    if (!added.length) return
+    if (replacement) {
+      setPhotos((items) => items.map((p) => (p.id === replacement ? { ...added[0], id: p.id } : p)))
+      setRetakeId(null)
+    } else setPhotos((items) => [...items, ...added])
+    if (kind === 'answers') {
+      setPhotoIndex(replacement ? photos.findIndex((p) => p.id === replacement) : photos.length)
+      setScreen('preview')
+    }
+  }
+  const pick = async (camera: boolean, replacement = retakeId) => {
+    try {
+      const added = await pickPhotos(camera)
+      addPhotos(camera ? added.map((item, i) => ({ ...item, name: `拍摄照片 ${photos.length + i + 1}` })) : added, replacement)
+      if (added.length && screen === 'guide' && kind !== 'answers') setScreen('capture')
+    } catch (error) {
+      showError(error)
+    }
+  }
+  const addExample = (replacement = retakeId) => {
+    addPhotos([demoPhoto()], replacement)
+  }
+  const importDocument = async () => {
+    try {
+      addPhotos(await pickDocuments(), null)
+    } catch (error) {
+      showError(error)
+    }
+  }
+  const removePhoto = (id: string) => {
+    setPhotos((items) => items.filter((p) => p.id !== id))
+    setQuestions((items) => items.filter((q) => !q.id.startsWith(`${id}:`)))
+    setPhotoIndex((i) => Math.max(0, Math.min(i, photos.length - 2)))
+    if (photos.length === 1) setScreen('capture')
+  }
+  const recognize = () => {
+    if (lock.current || !check()) return
+    lock.current = true
+    setBusy('正在识别题目…')
+    timer.current = setTimeout(() => {
+      const next = recognizePhotos(photos, questions)
+      setQuestions(next)
+      setIndex(Math.min(index, next.length - 1))
+      setScreen('review')
+      setBusy('')
+      lock.current = false
+    }, 750)
+  }
+  const submit = async (simulateFailure = false) => {
+    if (lock.current || !check()) return
+    const chosen = kind === 'mistakes' ? questions.filter((q) => q.isWrong) : questions
+    if (kind !== 'answers' && !chosen.length) {
+      toast(kind === 'mistakes' ? '请至少标记一道错题' : '请先识别题目')
+      return
+    }
+    if (chosen.some((q) => !q.content.trim() || !q.answer.trim() || q.options.some((option) => !option.trim()))) {
+      toast('请补齐题目、选项和参考答案')
+      return
+    }
+    lock.current = true
+    setBusy('正在保存…')
+    try {
+      if (simulateFailure) {
+        if (kind === 'answers')
+          setPhotos((items) =>
+            items.map((photo, i) =>
+              i === items.length - 1
+                ? { ...photo, failure: '照片模糊或纸张边缘不完整,请重新拍摄。' }
+                : { ...photo, uploaded: true, failure: undefined }
+            )
+          )
+        setFailure(
+          kind === 'answers'
+            ? '部分答题卡识别失败,已成功的图片无需重复上传。'
+            : '本次模拟上传未完成,已填写和识别的内容已保留。'
+        )
+        setScreen('failure')
+        return
+      }
+      if (photos.some((p) => p.failure)) {
+        toast('请先重新拍摄识别失败的照片')
+        return
+      }
+      const preserved = await preservePhotos(photos)
+      const record: RecordItem = {
+        id: newId('record'),
+        kind,
+        ...metadata,
+        name: kind === 'papers' ? name.trim() : kind === 'answers' ? '答题卡' : '错题采集',
+        photos: preserved,
+        questions: chosen,
+        students: kind === 'mistakes' ? selectedStudents : [],
+        submittedAt: new Date().toISOString(),
+        ...(kind === 'papers' ? { reviewStatus: 'pending' as const } : {})
+      }
+      update((previous) => addRecord(previous, record))
+      setSavedId(record.id)
+      setScreen('success')
+    } catch {
+      setFailure('本地保存未完成,请检查设备存储空间后重试。')
+      setScreen('failure')
+    } finally {
+      lock.current = false
+      setBusy('')
+    }
+  }
+  const back = async () => {
+    if (screen === 'guide') {
+      setScreen(guideReturn)
+      return
+    }
+    if (screen === 'gallery' || screen === 'review' || screen === 'preview') {
+      setScreen('capture')
+      return
+    }
+    if (screen === 'failure') {
+      setScreen(kind === 'answers' ? 'preview' : 'review')
+      return
+    }
+    if (screen === 'success') {
+      home()
+      return
+    }
+    if (photos.length) {
+      const answer = await Taro.showModal({
+        title: '离开本次采集?',
+        content: '未提交的照片和编辑内容将丢失。',
+        confirmText: '离开',
+        cancelText: '继续编辑'
+      })
+      if (!answer.confirm) return
+    }
+    void Taro.navigateBack()
+  }
+  const reset = () => {
+    setPhotos([])
+    setSelectedStudents([])
+    setQuestions([])
+    setSavedId('')
+    setIndex(0)
+    setPhotoIndex(0)
+    setRetakeId(null)
+    setScreen('capture')
+  }
+  const changeQuestion = (q: Question) => setQuestions((items) => items.map((item) => (item.id === q.id ? q : item)))
+  const goQuestion = (next: number) => setIndex(Math.max(0, Math.min(next, questions.length - 1)))
+  const goPage = (direction: number) => {
+    const next = questions.findIndex((q) => q.page === current.page + direction)
+    if (next >= 0) setIndex(next)
+  }
+  const photo = photos[photoIndex]
+  const previewPhoto = (item: Photo) => {
+    if (item.document) return
+    void Taro.previewImage({
+      current: photoSource(item, kind),
+      urls: photos.filter((p) => !p.document).map((p) => photoSource(p, kind))
+    })
+  }
+  const openGuide = () => {
+    setGuideReturn(screen)
+    setScreen('guide')
+  }
+  const pageTitle =
+    screen === 'capture'
+      ? titles[kind]
+      : screen === 'guide'
+      ? kind === 'answers'
+        ? '答题卡拍照示范'
+        : '拍摄示例'
+      : screen === 'gallery'
+      ? '已添加照片'
+      : screen === 'preview'
+      ? '确认答题卡'
+      : screen === 'review'
+      ? kind === 'mistakes'
+        ? '勾选错题'
+        : '题目识别'
+      : screen === 'failure'
+      ? '上传失败'
+      : '上传成功'
+
+  return (
+    <Page
+      title={pageTitle}
+      className={screen === 'capture' ? kind === 'papers' ? 'paper-capture-page' : kind === 'answers' ? 'answer-capture-page' : 'mistake-capture-page' : screen === 'guide' ? kind === 'answers' ? 'answer-guide-page' : 'photo-guide-page' : screen === 'gallery' ? 'photo-gallery-page' : ''}
+      onBack={() => void back()}
+      action={
+        screen === 'gallery' ? (
+          <Button className="text-button" onClick={() => void pick(true)}>继续拍照</Button>
+        ) : (screen === 'capture' && kind !== 'answers') || screen === 'preview' ? (
+          <Button className="text-button" onClick={openGuide}>
+            查看示例
+          </Button>
+        ) : undefined
+      }
+    >
+      {!['capture', 'guide', 'gallery'].includes(screen) && (
+        <View className="demo-banner">当前为本地体验,识别、上传、审核结果均为模拟数据。</View>
+      )}
+      <View className="workflow-body">
+        {screen === 'capture' && (
+          <>
+            {kind === 'mistakes' && (
+              <View className="student-link-bar">
+                <View className="student-link-copy">
+                  <Text className="panel-title">关联学生</Text>
+                  <Text className="panel-description">
+                    {selectedStudents.length
+                      ? selectedStudents.map((s) => `${s.className} · ${s.name}`).join('、')
+                      : '识别前请选择学生,支持跨班级多选'}
+                  </Text>
+                </View>
+                <Button className="text-button" onClick={() => setStudentPicker(true)}>
+                  选择学生
+                </Button>
+              </View>
+            )}
+            {kind === 'papers' && (
+              <View className="paper-metadata">
+                <Field label="试卷名称" placeholder="请输入试卷名称" value={name} onChange={setName} />
+                <View className="paper-metadata-row">
+                  <PaperSelect label="年份" value={year} choices={['2026', '2025', '2024', '2023', '2022']} onChange={setYear} />
+                  <PaperSelect label="试卷类型" value={paperType} choices={paperTypes} onChange={setPaperType} />
+                </View>
+                <PaperSelect label="省份" value={province} choices={provinces} onChange={setProvince} />
+              </View>
+            )}
+            {kind === 'answers' && <AnswerGuide />}
+            {kind !== 'answers' && (
+              <View className="capture-zone" onClick={() => void pick(true)}>
+                <Text className="capture-symbol">📸</Text>
+                <Text className="panel-title">拍照</Text>
+                <Text className="panel-description">支持连续拍摄,多页统一识别</Text>
+              </View>
+            )}
+            {kind !== 'answers' ? (
+              <>
+                {!!photos.length && (
+                  <View className="capture-batch">
+                    <Button className="text-button capture-count" onClick={() => setScreen('gallery')}>
+                      已添加 {photos.length} 张 · 查看 ›
+                    </Button>
+                    <Button className="primary-button" onClick={recognize}>识别全部照片</Button>
+                  </View>
+                )}
+                <View className="capture-imports">
+                  <Button className="capture-import-card" onClick={() => void pick(false)}>
+                    <Text className="capture-import-icon">🖼️</Text>
+                    <Text>从相册选择</Text>
+                  </Button>
+                  <Button className="capture-import-card" onClick={() => void importDocument()}>
+                    <Text className="capture-import-icon">📁</Text>
+                    <Text>Word / PDF 文件</Text>
+                  </Button>
+                </View>
+              </>
+            ) : (
+              <>
+                <View className="answer-buttons">
+                  <Button className="secondary-button answer-album-button" onClick={() => void pick(false)}>
+                    🖼 从相册多选
+                  </Button>
+                  <Button className="primary-button answer-camera-button" onClick={() => void pick(true)}>
+                    📸 拍摄答题卡
+                  </Button>
+                </View>
+                {!!photos.length && (
+                  <>
+                    <Button
+                      className="secondary-button"
+                      onClick={() => setScreen('preview')}
+                    >
+                      查看已添加的 {photos.length} 张照片
+                    </Button>
+                    <Actions>
+                      <Button
+                        className="primary-button"
+                        onClick={() => setScreen('preview')}
+                      >
+                        检查答题卡
+                      </Button>
+                    </Actions>
+                  </>
+                )}
+              </>
+            )}
+            {kind !== 'answers' && questions.length > 0 && (
+              <Button className="text-button" onClick={recognize}>
+                返回核对题目
+              </Button>
+            )}
+          </>
+        )}
+        {screen === 'guide' && kind !== 'answers' && (
+          <>
+            <View className="photo-guide-intro">
+              <Text className="photo-guide-title">试卷拍摄规范</Text>
+              <Text className="photo-guide-description">将试卷平放,镜头正对纸面{'\n'}一次拍摄一页,题目与选项完整入镜</Text>
+            </View>
+            <View className="photo-guide-stage">
+              <View className="photo-guide-stage-heading">
+                <Text className="photo-guide-label">试卷拍摄参考</Text>
+                <Text>正对纸面 · 完整拍摄</Text>
+              </View>
+              <View className="photo-guide-frame">
+                <Image className="photo-guide-reference" src={captureReference} mode="aspectFit" />
+                {['tl', 'tr', 'bl', 'br'].map((corner) => <View key={corner} className={`photo-guide-corner ${corner}`} />)}
+              </View>
+              <Text className="photo-guide-caption">{'✓ 四角完整\u3000✓ 题目清晰\u3000✓ 内容无遮挡'}</Text>
+            </View>
+            <View className="photo-guide-rules">
+              <View className="photo-guide-rule"><Text className="photo-guide-rule-title">▣ 拍全纸张</Text><Text>保留四角与页码{'\n'}题干、选项不缺失</Text></View>
+              <View className="photo-guide-rule"><Text className="photo-guide-rule-title">☀ 光线均匀</Text><Text>避免反光和阴影{'\n'}手指不遮挡内容</Text></View>
+              <View className="photo-guide-rule"><Text className="photo-guide-rule-title">▤ 字迹清晰</Text><Text>镜头正对纸面{'\n'}对焦后保持稳定</Text></View>
+            </View>
+            <View className="photo-guide-multipage">
+              <Text className="photo-guide-rule-title">多页试卷怎么拍?</Text>
+              <Text>按页码顺序逐页拍摄,全部拍完后统一识别。{'\n'}跨页题请拍全相关页面,避免遗漏题干或选项。</Text>
+            </View>
+          </>
+        )}
+        {screen === 'guide' && kind === 'answers' && (
+          <>
+            <AnswerGuide />
+            <Actions>
+              <Button className="secondary-button" onClick={() => setScreen(guideReturn)}>
+                返回采集
+              </Button>
+              <Button className="primary-button" onClick={() => void pick(true)}>
+                拍摄
+              </Button>
+            </Actions>
+          </>
+        )}
+        {screen === 'gallery' && (
+          <>
+            <View className="gallery-list">
+              {photos.map((p, i) => (
+                <View className={`gallery-row${p.document ? ' is-document' : ''}`} key={p.id}>
+                  {p.document ? (
+                    <View className="gallery-document-icon"><Text>≡</Text></View>
+                  ) : (
+                    <Image className="gallery-thumbnail" src={photoSource(p, kind)} mode="aspectFit" onClick={() => previewPhoto(p)} />
+                  )}
+                  <View className="gallery-file-copy">
+                    <Text className="gallery-file-name">{i + 1}. {p.name}</Text>
+                    {!p.document && <Button className="text-button gallery-view" onClick={() => previewPhoto(p)}>查看照片</Button>}
+                  </View>
+                  <Button className="text-button gallery-delete" onClick={() => removePhoto(p.id)}>
+                    删除
+                  </Button>
+                </View>
+              ))}
+            </View>
+            <View className="gallery-footer">
+              <Button className="primary-button" onClick={() => setScreen('capture')}>完成</Button>
+            </View>
+          </>
+        )}
+        {screen === 'preview' &&
+          (photo ? (
+            <>
+              <Swipe
+                onSwipe={(direction) => setPhotoIndex((i) => Math.max(0, Math.min(i + direction, photos.length - 1)))}
+              >
+                <Image
+                  className="original-image"
+                  src={photoSource(photo, kind)}
+                  mode="widthFix"
+                  onClick={() =>
+                    void Taro.previewImage({
+                      current: photoSource(photo, kind),
+                      urls: photos.map((p) => photoSource(p, kind))
+                    })
+                  }
+                />
+              </Swipe>
+              <Chips
+                values={photos.map((_, i) => `第 ${i + 1} 张`)}
+                selected={`第 ${photoIndex + 1} 张`}
+                onChange={(value) => setPhotoIndex(Number(value.match(/\d+/)?.[0]) - 1)}
+              />
+              {photo.uploaded && <Text className="answer-content">本张已成功,无需重复提交</Text>}
+              {photo.failure && <Text className="failure-copy">{photo.failure}</Text>}
+              <Actions>
+                <Button
+                  className="secondary-button"
+                  onClick={() => {
+                    setRetakeId(photo.id)
+                    void pick(true, photo.id)
+                  }}
+                >
+                  重新拍摄
+                </Button>
+                <Button className="secondary-button" onClick={() => removePhoto(photo.id)}>
+                  删除本张
+                </Button>
+              </Actions>
+              {retakeId && (
+                <Button className="text-button" onClick={() => addExample()}>
+                  用示例重新拍摄
+                </Button>
+              )}
+              <Text className="panel-title">上传前请确认</Text>
+              <Text className="panel-description">
+                ✓ 纸张四角、作答区域完整入镜{'\n'}✓ 姓名、班级、二维码清晰可辨{'\n'}✓ 字迹清楚,无反光、阴影或遮挡
+              </Text>
+              <Actions>
+                <Button
+                  className="secondary-button"
+                  onClick={() => {
+                    setRetakeId(null)
+                    void pick(true, null)
+                  }}
+                >
+                  继续拍摄
+                </Button>
+                <Button className="primary-button" onClick={() => void submit()}>
+                  确认上传({photos.length} 张)
+                </Button>
+              </Actions>
+              <Button
+                className="text-button"
+                onClick={() => {
+                  setRetakeId(null)
+                  addExample(null)
+                }}
+              >
+                再添加一张示例
+              </Button>
+              <Button className="text-button" onClick={() => void submit(true)}>
+                体验上传失败与重试
+              </Button>
+            </>
+          ) : (
+            <Empty>暂无照片,请返回采集</Empty>
+          ))}
+        {screen === 'review' && current && (
+          <>
+            <View className="panel-card">
+              <Text className="panel-title">{kind === 'papers' ? name : '试卷识别结果'}</Text>
+              <Text className="panel-description">
+                {photos.length} 页 · 已识别 {questions.length} 道题
+                {kind === 'papers' ? ` · ${year}年 · ${paperType} · ${province}` : ''}
+              </Text>
+              {kind === 'mistakes' && (
+                <Button className="text-button" onClick={() => setStudentPicker(true)}>
+                  关联:{selectedStudents.map((s) => `${s.className} ${s.name}`).join('、')}
+                </Button>
+              )}
+              <Swipe onSwipe={goPage}>
+                <View className="review-original">
+                  <Image
+                    className="original-image"
+                    src={photoSource(photos[current.page - 1], kind)}
+                    mode="aspectFit"
+                  />
+                  <View className="split-boxes">
+                    {questions
+                      .filter((q) => q.page === current.page)
+                      .map((q) => (
+                        <Button
+                          className={`split-box ${q.id === current.id ? 'selected' : ''}`}
+                          key={q.id}
+                          onClick={() => setIndex(questions.findIndex((item) => item.id === q.id))}
+                        >
+                          第 {q.num} 题 · {q.type}
+                        </Button>
+                      ))}
+                  </View>
+                </View>
+              </Swipe>
+              <Chips
+                values={photos.map((_, i) => `第 ${i + 1} 页`)}
+                selected={`第 ${current.page} 页`}
+                onChange={(value) => {
+                  const page = Number(value.match(/\d+/)?.[0])
+                  setIndex(questions.findIndex((q) => q.page === page))
+                }}
+              />
+            </View>
+            <View className="number-row">
+              {questions.map((q, i) => (
+                <Button
+                  aria-label={`第 ${q.num} 题`}
+                  className={`number-chip ${i === index ? 'selected' : ''}`}
+                  key={q.id}
+                  onClick={() => goQuestion(i)}
+                >
+                  {q.num}
+                  {q.isWrong ? '•' : ''}
+                </Button>
+              ))}
+            </View>
+            <Swipe onSwipe={(direction) => goQuestion(index + direction)}>
+              <QuestionCard question={current} onChange={changeQuestion} wrong={kind === 'mistakes'} />
+            </Swipe>
+            <View className="panel-card">
+              <Select
+                label="年级"
+                choices={['初一', '初二', '初三']}
+                value={current.grade}
+                onChange={(grade) => changeQuestion({ ...current, grade })}
+              />
+              <Select
+                label="难度"
+                choices={difficulties}
+                value={current.diff}
+                onChange={(diff) => changeQuestion({ ...current, diff })}
+              />
+              <Text className="field-label">知识点(点击移除)</Text>
+              <View className="chip-row">
+                {current.tags.map((tag) => (
+                  <Button
+                    key={tag}
+                    className="chip selected"
+                    onClick={() => changeQuestion({ ...current, tags: current.tags.filter((v) => v !== tag) })}
+                  >
+                    {tag} ×
+                  </Button>
+                ))}
+              </View>
+              <Select
+                label="添加知识点"
+                choices={topics}
+                value=""
+                onChange={(tag) => {
+                  if (!current.tags.includes(tag)) changeQuestion({ ...current, tags: [...current.tags, tag] })
+                }}
+              />
+            </View>
+            <Actions>
+              <Button className="secondary-button" disabled={index === 0} onClick={() => goQuestion(index - 1)}>
+                上一题
+              </Button>
+              <Button
+                className="secondary-button"
+                disabled={index === questions.length - 1}
+                onClick={() => goQuestion(index + 1)}
+              >
+                下一题
+              </Button>
+            </Actions>
+            <Actions>
+              <Button className="secondary-button" onClick={() => setScreen('capture')}>
+                继续拍照
+              </Button>
+              <Button className="primary-button" onClick={() => void submit()}>
+                {kind === 'mistakes' ? `上传错题(${questions.filter((q) => q.isWrong).length} 道)` : '上传试卷'}
+              </Button>
+            </Actions>
+            <Button className="text-button" onClick={() => void submit(true)}>
+              体验上传失败与重试
+            </Button>
+          </>
+        )}
+        {screen === 'failure' && (
+          <>
+            <View className="result-mark">⚠</View>
+            <View className="result-heading">
+              {kind === 'answers' && photos.some((p) => p.failure)
+                ? `${photos.filter((p) => p.failure).length} 张图片识别失败`
+                : '上传失败'}
+            </View>
+            <Text className="result-caption">{failure}</Text>
+            {photos
+              .filter((p) => p.failure)
+              .map((p, i) => (
+                <View className="panel-card" key={p.id}>
+                  <Text className="panel-title">第 {photos.indexOf(p) + 1} 张</Text>
+                  <Image className="photo-thumb" src={photoSource(p, kind)} mode="aspectFit" />
+                  <Text className="failure-copy">{p.failure}</Text>
+                  <Button
+                    className="secondary-button"
+                    onClick={() => {
+                      setPhotoIndex(photos.indexOf(p))
+                      setRetakeId(p.id)
+                      setScreen('preview')
+                    }}
+                  >
+                    重新拍摄{i ? '' : '失败照片'}
+                  </Button>
+                </View>
+              ))}
+            <Actions>
+              <Button className="secondary-button" onClick={home}>
+                回到首页
+              </Button>
+              <Button className="primary-button" onClick={() => setScreen(kind === 'answers' ? 'preview' : 'review')}>
+                返回重试
+              </Button>
+            </Actions>
+          </>
+        )}
+        {screen === 'success' && record && (
+          <>
+            <View className="result-mark">✅</View>
+            <View className="result-heading">
+              {kind === 'papers'
+                ? record.reviewStatus === 'approved'
+                  ? '试卷审核通过'
+                  : '上传成功,待审核'
+                : kind === 'answers'
+                ? '上传成功'
+                : '题目已存入题库'}
+            </View>
+            <Text className="result-caption">
+              {kind === 'answers'
+                ? '解析结果请到客户端的【批量解析】中查看'
+                : kind === 'papers'
+                ? `${record.name} · 共 ${record.photos.length} 页 / ${record.questions.length} 道题。审核通过后将发送通知。`
+                : `已上传 ${record.questions.length} 道错题`}
+            </Text>
+            {kind === 'mistakes' && (
+              <View className="panel-card">
+                <Text className="panel-title">关联学生</Text>
+                <Text className="panel-description">
+                  {record.students.map((s) => `${s.className} · ${s.name}`).join('、')}
+                </Text>
+                {record.questions.map((q) => (
+                  <Text className="question-content" key={q.id}>
+                    第 {q.num} 题 · {q.content}
+                  </Text>
+                ))}
+              </View>
+            )}
+            <Text className="panel-description">提交时间:{formatTime(record.submittedAt)}</Text>
+            <Actions>
+              <Button className="secondary-button" onClick={reset}>
+                继续上传
+              </Button>
+              <Button className="primary-button" onClick={home}>
+                回到首页
+              </Button>
+            </Actions>
+            <Button
+              className="text-button"
+              onClick={() => void Taro.navigateTo({ url: `/pages/messages/index?record=${record.id}` })}
+            >
+              查看上传记录
+            </Button>
+          </>
+        )}
+      </View>
+      {studentPicker && (
+        <StudentPicker
+          selected={selectedStudents}
+          onClose={() => setStudentPicker(false)}
+          onConfirm={(selected) => {
+            setSelectedStudents(selected)
+            setStudentPicker(false)
+          }}
+        />
+      )}
+      {!!busy && (
+        <View className="busy-layer">
+          <View className="busy-box">
+            <Text className="panel-title">{busy}</Text>
+            <Text className="muted-copy">请稍候,保留当前页面</Text>
+            <View className="progress-track">
+              <View className="progress-fill" style={{ width: '75%' }} />
+            </View>
+          </View>
+        </View>
+      )}
+    </Page>
+  )
+}

+ 350 - 0
tests/e2e/flows.spec.ts

@@ -0,0 +1,350 @@
+import { expect, test, type Page } from '@playwright/test'
+import { readFileSync } from 'node:fs'
+
+const active = (page: Page) => page.locator('.taro_page').last()
+async function openEntry(page: Page, title: string) {
+  await page.goto('/')
+  await page.locator('.workbench-card').filter({ hasText: title }).click()
+}
+async function example(page: Page) {
+  if (await page.locator('.mistake-capture-page, .paper-capture-page').count()) {
+    const chooser = page.waitForEvent('filechooser')
+    await page.getByText('从相册选择', { exact: true }).click()
+    await (await chooser).setFiles('src/assets/sample-paper.png')
+    return
+  }
+  if (await page.locator('.answer-capture-page').count()) {
+    const chooser = page.waitForEvent('filechooser')
+    await page.locator('.answer-album-button').click()
+    await (await chooser).setFiles('src/assets/sample-answer.png')
+    return
+  }
+  await page.getByText('添加示例照片', { exact: true }).click()
+}
+async function chooseStudents(page: Page) {
+  await active(page).getByText('选择学生', { exact: true }).click()
+  await page.locator('.student-class-trigger').click()
+  await page.locator('.student-class-option').filter({ hasText: '初一' }).click()
+  await page.locator('.student-names-trigger').click()
+  await page.locator('.student-option').filter({ hasText: '李一' }).click()
+  await page.locator('.student-class-trigger').click()
+  await page.locator('.student-class-option').filter({ hasText: '初二' }).click()
+  await page.locator('.student-names-trigger').click()
+  await page.locator('.student-option').filter({ hasText: '李一' }).click()
+  await page.locator('.student-confirm-button').click()
+}
+
+test('student associations belong to one upload and clear when reopening the page', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await chooseStudents(page)
+  await expect(page.locator('.student-link-copy')).toContainText('初一 · 李一、初二 · 李一')
+  await page.getByText('查看示例', { exact: true }).click()
+  await page.locator('.back-button').click()
+  await expect(page.locator('.student-link-copy')).toContainText('初一 · 李一、初二 · 李一')
+  await page.locator('.back-button').click()
+  await page.locator('.workbench-card').filter({ hasText: '上传错题' }).click()
+  await expect(page.getByText('识别前请选择学生,支持跨班级多选', { exact: true })).toBeVisible()
+  await active(page).getByText('选择学生', { exact: true }).click()
+  await expect(page.locator('.student-selected-tag')).toHaveCount(0)
+  await page.locator('.student-cancel-button').click()
+  await chooseStudents(page)
+  await page.reload()
+  await expect(page.getByText('识别前请选择学生,支持跨班级多选', { exact: true })).toBeVisible()
+  await page.screenshot({ path: `test-results/fresh-upload-${test.info().project.name}.png`, scale: 'css' })
+})
+
+test('photos and documents can be added before students are required at recognition', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  const camera = page.waitForEvent('filechooser')
+  await page.locator('.capture-zone').click()
+  await (await camera).setFiles('src/assets/paper-capture-reference.png')
+  await expect(page.locator('.student-dialog')).toHaveCount(0)
+  const album = page.waitForEvent('filechooser')
+  await page.getByText('从相册选择', { exact: true }).click()
+  await (await album).setFiles('src/assets/sample-paper.png')
+  await expect(page.locator('.student-dialog')).toHaveCount(0)
+  const documents = page.waitForEvent('filechooser')
+  await page.getByText('Word / PDF 文件', { exact: true }).click()
+  await (await documents).setFiles({ name: '练习卷.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF') })
+  await expect(page.locator('.student-dialog')).toHaveCount(0)
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.locator('.student-dialog')).toBeVisible()
+  await expect(page.getByText('请先关联学生', { exact: true })).toHaveCount(0)
+  await expect(page.locator('.question-card')).toHaveCount(0)
+  await page.screenshot({ path: `test-results/recognition-students-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.student-cancel-button').click()
+  await expect(page.getByText('已添加 3 张 · 查看 ›')).toBeVisible()
+  await chooseStudents(page)
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.getByText('3 页 · 已识别 9 道题', { exact: false })).toBeVisible()
+})
+
+test('student dialog supports dropdowns, removable tags and cancelling draft selections', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await page.getByText('选择学生', { exact: true }).click()
+  await page.locator('.student-confirm-button').click()
+  await expect(page.locator('.student-dialog')).toBeVisible()
+  await expect(page.locator('.student-class-trigger')).toHaveText('请选择班级▾')
+  await page.locator('.student-names-trigger').click()
+  await expect(page.locator('.student-class-option.selected')).toHaveCount(0)
+  await expect(page.locator('.student-option')).toHaveCount(0)
+  await page.locator('.student-class-option').filter({ hasText: '初一' }).click()
+  await page.locator('.student-names-trigger').click()
+  await page.locator('.student-option').filter({ hasText: '李一' }).click()
+  await page.locator('.student-option').filter({ hasText: '李二' }).click()
+  await page.locator('.student-names-trigger').click()
+  await expect(page.locator('.student-names-trigger')).toHaveText('李一、李二▾')
+  await expect(page.getByText('已选 2 名学生', { exact: true })).toBeVisible()
+  await page.locator('.student-dialog').screenshot({ path: `test-results/student-dialog-${test.info().project.name}.png`, scale: 'css' })
+  await page.getByLabel('移除初一 李二', { exact: true }).click()
+  await expect(page.locator('.student-selected-tag')).toHaveCount(1)
+  await page.locator('.student-cancel-button').click()
+  await page.getByText('选择学生', { exact: true }).click()
+  await expect(page.locator('.student-selected-tag')).toHaveCount(0)
+  await page.locator('.student-cancel-button').click()
+  await chooseStudents(page)
+  await page.getByText('选择学生', { exact: true }).click()
+  await expect(page.locator('.student-selected-tag')).toHaveCount(2)
+  await expect(page.locator('.student-class-trigger')).toHaveText('初一▾')
+  await expect(page.locator('.student-names-trigger')).toHaveText('李一▾')
+  await page.locator('.student-class-trigger').click()
+  await page.locator('.student-class-option').filter({ hasText: '初二' }).click()
+  await expect(page.locator('.student-names-trigger')).toHaveText('李一▾')
+  await page.locator('.student-cancel-button').click()
+  await expect(page.locator('.student-link-copy')).toContainText('初一 · 李一、初二 · 李一')
+})
+test('reopening student dialog restores the selected class and students', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await page.getByText('选择学生', { exact: true }).click()
+  await expect(page.locator('.student-class-trigger')).toHaveText('请选择班级▾')
+  await page.locator('.student-class-trigger').click()
+  await page.locator('.student-class-option').filter({ hasText: '初二' }).click()
+  await page.locator('.student-names-trigger').click()
+  await page.locator('.student-option').filter({ hasText: '李一' }).click()
+  await page.locator('.student-option').filter({ hasText: '李二' }).click()
+  await page.locator('.student-confirm-button').click()
+  await page.getByText('选择学生', { exact: true }).click()
+  await expect(page.locator('.student-class-trigger')).toHaveText('初二▾')
+  await expect(page.locator('.student-names-trigger')).toHaveText('李一、李二▾')
+  await expect(page.locator('.student-selected-tag')).toHaveCount(2)
+  await page.locator('.student-dialog').screenshot({ path: `test-results/student-restored-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.student-names-trigger').click()
+  await expect(page.locator('.student-option.selected')).toHaveCount(2)
+})
+
+test('mistakes retain edits across pages and retries, and persist linked students', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await chooseStudents(page)
+  await example(page)
+  await example(page)
+  await page.getByText('已添加 2 张 · 查看 ›').click()
+  await expect(page.locator('.gallery-row')).toHaveCount(2)
+  await page.locator('.gallery-footer').getByText('完成', { exact: true }).click()
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.getByText('已识别 6 道题', { exact: false })).toBeVisible()
+  await page.locator('.wrong-toggle').click()
+  await page.locator('.question-card textarea').nth(0).fill('修改后的题干:求二次函数的交点。')
+  await page.locator('.question-card .field-input input').first().fill('自定义选项 A')
+  await page.getByText('下一题', { exact: true }).click()
+  await page.getByText('上一题', { exact: true }).click()
+  await expect(page.locator('.question-card textarea').nth(0)).toHaveValue('修改后的题干:求二次函数的交点。')
+  await page.getByText('继续拍照', { exact: true }).click()
+  await example(page)
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.locator('.question-card textarea').nth(0)).toHaveValue('修改后的题干:求二次函数的交点。')
+  await page.getByText('体验上传失败与重试').click()
+  await expect(page.getByText('本次模拟上传未完成,已填写和识别的内容已保留。')).toBeVisible()
+  await page.getByText('返回重试', { exact: true }).click()
+  await page.getByText('上传错题(1 道)').click()
+  await expect(page.getByText('题目已存入题库')).toBeVisible()
+  await expect(page.getByText('初一 · 李一、初二 · 李一')).toBeVisible()
+  await page.getByText('查看上传记录', { exact: true }).click()
+  await expect(page.getByText('关联学生:初一 李一、初二 李一')).toBeVisible()
+  await page.reload()
+  await expect(page.getByText('修改后的题干:求二次函数的交点。')).toBeVisible()
+  await page.screenshot({ path: `test-results/mistakes-${test.info().project.name}.png`, fullPage: true })
+})
+
+test('mistake capture and photo guide match the reference layout and retain selected photos', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await expect(page.locator('.student-link-bar')).toBeVisible()
+  expect((await page.locator('.student-link-copy').boundingBox())!.width).toBeGreaterThan(180)
+  await expect(page.locator('.capture-import-card')).toHaveCount(2)
+  await expect(page.getByText('识别全部照片', { exact: true })).toHaveCount(0)
+  await page.locator('.mistake-capture-page').screenshot({ path: `test-results/capture-empty-${test.info().project.name}.png`, scale: 'css' })
+  await chooseStudents(page)
+  const chooser = page.waitForEvent('filechooser')
+  await page.getByText('从相册选择', { exact: true }).click()
+  await (await chooser).setFiles('src/assets/sample-paper.png')
+  await expect(page.getByText('已添加 1 张 · 查看 ›')).toBeVisible()
+  await page.locator('.mistake-capture-page').screenshot({ path: `test-results/capture-${test.info().project.name}.png`, scale: 'css' })
+  await page.getByText('查看示例', { exact: true }).click()
+  await expect(page.getByText('试卷拍摄规范', { exact: true })).toBeVisible()
+  await expect(page.locator('.photo-guide-page .primary-button')).toHaveCount(0)
+  await expect(page.locator('.photo-guide-page').getByText('添加示例照片', { exact: true })).toHaveCount(0)
+  await expect(page.locator('.photo-guide-corner')).toHaveCount(4)
+  await expect(page.locator('.photo-guide-rule')).toHaveCount(3)
+  await expect(page.getByText('多页试卷怎么拍?', { exact: true })).toBeVisible()
+  await expect.poll(() => page.locator('.photo-guide-reference img').evaluate((img) => (img as HTMLImageElement).naturalWidth)).toBe(600)
+  await expect.poll(() => active(page).evaluate((element) => element.scrollTop)).toBe(0)
+  expect(await active(page).evaluate((element) => element.scrollWidth <= window.innerWidth)).toBeTruthy()
+  await page.locator('.photo-guide-page').screenshot({ path: `test-results/photo-guide-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.photo-guide-multipage').scrollIntoViewIfNeeded()
+  await expect(page.locator('.photo-guide-multipage')).toBeInViewport()
+  await page.screenshot({ path: `test-results/photo-guide-details-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.back-button').click()
+  await expect(page.getByText('已添加 1 张 · 查看 ›')).toBeVisible()
+  await page.getByText('已添加 1 张 · 查看 ›').click()
+  await expect(page.locator('.gallery-row')).toHaveCount(1)
+})
+
+test('added photos use a mixed file list with preview, deletion, camera and done actions', async ({ page }) => {
+  await openEntry(page, '上传错题')
+  await chooseStudents(page)
+  for (let i = 0; i < 2; i++) {
+    const chooser = page.waitForEvent('filechooser')
+    await page.locator('.capture-zone').click()
+    await (await chooser).setFiles('src/assets/paper-capture-reference.png')
+  }
+  const album = page.waitForEvent('filechooser')
+  await page.getByText('从相册选择', { exact: true }).click()
+  await (await album).setFiles({ name: '初一中考.png', mimeType: 'image/png', buffer: readFileSync('src/assets/sample-paper.png') })
+  const documents = page.waitForEvent('filechooser')
+  await page.getByText('Word / PDF 文件', { exact: true }).click()
+  await (await documents).setFiles({ name: '知了数学-阶段学案.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', buffer: Buffer.from('local demo document') })
+  await page.getByText('已添加 4 张 · 查看 ›').click()
+  await expect(page.locator('.gallery-row')).toHaveCount(4)
+  await expect(page.locator('.gallery-file-name').nth(0)).toHaveText('1. 拍摄照片 1')
+  await expect(page.locator('.gallery-file-name').nth(2)).toHaveText('3. 初一中考.png')
+  await expect(page.locator('.gallery-row.is-document .gallery-view')).toHaveCount(0)
+  await page.screenshot({ path: `test-results/photo-gallery-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.gallery-view').first().click()
+  await expect(page.locator('.preview-image img')).toHaveCount(3)
+  await page.locator('.preview-image').click({ position: { x: 5, y: 5 } })
+  await page.locator('.gallery-row').nth(1).locator('.gallery-delete').click()
+  await expect(page.locator('.gallery-file-name').nth(1)).toHaveText('2. 初一中考.png')
+  const camera = page.waitForEvent('filechooser')
+  await page.locator('.nav-action').getByText('继续拍照', { exact: true }).click()
+  await (await camera).setFiles('src/assets/paper-capture-reference.png')
+  await expect(page.locator('.gallery-row')).toHaveCount(4)
+  await page.locator('.gallery-footer').getByText('完成', { exact: true }).click()
+  await expect(page.getByText('已添加 4 张 · 查看 ›')).toBeVisible()
+})
+
+test('answer cards support multiple photos, partial failure, retake and successful submission', async ({ page }) => {
+  await openEntry(page, '上传答题卡')
+  await expect(page.locator('.answer-capture-page .demo-banner')).toHaveCount(0)
+  await expect(page.locator('.nav-action .text-button')).toHaveCount(0)
+  await expect(page.locator('.answer-rule')).toHaveCount(3)
+  await expect.poll(() => page.locator('.answer-reference img').evaluate((img) => (img as HTMLImageElement).naturalWidth)).toBe(606)
+  await page.locator('.answer-capture-page').screenshot({ path: `test-results/answer-capture-${test.info().project.name}.png`, scale: 'css' })
+  await page.locator('.answer-camera-button').scrollIntoViewIfNeeded()
+  const albumButton = await page.locator('.answer-album-button').boundingBox()
+  const cameraButton = await page.locator('.answer-camera-button').boundingBox()
+  expect(Math.abs(albumButton!.y - cameraButton!.y)).toBeLessThan(1)
+  await page.screenshot({ path: `test-results/answer-buttons-${test.info().project.name}.png`, scale: 'css' })
+  expect(await active(page).evaluate((element) => element.scrollWidth <= window.innerWidth)).toBeTruthy()
+  await example(page)
+  await page.getByText('查看示例', { exact: true }).click()
+  await expect(page.locator('.answer-guide-page .answer-reference')).toBeVisible()
+  await page.locator('.back-button').click()
+  await page.locator('.back-button').click()
+  const camera = page.waitForEvent('filechooser')
+  await page.locator('.answer-camera-button').click()
+  await (await camera).setFiles('src/assets/sample-answer.png')
+  await page.getByText('体验上传失败与重试').click()
+  await expect(page.getByText('1 张图片识别失败')).toBeVisible()
+  await page.getByText('重新拍摄失败照片').click()
+  await page.getByText('用示例重新拍摄').click()
+  await expect(page.getByText('确认上传(2 张)')).toBeVisible()
+  await page.getByText('确认上传(2 张)').click()
+  await expect(page.getByText('解析结果请到客户端的【批量解析】中查看')).toBeVisible()
+  await page.getByText('查看上传记录').click()
+  await expect(page.getByText('已上传 2 张答题卡')).toBeVisible()
+})
+
+test('video playback, unread synchronization and library originals work offline', async ({ page }) => {
+  const errors: string[] = []
+  page.on('pageerror', (e) => errors.push(e.message))
+  await openEntry(page, '题目讲解')
+  await page.getByText('观看讲解 →', { exact: true }).first().click()
+  const video = page.locator('video')
+  await expect(video).toBeVisible()
+  await video.evaluate(async (element) => {
+    const player = element as HTMLVideoElement
+    player.muted = true
+    await player.play()
+  })
+  await expect.poll(() => video.evaluate((element) => (element as HTMLVideoElement).currentTime)).toBeGreaterThan(0.5)
+  await video.evaluate((element) => (element as HTMLVideoElement).pause())
+  await page.screenshot({ path: `test-results/explanation-${test.info().project.name}.png`, fullPage: true })
+  await page.locator('.back-button').click()
+  await expect(
+    page
+      .locator('.panel-card')
+      .filter({ has: page.getByText('函数图像平移', { exact: true }) })
+      .first()
+      .getByText('● 未读')
+  ).toHaveCount(0)
+  await page.locator('.back-button').click()
+  await page.goto('/#/pages/library/index')
+  await page.getByText('数学练习卷(一)', { exact: true }).click()
+  await page.getByText('查看原图', { exact: true }).click()
+  await page.getByText('第 2 页', { exact: true }).click()
+  await expect(page.locator('.original-image')).toBeVisible()
+  await page.screenshot({ path: `test-results/originals-${test.info().project.name}.png`, fullPage: true })
+  expect(await active(page).evaluate((element) => element.scrollWidth <= window.innerWidth)).toBeTruthy()
+  expect(errors).toEqual([])
+})
+
+test('paper metadata, local image and PDF imports, review approval and originals persist', async ({ page }) => {
+  test.setTimeout(60000)
+  await openEntry(page, '上传试卷')
+  const imageChooser = page.waitForEvent('filechooser')
+  await page.getByText('从相册选择', { exact: true }).click()
+  await (await imageChooser).setFiles('src/assets/sample-paper.png')
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.getByText('请输入试卷名称', { exact: true })).toBeVisible()
+  await page.locator('input[placeholder="请输入试卷名称"]').fill('回归测试卷')
+  for (const label of ['年份', '试卷类型', '省份']) {
+    await page
+      .locator('.select-field')
+      .filter({ has: page.getByText(label, { exact: true }) })
+      .click()
+    await page.locator('.weui-picker__action:visible').filter({ hasText: '确定' }).click()
+  }
+  const yearField = await page.locator('.paper-meta-field').filter({ hasText: '年份' }).boundingBox()
+  const typeField = await page.locator('.paper-meta-field').filter({ hasText: '试卷类型' }).boundingBox()
+  expect(Math.abs(yearField!.y - typeField!.y)).toBeLessThan(1)
+  expect(typeField!.x).toBeGreaterThan(yearField!.x + yearField!.width)
+  await expect(page.locator('.paper-capture-page .demo-banner')).toHaveCount(0)
+  await expect(page.locator('.capture-zone').getByText('拍照', { exact: true })).toBeVisible()
+  await page.getByText('查看示例', { exact: true }).click()
+  await page.locator('.back-button').click()
+  await expect(page.locator('input[placeholder="请输入试卷名称"]')).toHaveValue('回归测试卷')
+  await page.getByText('已添加 1 张 · 查看 ›').click()
+  await expect(page.locator('.gallery-row')).toHaveCount(1)
+  await page.locator('.gallery-footer').getByText('完成', { exact: true }).click()
+  await expect(page.getByText('已添加 1 张 · 查看 ›')).toBeVisible()
+  await page.screenshot({ path: `test-results/paper-capture-${test.info().project.name}.png`, fullPage: true, scale: 'css' })
+  expect(await active(page).evaluate((element) => element.scrollWidth <= window.innerWidth)).toBeTruthy()
+  const documentChooser = page.waitForEvent('filechooser')
+  await page.getByText('Word / PDF 文件', { exact: true }).click()
+  await (
+    await documentChooser
+  ).setFiles({ name: '练习卷.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF') })
+  await page.getByText('识别全部照片', { exact: true }).click()
+  await expect(page.getByText('2 页 · 已识别 6 道题', { exact: false })).toBeVisible()
+  await page.screenshot({ path: `test-results/paper-review-${test.info().project.name}.png`, fullPage: true })
+  await page.locator('.primary-button:visible').filter({ hasText: /^上传试卷$/ }).click()
+  await expect(page.getByText('上传成功,待审核')).toBeVisible()
+  await expect(page.getByText('试卷审核通过', { exact: true })).toBeVisible({ timeout: 25000 })
+  await page.getByText('查看上传记录', { exact: true }).click()
+  await page.reload()
+  await expect(page.getByText('审核通过', { exact: true })).toBeVisible()
+  await page.getByText('查看原图', { exact: true }).click()
+  await expect(page.locator('.original-image')).toBeVisible()
+  await expect.poll(() => page.locator('.original-image img').evaluate((img) => (img as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+  await page.getByText('第 2 页', { exact: true }).click()
+  await expect(page.getByText('文档文件,暂不提供原图预览')).toBeVisible()
+})

+ 7 - 8
tests/e2e/workbench.spec.ts

@@ -13,8 +13,8 @@ test('home, local previews and profile work without business API requests', asyn
   await expect(page.locator('.notification-button .bell-icon')).toBeVisible()
   await expect(page.locator('.notification-button .notification-dot')).toBeVisible()
   await page.locator('.notification-button').click()
-  await expect(page.getByText('有 3 条题目讲解待查看')).toBeVisible()
-  await page.locator('.sheet-close').click()
+  await expect(page.getByText('视频讲解已生成').first()).toBeVisible()
+  await page.locator('.taro_page').last().locator('.back-button').click()
   for (const title of ['上传错题', '上传试卷', '上传答题卡', '题目讲解']) {
     await expect(page.getByText(title, { exact: true })).toBeVisible()
   }
@@ -25,13 +25,11 @@ test('home, local previews and profile work without business API requests', asyn
   expect(secondCard).not.toBeNull()
   if (firstCard && secondCard) expect(secondCard.y - firstCard.y - firstCard.height).toBeLessThan(16)
   await page.locator('.workbench-card').filter({ hasText: '上传错题' }).click()
-  await expect(page.getByText('小明(演示) · 七年级')).toBeVisible()
-  await page.getByText('选择演示图片').click()
-  await expect(page.getByText('演示图片已选择')).toBeVisible()
-  await page.getByText('完成演示', { exact: true }).click()
+  await expect(page.getByText('识别前请选择学生,支持跨班级多选')).toBeVisible()
+  await page.locator('.taro_page').last().locator('.back-button').click()
   await page.locator('.workbench-card').filter({ hasText: '题目讲解' }).click()
-  await expect(page.getByText('一元一次方程的解法')).toBeVisible()
-  await page.locator('.sheet-close').click()
+  await expect(page.locator('.taro_page').last().getByText('函数图像平移', { exact: true })).toBeVisible()
+  await page.locator('.taro_page').last().locator('.back-button').click()
   await page.getByRole('link', { name: '我的', exact: true }).click()
   await expect(page.getByText('张老师', { exact: true })).toBeVisible()
   await expect(page.locator('.profile-header').getByText(/开发环境|测试环境|正式环境/)).toHaveCount(0)
@@ -39,6 +37,7 @@ test('home, local previews and profile work without business API requests', asyn
   await expect(page.getByText('10000001', { exact: true })).toBeVisible()
   await page.locator('.sheet-close').click()
   await page.locator('.settings-row').filter({ hasText: '修改密码' }).click()
+  await page.locator('input[placeholder="输入当前演示密码"]').fill('old-demo123')
   await page.locator('input[placeholder="输入至少 8 位演示密码"]').fill('demo12345')
   await page.locator('input[placeholder="再次输入演示密码"]').fill('demo12345')
   await page.getByText('演示确认', { exact: true }).click()

+ 32 - 1
tests/environment.test.mjs

@@ -110,7 +110,12 @@ test('available WeChat build artifacts contain the matching environment and page
   assert.equal(metadata.dataSource, 'local')
   assert.equal(metadata.label, expected.label)
   const app = JSON.parse(readFileSync(`${directory}/app.json`, 'utf8'))
-  assert.deepEqual(app.pages, ['pages/home/index', 'pages/profile/index'])
+  assert.deepEqual(app.pages, ['pages/home/index', 'pages/profile/index', 'pages/upload/index', 'pages/messages/index', 'pages/explanations/index', 'pages/library/index'])
+  for (const page of app.pages) {
+    for (const extension of ['js', 'json', 'wxml', 'wxss']) {
+      assert.ok(existsSync(`${directory}/${page}.${extension}`), `Missing WeChat page artifact: ${page}.${extension}`)
+    }
+  }
   for (const item of app.tabBar.list) {
     assert.ok(existsSync(`${directory}/${item.iconPath}`))
     assert.ok(existsSync(`${directory}/${item.selectedIconPath}`))
@@ -144,3 +149,29 @@ test('available WeChat build artifacts contain the matching environment and page
   assert.equal(config.enableDebug, expected.enableDebug)
   assert.equal(config.apiBaseUrl, expected.apiBaseUrl)
 })
+
+test('WeChat demo media are emitted as package files, never inline video data URLs', {
+  skip: !existsSync(`${outputDirectory('weapp')}/app.json`),
+}, () => {
+  const directory = outputDirectory('weapp')
+  for (const name of ['sample-paper.png', 'sample-answer.png', 'explanation-0.mp4', 'explanation-1.mp4', 'explanation-2.mp4', 'explanation-3.mp4']) {
+    assert.ok(existsSync(`${directory}/assets/${name}`), `Missing package asset: ${name}`)
+  }
+  const page = readFileSync(`${directory}/pages/explanations/index.js`, 'utf8')
+  assert.ok(!page.includes('data:video/'), 'WeChat videos must use a local package path')
+  assert.match(page, /assets\/explanation-0\.mp4/)
+})
+
+test('WeChat text actions remove the native button pseudo-element border with a class selector', {
+  skip: !existsSync(`${outputDirectory('weapp')}/app.json`),
+}, () => {
+  const directory = outputDirectory('weapp')
+  const styles = readdirSync(directory, { recursive: true })
+    .filter((file) => file.endsWith('.wxss'))
+    .map((file) => readFileSync(path.join(directory, file), 'utf8'))
+    .join('\n')
+  // Native components need a class selector; ancestor + button selectors do
+  // not reliably override WeChat's built-in button::after border.
+  // Minified builds normalize ::after to its equivalent :after spelling.
+  assert.match(styles, /\.text-button::?after\s*\{[^}]*border:\s*0(?:[;\s}])/)
+})

+ 91 - 0
tests/workbench.test.mjs

@@ -0,0 +1,91 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import {
+  addRecord,
+  advanceMockResults,
+  createInitialState,
+  formatTime,
+  markRead,
+  recognizePhotos,
+  students,
+  validateUpload
+} from '../src/data/workbench.ts'
+
+const photo = (id) => ({ id, path: '', name: id, demo: true })
+const metadata = { name: '测试卷', year: '2026', paperType: '模拟', province: '浙江省' }
+
+test('appending and deleting photos retains question edits and renumbers pages', () => {
+  const first = recognizePhotos([photo('a'), photo('b')])
+  first[0].content = '修改后的题干'
+  first[0].options[0] = '修改后的选项'
+  first[0].isWrong = true
+  first[0].tags.push('几何')
+  const appended = recognizePhotos([photo('a'), photo('b'), photo('c')], first)
+  assert.equal(appended.length, 9)
+  assert.equal(appended[0].content, '修改后的题干')
+  assert.equal(appended[0].options[0], '修改后的选项')
+  assert.equal(appended[0].isWrong, true)
+  assert.ok(appended[0].tags.includes('几何'))
+  const removed = recognizePhotos([photo('b'), photo('c')], appended)
+  assert.equal(removed.length, 6)
+  assert.equal(removed[0].page, 1)
+  assert.equal(removed[3].page, 2)
+  assert.deepEqual(
+    removed.map((q) => q.num),
+    [1, 2, 3, 4, 5, 6]
+  )
+})
+
+test('required fields and student identity work across different classes with identical names', () => {
+  assert.notEqual(students[0].id, students[3].id)
+  assert.equal(students[0].name, students[3].name)
+  assert.equal(validateUpload('mistakes', [photo('a')], [], metadata), '请先关联学生')
+  assert.equal(validateUpload('mistakes', [photo('a')], [students[0], students[3]], metadata), '')
+  for (const field of ['name', 'year', 'paperType', 'province'])
+    assert.ok(validateUpload('papers', [photo('a')], [], { ...metadata, [field]: '' }))
+  assert.equal(validateUpload('answers', [], [], metadata), '请先添加照片')
+})
+
+test('submission, approval and explanation completion are idempotent and survive serialization', () => {
+  const now = Date.now()
+  let state = createInitialState()
+  const record = {
+    id: 'new-paper',
+    kind: 'papers',
+    ...metadata,
+    photos: [photo('a')],
+    questions: recognizePhotos([photo('a')]),
+    students: [],
+    submittedAt: new Date(now).toISOString(),
+    reviewStatus: 'pending'
+  }
+  state = addRecord(state, record)
+  assert.equal(addRecord(state, record), state)
+  assert.equal(state.records[0].reviewStatus, 'pending')
+  state = JSON.parse(JSON.stringify(state))
+  const complete = advanceMockResults(state, now + 21000)
+  assert.equal(complete.records[0].reviewStatus, 'approved')
+  assert.equal(complete.notices.filter((n) => n.id === 'approved-new-paper').length, 1)
+  assert.equal(
+    complete.explanations.find((v) => v.status === 'waiting'),
+    undefined
+  )
+  assert.equal(advanceMockResults(complete, now + 30000), complete)
+  assert.equal(state.records[0].reviewStatus, 'pending', 'previous state must remain immutable')
+})
+
+test('opening an explanation synchronizes its message badge, while unrelated notices stay unread', () => {
+  const initial = createInitialState()
+  const read = markRead(initial, initial.explanations[0].id)
+  assert.equal(read.explanations[0].unread, false)
+  assert.equal(read.notices[0].unread, false)
+  assert.equal(read.notices[1].unread, true)
+  const all = markRead(read)
+  assert.ok(all.notices.every((n) => !n.unread))
+  assert.ok(all.explanations.every((v) => !v.unread))
+})
+
+test('China time display stays stable across device timezones', () => {
+  assert.equal(formatTime('2026-10-26T01:05:00Z'), '2026/10/26 09:05')
+  assert.equal(formatTime('invalid'), '待同步')
+})

+ 1 - 0
types/global.d.ts

@@ -14,4 +14,5 @@ declare namespace NodeJS {
 }
 
 declare module '*.png' { const source: string; export default source }
+declare module '*.mp4' { const source: string; export default source }
 declare module '*.scss'