Browse Source

feat: add admin production workspace editor

bob 1 tháng trước cách đây
mục cha
commit
866412f336

+ 104 - 0
AGENTS.md

@@ -613,3 +613,107 @@
 - 未确认与风险:尚未用真实平台管理员账号在浏览器完成创建组织、成员调整与积分表单操作;表单修复的公网业务路径仍为部分确认。
 - 后续修改提示:如再次修改组织管理表单,应以真实管理员账号验证成功、错误与刷新后状态,并继续保护生产持久卷。
 - 关联:C-20260923-29;提交 `17b3aff`。
+
+### C-20260924-01|现场演示 HTML 幻灯片与品牌视觉
+
+- 日期:2026-09-24
+- 类型:文档 / 前端
+- 需求与原因:为现有系统制作对外现场演示幻灯片,并加入纯粹星辰 Logo,整体视觉与星痕网站一致。
+- 修改范围:新增 `docs/现场演示-slides.html`,包含 9 页可键盘、按钮和触屏翻页的单文件幻灯片;内嵌现有联系二维码与纯粹星辰官方 SVG Logo;采用主站品牌蓝、宋体标题、浅灰背景及扁平卡片。
+- 保留/新增判断:保留 D-009 的证据边界;成品课程视频需在现场另行播放,讲课录音转写与 AI 整理逐字稿作为制作流程说明,不宣称当前系统已具备一键生成能力。
+- 功能确认状态:部分确认
+- 验证证据:HTML 解析成功,含 9 页、内嵌 Logo 与二维码;JavaScript 语法检查通过,静态检查确认翻页事件与联系方式存在;`git diff --check` 通过。
+- 未确认与风险:本地 `file://` 页面被浏览器工具安全策略阻止,尚未完成桌面及窄屏目视放映;正式演示前须实际打开幻灯片检查排版,并准备已制作课程样片。
+- 后续修改提示:更新对外联系方式或品牌素材时同步检查本文件内嵌内容;增加播放视频应使用授权样片,并验证离线放映路径。
+- 关联:无。
+
+### C-20260924-02|导出现场演示 PDF
+
+- 日期:2026-09-24
+- 类型:文档
+- 需求与原因:将现场演示 HTML 幻灯片导出为可投影、可分享的 PDF。
+- 修改范围:新增 `output/pdf/星痕现场演示.pdf`,共 9 页 16:9 横版;沿用幻灯片的内容、品牌配色、纯粹星辰 Logo 与联系二维码。
+- 保留/新增判断:延续 C-20260924-01;样片播放位置保留提示,PDF 不嵌入视频。
+- 功能确认状态:已确认
+- 验证证据:`pdfinfo` 确认 9 页、960×540 pt;逐页渲染 PNG 并检查九宫格缩略图,中文字体、Logo、二维码和卡片均可见;`pypdf` 确认标题、逐字稿与联系方式可提取。
+- 未确认与风险:未在现场投影设备上检查;视频样片需单独准备。
+- 后续修改提示:若 HTML 文案继续变化,应同步重导 PDF,并逐页渲染核对。
+- 关联:C-20260924-01。
+
+### C-20260924-03|导出可编辑 PPTX 现场演示稿
+
+- 日期:2026-09-24
+- 类型:文档
+- 需求与原因:用户需要一份便于二次修改的 PPTX 版本。
+- 修改范围:新增 `output/pptx/星痕现场演示-可编辑版-v2.pptx`,沿用 9 页 HTML/PDF 的内容、配色与纯粹星辰 Logo;标题、正文、卡片和色块保留为原生可编辑幻灯片元素,Logo 与二维码保留为图片素材。
+- 保留/新增判断:延续 C-20260924-01 和 C-20260924-02 的内容边界;样片需现场另行播放,不在 PPTX 中内嵌视频。
+- 功能确认状态:部分确认
+- 验证证据:PPTX 结构校验与版面几何校验通过,9 页均可被 Artifact Tool 导入;使用项目工作区字体配置通过 LibreOffice 渲染 9 页并目视检查,中文、Logo、二维码与内容均正常;检查幻灯片 XML,关键文案保留为文本元素。
+- 未确认与风险:未在 Microsoft PowerPoint 中实际打开编辑;现场使用的设备需安装中文字体或允许字体替换,否则可能出现换行变化。
+- 后续修改提示:二次编辑后检查第 2 页样片占位与第 9 页联系方式;更换字体时重新检查整套幻灯片的换行和边界。
+- 关联:C-20260924-01、C-20260924-02。
+
+### C-20260924-04|平台制作与成员共用课程内容视图
+
+- 日期:2026-09-24
+- 类型:功能 / 样式
+- 需求与原因:平台管理员接单时看到的课程详情与普通成员提交课程后看到的内容结构不同,双方沟通分集、讲稿和素材容易产生误会;制作操作需要与课程内容分区。
+- 修改范围:新增 `src/components/CourseContentView.tsx`,在成员课程内容页及新平台单课制作页共用分集、讲稿、分集素材、通用备注与素材、数字讲师展示;成员提交后仍可查看课程内容。新增 `src/pages/admin/AdminProductionWorkspace.tsx` 与 `/admin/production-queue/:id` 路由,将接单、联系人、实际积分、按分集上传及删除交付文件、交付说明和结单放入独立制作操作区。制作队列改为简要入口;全部课程增加单课查看入口;调整桌面和窄屏样式。
+- 保留/新增判断:遵守 D-001、D-004、D-005、D-009。共用组件只负责读取和呈现课程内容,管理员操作继续通过已有鉴权后台接口执行;提交后讲师优先展示提交快照。替代 C-20260923-14 中“制作操作必须全部放在队列卡片、不设置平台单课页面”的具体布局判断;保留“队列与单课页面不得重复承载完整制作内容”的目的。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build`、`git diff --check` 通过;用同一份模拟课程数据在本地浏览器打开成员与平台管理员页面,核对分集顺序、讲稿、每集素材、通用备注和讲师资料相同;桌面及 390px 窄屏目视检查,修复窄屏两列挤压问题;管理员制作进度页签可切换,交付区与课程内容视觉分离,未上传文件时结单按钮禁用。
+- 未确认与风险:本地浏览器使用模拟接口和账号,未用真实已登录用户或平台管理员验证接单、积分调整、文件上传/删除、交付扣费及完成后回看;未部署公网。后台既有状态接口允许的流转范围比页面按钮更宽,本次未改动服务端流转规则。原有课程内容编辑视图与共用的提交后只读视图仍由不同组件呈现,需用真实课程核对特殊讲稿和旧讲师数据。
+- 后续修改提示:修改课程详情字段或素材展示时,应同时检查成员与平台共用视图、提交快照和鉴权预览;新增制作操作必须保持在独立区域,并验证错误状态、跨组织访问及积分流水。上线前按 D-009 完成真实账号与公网路径验证。
+- 关联:C-20260923-14、C-20260923-18、C-20260923-19。
+
+### C-20260924-05|制作状态前置并简化数字讲师录音展示
+
+- 日期:2026-09-24
+- 类型:修复 / 样式
+- 需求与原因:用户在真实课程页面指出制作中状态位于全部课程资料之后,打开页面难以先确认当前进度;数字讲师已上传声音时只需试听,无须在课程详情展示文件名和下载入口。
+- 修改范围:`src/pages/CourseWorkspace.tsx` 将制作中状态面板移动到“课程内容”页签的最前面,课程内容仍在其后;`src/components/CourseContentView.tsx` 移除数字讲师录音文件名与下载按钮,保留鉴权试听;`src/styles.css` 清理不再使用的录音文件名样式。共用内容组件的改动同时作用于成员与平台管理员页面。
+- 保留/新增判断:延续 C-20260924-04 的课程内容共用结构;制作中课程优先展示状态,数字讲师声音在课程详情中仅提供试听。素材文件的预览和下载功能不受影响。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build`、`git diff --check` 通过;在本地已登录的真实课程页面目视确认“制作中”卡片位于课程分集内容之前,数字讲师卡片只显示“试听”按钮,页面可访问性树中不再出现录音文件名和录音下载按钮。
+- 未确认与风险:未在真实平台管理员账号中另行点击试听或做公网验证;播放器实现未变,本次只调整其周围的展示。
+- 后续修改提示:若更改共用的数字讲师展示,需同时核对成员与平台管理员页面;状态顺序按课程状态分别检查,勿让制作中提示重新落到长讲稿之后。
+- 关联:C-20260924-04。
+
+### C-20260924-06|平台管理员编辑制作中课程的全部内容与素材
+
+- 日期:2026-09-24
+- 类型:功能 / 数据库
+- 需求与原因:平台管理员接单后需要修改课程资料、分集讲稿和素材,原有成员接口在提交后限制分集增删和文件操作,后台只能查看课程内容与上传交付物。
+- 修改范围:`server/index.ts` 新增仅平台管理员可调用、仅 `IN_PRODUCTION` 可写的课程资料、分集增删改、通用与分集素材上传删除、数字讲师快照同步及本课程专用编辑接口;`server/schema.sql` 新增 `course_instructor_files` 保存本课程专用照片和录音元数据,并接入鉴权文件读取;`src/api.ts`、`src/components/AdminCourseContentEditor.tsx`、`src/pages/admin/AdminProductionWorkspace.tsx`、`src/styles.css` 增加后台编辑模式。课程预览仍使用成员与平台共用的 `CourseContentView`。分集删除前校验至少保留一集及无关联交付文件;增删分集重算预计积分,不改实际积分或已扣流水。
+- 保留/新增判断:遵守 D-001、D-004、D-005、D-007、D-009。平台管理员可修改制作中课程的课程级内容;本课讲师照片和声音修改只写课程快照及本课文件,不改共享讲师资产库。管理员主动“从资产库重新同步”才覆盖本课快照。后台编辑与成员读取同一份课程记录。制作完成后不允许继续编辑。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build`、`git diff --check` 通过;独立 PostgreSQL 测试库从空库启动并重复执行建表脚本通过。实际 API 验证管理员修改课程资料、分集、素材上传,成员刷新读取相同结果;未登录及普通成员无法调用后台编辑接口,其他个人账号无法读取文件;非法分集、唯一分集删除、错误讲师照片 ID、完成课程编辑均被拒绝。测试本课讲师名称、备注、照片和录音替换后成员可读取,资产库原讲师未变;重新同步后本课专用文件元数据被清理。隔离浏览器中核对后台编辑入口、保存回显、讲师编辑和预览,以及成员端显示管理员修改的讲师备注。
+- 未确认与风险:未在生产库或公网验证;未用真实音视频文件检查媒体质量,也未完成窄屏实际目视检查。生产部署前备份数据库与上传目录;若需回退到旧版应用,须同时恢复与该版本对应的数据库和上传文件,否则本课新增的讲师文件旧版鉴权接口无法读取。管理员课程编辑与结单同时发生时,部分分集和素材接口仍可能有状态竞态,后续需通过事务串行化。
+- 后续修改提示:课程快照专用文件必须始终经课程权限鉴权,新增快照替换路径需清理失效的本课文件;修改共享讲师资产不得自动改写历史课程快照。完成制作路径和预计/实际积分关系需一起复核。
+- 关联:C-20260923-05、C-20260924-04。
+
+### C-20260924-07|成员课程详情的各状态提示统一前置
+
+- 日期:2026-09-24
+- 类型:修复 / 样式
+- 需求与原因:用户确认课程详情中只要存在状态提示框,均应在课程内容页签最前面,先说明状态再展示分集和素材。
+- 修改范围:`src/pages/CourseWorkspace.tsx` 将已完成课程的交付提示和等待制作课程的排期提示移到共用课程内容视图之前;此前已前置的制作中提示保留原位。草稿和驳回状态目前没有独立状态提示框,本次未新增文案或状态流程。
+- 保留/新增判断:延续 C-20260924-05,成员课程内容页签中现有的等待制作、制作中、已完成三类状态提示均先于分集和素材。制作进度页签与课程内容视图的数据来源不变。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build`、`git diff --check` 通过;独立测试库中的成员课程分别设为等待制作、制作中、已完成,浏览器可访问性树均显示状态提示标题在“课程分集与素材”之前。制作中课程还核对了讲师备注仍与后台保存结果一致。
+- 未确认与风险:未在真实生产课程或公网逐状态核对;草稿和驳回目前没有独立提示框,若以后新增须遵守前置规则。
+- 后续修改提示:增加或调整任何课程状态提示框时,把它放在课程内容页签的课程分集视图之前,并用对应状态的真实页面核对顺序。
+- 关联:C-20260924-05、C-20260924-06。
+
+### C-20260924-08|平台制作工作区课程内容编辑与共用视图
+
+- 日期:2026-09-24
+- 类型:功能 / 前后端协同
+- 需求与原因:平台管理员制作课程时需要在课程详情中直接编辑提交内容,且成员与制作人员看到一致的课程内容,避免课程信息和制作操作分散。
+- 修改范围:`server/index.ts`、`server/schema.sql` 增加平台管理员制作中课程编辑接口、课程讲师文件鉴权与元数据结构;`src/components/AdminCourseContentEditor.tsx`、`src/components/CourseContentView.tsx`、`src/pages/admin/AdminProductionWorkspace.tsx` 提供编辑器、共用视图和制作工作区;更新 `src/App.tsx`、`src/api.ts`、`src/pages/CourseWorkspace.tsx`、`src/pages/admin/AdminCourses.tsx`、`src/pages/admin/AdminLayout.tsx`、`src/pages/admin/AdminProductionQueue.tsx` 与 `src/styles.css` 接入页面和样式。
+- 保留/新增判断:遵守 D-001、D-004、D-005、D-007、D-009 及 C-20260924-04、C-20260924-06。制作操作仍需管理员鉴权,制作中课程编辑范围及课程讲师快照文件遵循 C-20260924-06。
+- 功能确认状态:部分确认
+- 验证证据:`pnpm run check`、`pnpm run build` 与 `git diff --check` 通过;具体业务路径及数据库/API 验证沿用 C-20260924-06 记录,本次未重复执行端到端验证。
+- 未确认与风险:未在生产库或公网验证本次组合后的代码;课程内容和制作操作仍需真实平台管理员账号端到端验收。
+- 后续修改提示:修改课程编辑、快照文件或制作队列时,继续核对成员与管理员读取同一课程记录、文件鉴权以及编辑与结单并发状态。
+- 关联:C-20260924-04、C-20260924-06。

+ 204 - 1
server/index.ts

@@ -293,13 +293,14 @@ app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) =>
   const digitalFile = await pool.query(`SELECT f.stored_name,d.organization_id,d.personal_user_id FROM digital_instructor_files f JOIN digital_instructors d ON d.id=f.instructor_id WHERE f.stored_name=$1`, [stored])
   if (digitalFile.rows[0]) {
     const file = digitalFile.rows[0]
-    const permitted = file.personal_user_id === userId(req) || (file.organization_id && ((req as any).user.role === 'ADMIN' || (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0]))
+    const permitted = (req as any).user.role === 'ADMIN' || file.personal_user_id === userId(req) || (file.organization_id && (await pool.query('SELECT 1 FROM organization_members WHERE organization_id=$1 AND user_id=$2', [file.organization_id,userId(req)])).rows[0])
     if (!permitted) return reply.code(403).send({ message: '无权访问该文件' })
     return reply.sendFile(stored, uploadDir)
   }
   const { rows } = await pool.query(`SELECT c.user_id,c.organization_id FROM courses c WHERE
     EXISTS(SELECT 1 FROM course_assets a WHERE a.course_id=c.id AND a.stored_name=$1)
     OR EXISTS(SELECT 1 FROM course_deliverables d WHERE d.course_id=c.id AND d.stored_name=$1)
+    OR EXISTS(SELECT 1 FROM course_instructor_files f WHERE f.course_id=c.id AND f.stored_name=$1)
     OR EXISTS(SELECT 1 FROM instructors i WHERE i.course_id=c.id AND i.image_stored_name=$1)
     OR EXISTS(SELECT 1 FROM instructor_images ii JOIN instructors i ON i.id=ii.instructor_id WHERE i.course_id=c.id AND ii.stored_name=$1)
     LIMIT 1`, [stored])
@@ -1084,6 +1085,208 @@ app.get('/api/admin/courses/:id', { preHandler: courseOperator }, async (req, re
   return { course }
 })
 
+// 制作中的课程由平台管理员维护;成员端仍读取同一份课程内容。
+async function productionCourse(id: string, reply: FastifyReply) {
+  const { rows } = await pool.query('SELECT * FROM courses WHERE id=$1', [id])
+  if (!rows[0]) { reply.code(404).send({ message: '课程不存在' }); return null }
+  if (rows[0].status !== 'IN_PRODUCTION') { reply.code(409).send({ message: '只有制作中的课程可以编辑素材' }); return null }
+  return rows[0]
+}
+
+async function pruneCourseInstructorFiles(id: string) {
+  const latest = await pool.query('SELECT digital_instructor_snapshot FROM courses WHERE id=$1', [id])
+  const snapshot = latest.rows[0]?.digital_instructor_snapshot || {}
+  const referenced = new Set([...(snapshot.images || []), ...(snapshot.audio || [])].map((file: any) => file.id))
+  const files = await pool.query('SELECT id,stored_name FROM course_instructor_files WHERE course_id=$1', [id])
+  for (const file of files.rows) if (!referenced.has(file.id)) {
+    await pool.query('DELETE FROM course_instructor_files WHERE id=$1 AND course_id=$2', [file.id,id])
+    await unlink(resolve(uploadDir,file.stored_name)).catch(() => {})
+  }
+}
+
+app.patch('/api/admin/courses/:id/content', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as { id: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const p = z.object({ name: z.string().trim().min(1).max(100), description: z.string().max(500), assetNotes: z.string().max(5000) }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
+  await pool.query('UPDATE courses SET name=$1,description=$2,asset_notes=$3,updated_at=NOW() WHERE id=$4', [p.data.name,p.data.description,p.data.assetNotes,id])
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台更新课程资料', '课程名称、需求说明或通用备注已更新。')
+  return { course: await fullCourse(row, true) }
+})
+
+app.post('/api/admin/courses/:id/instructor-sync', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as { id: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  if (!row.digital_instructor_id) return reply.code(409).send({ message: '课程尚未选择数字讲师' })
+  const instructor = await digitalInstructors.get(row.digital_instructor_id)
+  if (!instructor || (row.organization_id ? instructor.organization_id !== row.organization_id : instructor.personal_user_id !== row.user_id)) {
+    return reply.code(409).send({ message: '课程讲师与所属工作区不一致' })
+  }
+  if (!await readyDigitalInstructor(instructor.id)) return reply.code(409).send({ message: '讲师照片和录音尚未齐备' })
+  const snapshot = await digitalInstructors.dto(instructor)
+  await pool.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
+  await pruneCourseInstructorFiles(id)
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台同步数字讲师资料', '本课程的数字讲师快照已更新。')
+  return { course: await fullCourse(row, true) }
+})
+
+app.post('/api/admin/courses/:id/instructor', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as { id: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  if (!row.digital_instructor_snapshot) return reply.code(409).send({ message: '课程没有可编辑的数字讲师快照' })
+  const fields: Record<string,string> = {}
+  const pending: Array<{ id: string; kind: 'IMAGE' | 'AUDIO'; original: string; stored: string; size: number; mime: string }> = []
+  let committed = false
+  try {
+    for await (const part of req.parts()) {
+      if (part.type === 'field') { fields[part.fieldname] = String(part.value); continue }
+      const kind = part.fieldname === 'images' ? 'IMAGE' : part.fieldname === 'audio' ? 'AUDIO' : null
+      const mime = part.mimetype.toLowerCase()
+      if (!kind || !(kind === 'IMAGE' ? ['image/jpeg','image/png','image/webp'].includes(mime) : ['audio/wav','audio/x-wav','audio/mpeg','audio/mp4','audio/x-m4a','audio/webm','audio/ogg'].includes(mime))) throw new Error('照片或录音格式不支持')
+      if (kind === 'AUDIO' && pending.some((file) => file.kind === 'AUDIO')) throw new Error('一次只能上传一份录音')
+      const buffer = await part.toBuffer()
+      if (!buffer.length || buffer.length > (kind === 'IMAGE' ? 20 : 50) * 1024 * 1024) throw new Error('照片或录音为空或超过大小限制')
+      const stored = `course-instructor-${id}-${randomUUID()}${extname(part.filename).slice(0,10).toLowerCase()}`
+      await writeFile(resolve(uploadDir,stored),buffer)
+      pending.push({ id:randomUUID(),kind,original:part.filename.slice(0,255),stored,size:buffer.length,mime })
+    }
+    const parsed = z.object({ name: z.string().trim().min(1).max(80), note: z.string().max(1000), removeImageIds: z.string().default('[]') }).safeParse(fields)
+    if (!parsed.success) throw new Error(parsed.error.issues[0]?.message || '请检查讲师资料')
+    const removeIds: unknown = JSON.parse(parsed.data.removeImageIds)
+    if (!Array.isArray(removeIds) || removeIds.some((value) => typeof value !== 'string')) throw new Error('照片移除参数不正确')
+    const client = await pool.connect()
+    let snapshot: any
+    try {
+      await client.query('BEGIN')
+      const current = await client.query('SELECT status,digital_instructor_snapshot FROM courses WHERE id=$1 FOR UPDATE', [id])
+      if (current.rows[0]?.status !== 'IN_PRODUCTION') throw new Error('只有制作中的课程可以编辑讲师')
+      snapshot = current.rows[0].digital_instructor_snapshot
+      if (!snapshot) throw new Error('课程没有可编辑的数字讲师快照')
+      const originalImages = snapshot.images || []
+      if (removeIds.some((value) => !originalImages.some((image: any) => image.id === value))) throw new Error('要移除的照片不存在')
+      const images = [...originalImages.filter((image: any) => !removeIds.includes(image.id)), ...pending.filter((file) => file.kind === 'IMAGE').map((file) => ({ id:file.id,name:file.original,url:`/uploads/${file.stored}` }))]
+      const newAudio = pending.find((file) => file.kind === 'AUDIO')
+      const audio = newAudio ? [{ id:newAudio.id,name:newAudio.original,url:`/uploads/${newAudio.stored}` }] : (snapshot.audio || [])
+      if (!images.length || !audio.length || images.length > 19) throw new Error('请保留 1 至 19 张照片和一份录音')
+      snapshot = { ...snapshot, name:parsed.data.name, note:parsed.data.note, images, audio, ready:true }
+      for (const file of pending) await client.query('INSERT INTO course_instructor_files(id,course_id,kind,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [file.id,id,file.kind,file.original,file.stored,file.size,file.mime])
+      await client.query('UPDATE courses SET digital_instructor_snapshot=$1,updated_at=NOW() WHERE id=$2', [JSON.stringify(snapshot),id])
+      await client.query('COMMIT')
+      committed = true
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    await pruneCourseInstructorFiles(id)
+    await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改本课数字讲师', '本课程的数字讲师资料或素材已更新。')
+    return { course: await fullCourse(row,true) }
+  } catch (error) {
+    return reply.code(400).send({ message:(error as Error).message || '讲师资料保存失败' })
+  } finally {
+    if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
+  }
+})
+
+app.post('/api/admin/courses/:id/episodes', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as { id: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500).default(''), lectureNotes: z.string().default('') }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
+  const max = await pool.query('SELECT COALESCE(MAX(episode_number),0)::int AS value FROM episodes WHERE course_id=$1', [id])
+  await pool.query('INSERT INTO episodes(id,course_id,episode_number,title,summary,lecture_notes) VALUES($1,$2,$3,$4,$5,$6)', [randomUUID(),id,max.rows[0].value+1,p.data.title,p.data.summary,p.data.lectureNotes])
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台新增课程分集', '制作中的课程增加了分集。')
+  return reply.code(201).send({ course: await fullCourse(row, true) })
+})
+
+app.patch('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
+  const { id, episodeId } = req.params as { id: string; episodeId: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const p = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().max(500), lectureNotes: z.string() }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查分集信息' })
+  const richPrefix = '<!--xinghen-richtext-->'
+  const notes = p.data.lectureNotes.startsWith(richPrefix) ? richPrefix + sanitizeHtml(p.data.lectureNotes.slice(richPrefix.length), {
+    allowedTags: ['p','div','br','strong','b','em','i','ul','ol','li'], allowedAttributes: {}
+  }) : p.data.lectureNotes
+  const updated = await pool.query('UPDATE episodes SET title=$1,summary=$2,lecture_notes=$3,updated_at=NOW() WHERE id=$4 AND course_id=$5 RETURNING id', [p.data.title,p.data.summary,notes,episodeId,id])
+  if (!updated.rows[0]) return reply.code(404).send({ message: '分集不存在' })
+  await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台修改课程分集', '制作中的分集标题、简介或讲稿已更新。')
+  return { course: await fullCourse(row, true) }
+})
+
+app.delete('/api/admin/courses/:id/episodes/:episodeId', { preHandler: adminAuth }, async (req, reply) => {
+  const { id, episodeId } = req.params as { id: string; episodeId: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const existing = await pool.query('SELECT id FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
+  if (!existing.rows[0]) return reply.code(404).send({ message: '分集不存在' })
+  const count = await pool.query('SELECT COUNT(*)::int AS value FROM episodes WHERE course_id=$1', [id])
+  if (count.rows[0].value <= 1) return reply.code(409).send({ message: '课程至少保留一个分集' })
+  const delivered = await pool.query('SELECT 1 FROM course_deliverables WHERE course_id=$1 AND episode_id=$2 LIMIT 1', [id,episodeId])
+  if (delivered.rows[0]) return reply.code(409).send({ message: '该集已有交付文件,请先处理交付文件' })
+  const assets = await pool.query('SELECT stored_name FROM course_assets WHERE course_id=$1 AND episode_id=$2', [id,episodeId])
+  await pool.query('DELETE FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])
+  for (const asset of assets.rows) {
+    await unlink(resolve(uploadDir,asset.stored_name)).catch(() => {})
+    await unlink(resolve(uploadDir,'.previews',asset.stored_name+'.pdf')).catch(() => {})
+    await unlink(resolve(uploadDir,'.previews',`${asset.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
+  }
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程分集', '制作中的课程删除了一个分集及其素材。')
+  return { course: await fullCourse(row, true) }
+})
+
+app.post('/api/admin/courses/:id/assets', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as { id: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const pending: Array<{ original: string; stored: string; size: number; mime: string }> = []
+  let episodeId: string | null = null
+  let committed = false
+  try {
+    for await (const part of req.parts()) {
+      if (part.type === 'file') {
+        const stored = `${id}-${randomUUID()}${extname(part.filename).slice(0,16)}`
+        await pipeline(part.file,createWriteStream(resolve(uploadDir,stored)))
+        if (part.file.truncated) { await unlink(resolve(uploadDir,stored)).catch(() => {}); return reply.code(413).send({ message: '文件超过大小限制' }) }
+        pending.push({ original: part.filename, stored, size: part.file.bytesRead, mime: part.mimetype })
+      } else if (part.fieldname === 'episodeId' && part.value) episodeId = String(part.value)
+    }
+    if (!pending.length) return reply.code(400).send({ message: '请选择课程素材文件' })
+    if (episodeId && !(await pool.query('SELECT 1 FROM episodes WHERE id=$1 AND course_id=$2', [episodeId,id])).rows[0]) return reply.code(400).send({ message: '分集不存在' })
+    const client = await pool.connect()
+    try {
+      await client.query('BEGIN')
+      for (const file of pending) await client.query('INSERT INTO course_assets(id,course_id,episode_id,original_name,stored_name,size,mime_type) VALUES($1,$2,$3,$4,$5,$6,$7)', [randomUUID(),id,episodeId,file.original,file.stored,file.size,file.mime])
+      await client.query('COMMIT')
+      committed = true
+    } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+    await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
+    await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台上传课程素材', `新增 ${pending.length} 份课程素材。`)
+    return { course: await fullCourse(row, true) }
+  } finally {
+    if (!committed) for (const file of pending) await unlink(resolve(uploadDir,file.stored)).catch(() => {})
+  }
+})
+
+app.delete('/api/admin/courses/:id/assets/:assetId', { preHandler: adminAuth }, async (req, reply) => {
+  const { id, assetId } = req.params as { id: string; assetId: string }
+  const row = await productionCourse(id, reply)
+  if (!row) return
+  const deleted = await pool.query('DELETE FROM course_assets WHERE id=$1 AND course_id=$2 RETURNING stored_name', [assetId,id])
+  if (!deleted.rows[0]) return reply.code(404).send({ message: '素材不存在' })
+  const stored = deleted.rows[0].stored_name
+  await unlink(resolve(uploadDir,stored)).catch(() => {})
+  await unlink(resolve(uploadDir,'.previews',stored+'.pdf')).catch(() => {})
+  await unlink(resolve(uploadDir,'.previews',`${stored}.${previewCacheVersion}.pdf`)).catch(() => {})
+  await pool.query('UPDATE courses SET updated_at=NOW() WHERE id=$1', [id])
+  await addProgressEvent(id, 'ADMIN', 'CONTENT_UPDATED', '平台删除课程素材', '制作中的课程移除了一份素材。')
+  return { course: await fullCourse(row, true) }
+})
+
 // 4. 修改课程状态(例如接单转为 IN_PRODUCTION 或其他流转)
 app.post('/api/admin/courses/:id/status', { preHandler: courseOperator }, async (req, reply) => {
   const { id } = req.params as any

+ 13 - 0
server/schema.sql

@@ -203,6 +203,19 @@ CREATE TABLE IF NOT EXISTS course_assets (
   mime_type VARCHAR(150) NOT NULL DEFAULT 'application/octet-stream',
   created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
 );
+
+-- 平台制作阶段对单门课程的讲师快照补充的照片与声音,不影响讲师资产库。
+CREATE TABLE IF NOT EXISTS course_instructor_files (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  kind TEXT NOT NULL CHECK (kind IN ('IMAGE', 'AUDIO')),
+  original_name TEXT NOT NULL,
+  stored_name TEXT NOT NULL UNIQUE,
+  size BIGINT NOT NULL,
+  mime_type TEXT NOT NULL,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_course_instructor_files_course ON course_instructor_files(course_id);
 ALTER TABLE course_assets ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE CASCADE;
 CREATE INDEX IF NOT EXISTS idx_assets_course ON course_assets(course_id, created_at);
 CREATE INDEX IF NOT EXISTS idx_assets_episode ON course_assets(episode_id, created_at);

+ 2 - 0
src/App.tsx

@@ -11,6 +11,7 @@ import { AdminLayout } from './pages/admin/AdminLayout'
 import { AdminDashboard } from './pages/admin/AdminDashboard'
 import { AdminCourses } from './pages/admin/AdminCourses'
 import { AdminProductionQueue } from './pages/admin/AdminProductionQueue'
+import { AdminProductionWorkspace } from './pages/admin/AdminProductionWorkspace'
 import { AdminUsers } from './pages/admin/AdminUsers'
 import { AdminLoginPage } from './pages/admin/AdminLoginPage'
 import { AdminOrganizations, AdminOrganizationDetail } from './pages/admin/AdminOrganizations'
@@ -52,6 +53,7 @@ export default function App() {
         <Route index element={<AdminDashboard />} />
         <Route path="courses" element={<AdminCourses />} />
         <Route path="production-queue" element={<AdminProductionQueue />} />
+        <Route path="production-queue/:id" element={<AdminProductionWorkspace />} />
         <Route path="users" element={<AdminUsers />} />
         <Route path="organizations" element={<AdminOrganizations />} />
         <Route path="digital-instructors" element={<AdminDigitalInstructors />} />

+ 26 - 0
src/api.ts

@@ -276,6 +276,32 @@ export const api = {
       return request<{ courses: Course[]; total: number }>(`/admin/courses${qs}`)
     },
     getCourse: (id: string) => request<{ course: Course }>(`/admin/courses/${id}`),
+    updateCourseContent: (id: string, data: { name: string; description: string; assetNotes: string }) =>
+      request<{ course: Course }>(`/admin/courses/${id}/content`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
+    syncCourseInstructor: (id: string) => request<{ course: Course }>(`/admin/courses/${id}/instructor-sync`, { method: 'POST' }),
+    updateCourseInstructor: (id: string, data: { name: string; note: string; removeImageIds: string[]; images: File[]; audio: File | null }) => {
+      const form = new FormData()
+      form.append('name', data.name)
+      form.append('note', data.note)
+      form.append('removeImageIds', JSON.stringify(data.removeImageIds))
+      data.images.forEach((file) => form.append('images', file))
+      if (data.audio) form.append('audio', data.audio)
+      return request<{ course: Course }>(`/admin/courses/${id}/instructor`, { method: 'POST', body: form })
+    },
+    createCourseEpisode: (id: string, data: { title: string; summary: string; lectureNotes: string }) =>
+      request<{ course: Course }>(`/admin/courses/${id}/episodes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
+    updateCourseEpisode: (id: string, episodeId: string, data: { title: string; summary: string; lectureNotes: string }) =>
+      request<{ course: Course }>(`/admin/courses/${id}/episodes/${episodeId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
+    deleteCourseEpisode: (id: string, episodeId: string) =>
+      request<{ course: Course }>(`/admin/courses/${id}/episodes/${episodeId}`, { method: 'DELETE' }),
+    uploadCourseAssets: (id: string, files: File[], episodeId?: string) => {
+      const form = new FormData()
+      files.forEach((file) => form.append('assets', file))
+      if (episodeId) form.append('episodeId', episodeId)
+      return request<{ course: Course }>(`/admin/courses/${id}/assets`, { method: 'POST', body: form })
+    },
+    deleteCourseAsset: (id: string, assetId: string) =>
+      request<{ course: Course }>(`/admin/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),
     updateStatus: (id: string, status: CourseStatus) =>
       request<{ course: Course }>(`/admin/courses/${id}/status`, {
         method: 'POST',

+ 100 - 0
src/components/AdminCourseContentEditor.tsx

@@ -0,0 +1,100 @@
+import { useState } from 'react'
+import { api } from '../api'
+import type { Course, CourseAsset, Episode } from '../types'
+import { InstructorVoicePlayer } from './InstructorVoicePlayer'
+import { LectureNotesDialog } from './LectureNotesDialog'
+import { ProtectedImage } from './ProtectedImage'
+
+type Save = (action: () => Promise<{ course: Course }>, message: string) => Promise<boolean>
+
+function AssetEditor({ course, episodeId, busy, save }: { course: Course; episodeId?: string; busy: boolean; save: Save }) {
+  const [files, setFiles] = useState<File[]>([])
+  const assets = course.assets.filter((asset) => (asset.episodeId || undefined) === episodeId)
+  const remove = (asset: CourseAsset) => {
+    if (window.confirm(`删除素材「${asset.originalName}」?`)) void save(() => api.admin.deleteCourseAsset(course.id, asset.id), '课程素材已删除')
+  }
+  return <div className="admin-content-assets">
+    <strong>{episodeId ? '本集素材' : '通用素材'}({assets.length})</strong>
+    {assets.map((asset) => <div className="admin-production-file" key={asset.id}><span>{asset.originalName}</span><button type="button" disabled={busy} onClick={() => remove(asset)}>删除</button></div>)}
+    <label>添加素材<input type="file" multiple disabled={busy} onChange={(event) => { setFiles(Array.from(event.target.files || [])); event.target.value = '' }} /></label>
+    {!!files.length && <button type="button" className="secondary-button" disabled={busy} onClick={() => void save(() => api.admin.uploadCourseAssets(course.id, files, episodeId), '课程素材已上传').then((ok) => { if (ok) setFiles([]) })}>上传 {files.length} 份素材</button>}
+  </div>
+}
+
+function EpisodeEditor({ course, episode, busy, save }: { course: Course; episode: Episode; busy: boolean; save: Save }) {
+  const [title, setTitle] = useState(episode.title)
+  const [summary, setSummary] = useState(episode.summary)
+  const [notesOpen, setNotesOpen] = useState(false)
+  const [notesBusy, setNotesBusy] = useState(false)
+  const changed = title.trim() !== episode.title || summary !== episode.summary
+  const update = (lectureNotes: string) => api.admin.updateCourseEpisode(course.id, episode.id, { title: title.trim(), summary, lectureNotes })
+  return <section className="paper-form admin-production-action-card admin-content-episode">
+    <div className="admin-content-episode-head"><h3>EP {String(episode.episodeNumber).padStart(2, '0')}</h3><button type="button" disabled={busy || course.episodes.length <= 1} onClick={() => { if (window.confirm(`删除「${episode.title}」及本集素材?`)) void save(() => api.admin.deleteCourseEpisode(course.id, episode.id), '分集已删除') }}>删除分集</button></div>
+    <label>分集标题<input value={title} maxLength={200} onChange={(event) => setTitle(event.target.value)} /></label>
+    <label>分集简介<textarea value={summary} maxLength={500} onChange={(event) => setSummary(event.target.value)} /></label>
+    <button type="button" className="secondary-button" disabled={busy || !changed || !title.trim()} onClick={() => void save(() => update(episode.lectureNotes), '分集信息已保存')}>保存分集信息</button>
+    <div className="admin-content-notes"><span>口播稿:{episode.lectureNotes?.trim() ? '已填写' : '未填写'}</span><button type="button" className="secondary-button" disabled={busy} onClick={() => setNotesOpen(true)}>编辑口播稿</button></div>
+    <AssetEditor course={course} episodeId={episode.id} busy={busy} save={save} />
+    {notesOpen && <LectureNotesDialog title={episode.title} initialValue={episode.lectureNotes || ''} saving={notesBusy} onClose={() => setNotesOpen(false)} onSave={async (value) => { setNotesBusy(true); try { const ok = await save(() => update(value), '口播稿已保存'); if (ok) setNotesOpen(false) } finally { setNotesBusy(false) } }} />}
+  </section>
+}
+
+function InstructorEditor({ course, busy, save }: { course: Course; busy: boolean; save: Save }) {
+  const instructor = course.digitalInstructorSnapshot
+  const [name, setName] = useState(instructor?.name || '')
+  const [note, setNote] = useState(instructor?.note || '')
+  const [removeIds, setRemoveIds] = useState<string[]>([])
+  const [images, setImages] = useState<File[]>([])
+  const [audio, setAudio] = useState<File | null>(null)
+  if (!instructor) return <section className="paper-form admin-production-action-card"><h3>数字讲师</h3><p>暂无数字讲师快照</p></section>
+  const kept = instructor.images.filter((image) => !removeIds.includes(image.id))
+  const changed = name.trim() !== instructor.name || note !== instructor.note || removeIds.length > 0 || images.length > 0 || !!audio
+  return <section className="paper-form admin-production-action-card">
+    <h3>本课程的数字讲师</h3>
+    <p>此处修改只影响本课程展示。同步资产库会重新取用资产库中的照片、声音和资料。</p>
+    <label>讲师名称<input value={name} maxLength={80} onChange={(event) => setName(event.target.value)} /></label>
+    <label>备注<textarea value={note} maxLength={1000} onChange={(event) => setNote(event.target.value)} /></label>
+    <strong>照片(保留 {kept.length} 张,新增 {images.length} 张)</strong>
+    <div className="admin-instructor-images">{instructor.images.map((image) => <div key={image.id} className={removeIds.includes(image.id) ? 'removed' : ''}><ProtectedImage src={image.url} alt={`${instructor.name} 照片`} /><button type="button" disabled={busy} onClick={() => setRemoveIds((current) => current.includes(image.id) ? current.filter((id) => id !== image.id) : [...current,image.id])}>{removeIds.includes(image.id) ? '保留' : '移除'}</button></div>)}</div>
+    <label>追加照片<input type="file" accept="image/jpeg,image/png,image/webp" multiple disabled={busy} onChange={(event) => { setImages(Array.from(event.target.files || [])); event.target.value = '' }} /></label>
+    {!!images.length && <p>已选择 {images.length} 张新照片</p>}
+    {!!instructor.audio.length && <InstructorVoicePlayer src={instructor.audio[0].url} title={`${instructor.name} 录音试听`} />}
+    <label>替换声音<input type="file" accept="audio/wav,audio/mpeg,audio/mp4,audio/webm,audio/ogg" disabled={busy} onChange={(event) => { setAudio(event.target.files?.[0] || null); event.target.value = '' }} /></label>
+    {audio && <p>已选择 {audio.name}</p>}
+    <button type="button" className="secondary-button" disabled={busy || !changed || !name.trim() || kept.length + images.length < 1 || kept.length + images.length > 19} onClick={() => void save(() => api.admin.updateCourseInstructor(course.id, { name:name.trim(), note, removeImageIds:removeIds, images, audio }), '本课程数字讲师资料已保存')}>保存本课讲师资料</button>
+    {course.digitalInstructorId && <button type="button" className="secondary-button" disabled={busy} onClick={() => { if (window.confirm('用资产库当前资料覆盖本课程的讲师修改?')) void save(() => api.admin.syncCourseInstructor(course.id), '数字讲师资料已同步到本课程') }}>从资产库重新同步</button>}
+  </section>
+}
+
+export function AdminCourseContentEditor({ course, busy, save }: { course: Course; busy: boolean; save: Save }) {
+  const [name, setName] = useState(course.name)
+  const [description, setDescription] = useState(course.description)
+  const [assetNotes, setAssetNotes] = useState(course.assetNotes || '')
+  const [newTitle, setNewTitle] = useState('')
+  const [newSummary, setNewSummary] = useState('')
+  const changed = name.trim() !== course.name || description !== course.description || assetNotes !== (course.assetNotes || '')
+  return <section className="admin-content-editor" aria-label="编辑课程素材">
+    <div className="admin-content-editor-head"><div><h2>编辑课程内容与素材</h2><p>保存后,成员的课程详情会显示同一份最新内容。</p></div></div>
+    <div className="admin-content-editor-grid">
+      <section className="paper-form admin-production-action-card">
+        <h3>课程资料</h3>
+        <label>课程名称<input value={name} maxLength={100} onChange={(event) => setName(event.target.value)} /></label>
+        <label>需求说明<textarea value={description} maxLength={500} onChange={(event) => setDescription(event.target.value)} /></label>
+        <label>通用素材备注<textarea value={assetNotes} maxLength={5000} onChange={(event) => setAssetNotes(event.target.value)} /></label>
+        <button type="button" className="secondary-button" disabled={busy || !changed || !name.trim()} onClick={() => void save(() => api.admin.updateCourseContent(course.id, { name: name.trim(), description, assetNotes }), '课程资料已保存')}>保存课程资料</button>
+      </section>
+      <InstructorEditor course={course} busy={busy} save={save} />
+      <section className="paper-form admin-production-action-card">
+        <h3>通用素材</h3>
+        <AssetEditor course={course} busy={busy} save={save} />
+      </section>
+      <section className="paper-form admin-production-action-card">
+        <h3>新增分集</h3>
+        <label>分集标题<input value={newTitle} maxLength={200} onChange={(event) => setNewTitle(event.target.value)} placeholder="输入分集标题" /></label>
+        <label>分集简介<textarea value={newSummary} maxLength={500} onChange={(event) => setNewSummary(event.target.value)} /></label>
+        <button type="button" className="secondary-button" disabled={busy || !newTitle.trim()} onClick={() => void save(() => api.admin.createCourseEpisode(course.id, { title: newTitle.trim(), summary: newSummary, lectureNotes: '' }), '分集已新增').then((ok) => { if (ok) { setNewTitle(''); setNewSummary('') } })}>添加分集</button>
+      </section>
+    </div>
+    <div className="admin-content-episodes">{course.episodes.map((episode) => <EpisodeEditor key={episode.id} course={course} episode={episode} busy={busy} save={save} />)}</div>
+  </section>
+}

+ 88 - 0
src/components/CourseContentView.tsx

@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import { ChevronDown, ChevronRight, Download, FileText, Layers } from 'lucide-react'
+import { downloadPrivateFile } from '../api'
+import { canPreviewAsset, CourseAssetPreview } from './CourseAssetPreview'
+import { InstructorVoicePlayer } from './InstructorVoicePlayer'
+import { ProtectedImage } from './ProtectedImage'
+import { notesHtml, notesText } from '../utils/lectureNotes'
+import type { Course, CourseAsset } from '../types'
+
+/** Submitted course content is intentionally identical for its owner and the production team. */
+export function CourseContentView({ course }: { course: Course }) {
+  const [expanded, setExpanded] = useState<Record<string, boolean>>({})
+  const [previewAsset, setPreviewAsset] = useState<CourseAsset | null>(null)
+  const [error, setError] = useState('')
+  const globalAssets = course.assets.filter((asset) => !asset.episodeId)
+  const instructor = course.digitalInstructorSnapshot
+  const legacyInstructor = !instructor ? course.instructors[0] : null
+  const instructorName = instructor?.name || legacyInstructor?.name
+  const instructorNote = instructor?.note || legacyInstructor?.note || legacyInstructor?.introduction
+  const photos = instructor?.images?.map((photo) => ({ key: photo.id, url: photo.url })) || legacyInstructor?.imageUrls?.map((url) => ({ key: url, url })) || []
+  const audio = instructor?.audio?.map((file) => ({ key: file.id, url: file.url })) || legacyInstructor?.audioUrls?.map((url) => ({ key: url, url })) || []
+  const showDownload = (url: string, name: string) => {
+    void downloadPrivateFile(url, name).catch((cause) => setError((cause as Error).message))
+  }
+
+  const assetRow = (asset: CourseAsset) => <div className="shared-course-asset" key={asset.id}>
+    <FileText size={17} aria-hidden="true" />
+    <span title={asset.originalName}>{asset.originalName}</span>
+    {canPreviewAsset(asset) && <button type="button" onClick={() => setPreviewAsset(asset)}>预览</button>}
+    <button type="button" onClick={() => showDownload(asset.url, asset.originalName)}><Download size={14} /> 下载</button>
+  </div>
+
+  return <div className="workspace-grid shared-course-view">
+    <main className="episodes-workspace-main">
+      {error && <div className="notice notice-error" role="alert">{error}</div>}
+      <section className="paper-form episodes-header-panel">
+        <div className="panel-heading-row">
+          <div>
+            <h2>课程分集与素材</h2>
+            {course.description && <p className="panel-intro">{course.description}</p>}
+          </div>
+        </div>
+        <div className="episodes-metrics-bar">
+          <div className="metric-item"><span className="metric-num">{course.episodes.length}</span><small>课程分集</small></div>
+          <div className="metric-item"><span className="metric-num">{course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length} / {course.episodes.length}</span><small>已填写讲稿</small></div>
+          <div className="metric-item"><span className="metric-num">{course.assets.length}</span><small>素材文件</small></div>
+        </div>
+      </section>
+
+      <section className="episodes-list-container" aria-label="课程分集">
+        {course.episodes.map((episode, index) => {
+          const isExpanded = expanded[episode.id] ?? index < 2
+          const assets = course.assets.filter((asset) => asset.episodeId === episode.id)
+          return <article className={`episode-card ${isExpanded ? 'expanded' : 'collapsed'}`} key={episode.id}>
+            <button type="button" className="episode-card-header shared-episode-toggle" aria-expanded={isExpanded} onClick={() => setExpanded((current) => ({ ...current, [episode.id]: !isExpanded }))}>
+              <span className="header-left"><span className="ep-toggle-arrow">{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</span><span className="ep-number-tag">EP {String(episode.episodeNumber).padStart(2, '0')}</span><strong className="ep-title">{episode.title}</strong></span>
+              <span className="header-right"><span className="tag-pill tag-pill-success">讲稿 {notesText(episode.lectureNotes || '').length} 字</span><span className="tag-pill tag-pill-info">{assets.length} 份素材</span></span>
+            </button>
+            {isExpanded && <div className="episode-card-body">
+              {episode.summary && <p className="shared-episode-summary">{episode.summary}</p>}
+              <div className="ep-body-grid">
+                <section className="ep-assets-box"><div className="box-title-row"><strong><Layers size={16} /> 本集素材</strong></div>{assets.length ? assets.map(assetRow) : <p className="empty-sub-tip">本集暂无素材</p>}</section>
+                <section className="ep-notes-box"><div className="box-title-row"><strong><FileText size={16} /> 口播稿</strong></div>{episode.lectureNotes?.trim() ? <div className="lecture-preview shared-lecture-content" dangerouslySetInnerHTML={{ __html: notesHtml(episode.lectureNotes) }} /> : <p className="empty-sub-tip">本集暂无讲稿</p>}</section>
+              </div>
+            </div>}
+          </article>
+        })}
+      </section>
+
+      <section className="paper-form global-assets-panel">
+        <div className="panel-heading-row"><div><h3>通用素材 ({globalAssets.length})</h3><p className="panel-intro">适用于全套课程的附件与说明</p></div></div>
+        {course.assetNotes && <div className="asset-notes-card"><strong>通用备注</strong><p className="asset-notes-text">{course.assetNotes}</p></div>}
+        {globalAssets.length ? globalAssets.map(assetRow) : <p className="empty-sub-tip">暂无通用素材</p>}
+      </section>
+    </main>
+
+    <aside className="instructor-panel workspace-sidebar">
+      <div className="aside-title"><h3>数字讲师</h3></div>
+      {instructorName ? <div className="instructor-card shared-instructor-card">
+        <div className="shared-instructor-photos">{photos.map((photo) => <ProtectedImage key={photo.key} src={photo.url} alt={`${instructorName} 照片`} />)}</div>
+        <strong className="instructor-name">{instructorName}</strong>
+        {instructorNote && <p className="instructor-note">{instructorNote}</p>}
+        {audio.map((file, index) => <div className="shared-instructor-audio" key={file.key}><InstructorVoicePlayer src={file.url} title={`${instructorName} 录音${audio.length > 1 ? ` ${index + 1}` : ''}试听`} /></div>)}
+      </div> : <p className="empty-sub-tip">暂无数字讲师资料</p>}
+    </aside>
+    {previewAsset && <CourseAssetPreview key={previewAsset.id} asset={previewAsset} courseId={course.id} onClose={() => setPreviewAsset(null)} />}
+  </div>
+}

+ 46 - 44
src/pages/CourseWorkspace.tsx

@@ -36,6 +36,7 @@ import { DeleteCourseModal } from '../components/DeleteCourseModal'
 import { ContactModal } from '../components/ContactModal'
 import { EditCourseModal } from '../components/EditCourseModal'
 import { CourseProductionProgress } from '../components/CourseProductionProgress'
+import { CourseContentView } from '../components/CourseContentView'
 import { LectureNotesDialog } from '../components/LectureNotesDialog'
 import { notesHtml, notesText } from '../utils/lectureNotes'
 import contactQrCode from '../assets/contact-group-qrcode.jpg'
@@ -353,6 +354,49 @@ export function CourseWorkspace() {
         </div>
       ) : (
         <div className="course-content-tab-panel" role="tabpanel">
+      {/* 正在制作中面板 */}
+      {isProducing && (
+        <section className="success-panel producing-panel">
+          <div className="producing-orbit-icon">
+            <LoaderCircle className="spin" size={48} />
+          </div>
+          <h2>课程正在精心制作中</h2>
+          <p>
+            平台课程制作师已接单并正在处理您的 {episodes.length > 0 ? `${episodes.length} 个分集讲稿与课件素材` : '课件素材'},制作完成后将第一时间在此处按集交付成品。
+          </p>
+          <div className="receipt">
+            <span>
+              <small>课程状态</small>
+              <strong style={{ color: '#0875bd' }}>制作中</strong>
+            </span>
+            <span>
+              <small>课程分集</small>
+              <strong>{episodes.length} 集</strong>
+            </span>
+            <span>
+              <small>素材文件</small>
+              <strong>{course.assets.length} 份</strong>
+            </span>
+            <span>
+              <small>提交时间</small>
+              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
+            </span>
+          </div>
+          <div className="success-panel-actions">
+            <Link className="secondary-button" to="/">
+              返回课程列表
+            </Link>
+            <button
+              type="button"
+              className="secondary-button"
+              onClick={() => setShowContactModal(true)}
+            >
+              <MessageCircle size={15} />
+              联系星痕
+            </button>
+          </div>
+        </section>
+      )}
 
       {/* 制作完成交付成果面板 */}
       {isCompleted && (
@@ -463,50 +507,6 @@ export function CourseWorkspace() {
         </section>
       )}
 
-      {/* 正在制作中面板 */}
-      {isProducing && (
-        <section className="success-panel producing-panel">
-          <div className="producing-orbit-icon">
-            <LoaderCircle className="spin" size={48} />
-          </div>
-          <h2>课程正在精心制作中</h2>
-          <p>
-            平台课程制作师已接单并正在处理您的 {episodes.length > 0 ? `${episodes.length} 个分集讲稿与课件素材` : '课件素材'},制作完成后将第一时间在此处按集交付成品。
-          </p>
-          <div className="receipt">
-            <span>
-              <small>课程状态</small>
-              <strong style={{ color: '#0875bd' }}>制作中</strong>
-            </span>
-            <span>
-              <small>课程分集</small>
-              <strong>{episodes.length} 集</strong>
-            </span>
-            <span>
-              <small>素材文件</small>
-              <strong>{course.assets.length} 份</strong>
-            </span>
-            <span>
-              <small>提交时间</small>
-              <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
-            </span>
-          </div>
-          <div className="success-panel-actions">
-            <Link className="secondary-button" to="/">
-              返回课程列表
-            </Link>
-            <button
-              type="button"
-              className="secondary-button"
-              onClick={() => setShowContactModal(true)}
-            >
-              <MessageCircle size={15} />
-              联系星痕
-            </button>
-          </div>
-        </section>
-      )}
-
       {/* 等待制作面板 */}
       {isWaiting && !isEditingWaitingCourse && (
         <section className="success-panel">
@@ -606,6 +606,8 @@ export function CourseWorkspace() {
         </section>
       )}
 
+      {!canEditCourseContent && <CourseContentView course={course} />}
+
       {/* 草稿或等待制作课程的分集管理、素材上传与讲师编辑 */}
       {canEditCourseContent && (
         <>

+ 3 - 0
src/pages/admin/AdminCourses.tsx

@@ -12,6 +12,7 @@ import {
   Users,
 } from 'lucide-react'
 import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
 import { api } from '../../api'
 import { StatusBadge } from '../../components/StatusBadge'
 import { formatDate, formatDateTime } from '../../utils/date'
@@ -128,6 +129,7 @@ export function AdminCourses() {
                 <th>当前状态</th>
                 <th>创建 / 更新时间</th>
                 <th>提交 / 交付时间</th>
+                <th>详情</th>
               </tr>
             </thead>
             <tbody>
@@ -205,6 +207,7 @@ export function AdminCourses() {
                       )}
                     </div>
                   </td>
+                  <td><Link className="secondary-button small-button" to={`/admin/production-queue/${course.id}`}>查看课程</Link></td>
                 </tr>
               ))}
             </tbody>

+ 1 - 1
src/pages/admin/AdminLayout.tsx

@@ -51,7 +51,7 @@ export function AdminLayout() {
             <span>全部课程</span>
           </NavLink>
           <NavLink to="/admin/production-queue" className={({ isActive }) => (isActive ? 'active' : '')}>
-            <Clock3 size={16} /><span>待制作课程</span>
+            <Clock3 size={16} /><span>课程制作</span>
           </NavLink>
           <NavLink to="/admin/users" className={({ isActive }) => (isActive ? 'active' : '')}>
             <Users size={16} />

+ 16 - 67
src/pages/admin/AdminProductionQueue.tsx

@@ -1,9 +1,9 @@
-import { Clock3, Download, FileText, RefreshCw, Search, UserRound } from 'lucide-react'
+import { Clock3, FileText, RefreshCw, Search, UserRound } from 'lucide-react'
 import { useEffect, useState } from 'react'
-import { api, downloadPrivateFile } from '../../api'
-import { ProtectedImage } from '../../components/ProtectedImage'
+import { Link } from 'react-router-dom'
+import { api } from '../../api'
 import { formatDateTime } from '../../utils/date'
-import { notesHtml, notesText } from '../../utils/lectureNotes'
+import { notesText } from '../../utils/lectureNotes'
 import type { Course } from '../../types'
 
 export function AdminProductionQueue() {
@@ -13,19 +13,14 @@ export function AdminProductionQueue() {
   const [keyword, setKeyword] = useState('')
   const [query, setQuery] = useState('')
   const [status, setStatus] = useState<'WAITING_PRODUCTION' | 'IN_PRODUCTION'>('WAITING_PRODUCTION')
-  const [busyId, setBusyId] = useState('')
-  const [notice, setNotice] = useState('')
-  const [files, setFiles] = useState<Record<string, File[]>>({})
-  const [notes, setNotes] = useState<Record<string, string>>({})
-  const [points, setPoints] = useState<Record<string, string>>({})
 
   useEffect(() => {
     let active = true
     setLoading(true)
     setError('')
     api.admin.listCourses({ status, keyword: query })
-      .then(({ courses }) => { if (active) setCourses(courses) })
-      .catch((err) => { if (active) setError(err.message) })
+      .then(({ courses: next }) => { if (active) setCourses(next) })
+      .catch((cause) => { if (active) setError((cause as Error).message) })
       .finally(() => { if (active) setLoading(false) })
     return () => { active = false }
   }, [query, status])
@@ -34,72 +29,26 @@ export function AdminProductionQueue() {
     setLoading(true)
     setError('')
     api.admin.listCourses({ status, keyword: query })
-      .then(({ courses }) => setCourses(courses))
-      .catch((err) => setError(err.message))
+      .then(({ courses: next }) => setCourses(next))
+      .catch((cause) => setError((cause as Error).message))
       .finally(() => setLoading(false))
   }
 
-  const run = async (id: string, action: () => Promise<unknown>, message: string) => {
-    setBusyId(id)
-    setError('')
-    setNotice('')
-    try { await action(); setNotice(message); refresh() }
-    catch (err) { setError((err as Error).message) }
-    finally { setBusyId('') }
-  }
-
   return <div className="page admin-page production-queue-page">
     <div className="admin-header-row">
-      <div><h2>待制作课程</h2><p className="admin-subtitle">团队制作准备清单 · 查看已提交课程的资料、素材和数字讲师</p></div>
+      <div><h2>课程制作队列</h2><p className="admin-subtitle">选择课程后,在与成员一致的课程内容视图中核对资料并制作。</p></div>
       <button type="button" className="secondary-button" onClick={refresh}><RefreshCw size={15} /> 刷新队列</button>
     </div>
     <div className="production-queue-toolbar">
       <div className="production-queue-tabs"><button type="button" className={status === 'WAITING_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('WAITING_PRODUCTION')}>待制作</button><button type="button" className={status === 'IN_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('IN_PRODUCTION')}>制作中</button><strong><Clock3 size={17} /> {loading ? '…' : courses.length} 门</strong></div>
-      <form onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()) }}>
-        <Search size={16} /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程、分类或提交人" aria-label="搜索待制作课程" />
-        <button type="submit" className="secondary-button">搜索</button>
-      </form>
+      <form onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()) }}><Search size={16} /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程或提交人" aria-label="搜索制作队列" /><button type="submit" className="secondary-button">搜索</button></form>
     </div>
     {error && <div className="notice notice-error" role="alert">{error}</div>}
-    {notice && <div className="notice notice-success" role="status">{notice}</div>}
-    {loading ? <div className="center-loading">正在读取待制作课程…</div> : !error && courses.length === 0 ?
-      <div className="empty-state"><Clock3 size={32} /><h3>{query ? '没有匹配的课程' : status === 'WAITING_PRODUCTION' ? '当前没有等待制作的课程' : '当前没有制作中的课程'}</h3><p>{query ? '可以调整搜索词或清空搜索。' : '课程状态变化后,这里的列表会随之更新。'}</p></div> :
-      <div className="production-queue-list">{courses.map((course) => {
-        const instructor = course.digitalInstructorSnapshot
-        const notesCount = course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length
-        return <article className="production-queue-card" key={course.id}>
-          <div className="production-queue-card-head">
-            <div><span className="production-queue-index">等待制作</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
-          </div>
-          <div className="production-queue-meta">
-            <span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.creator?.phone || '无联系电话'} · {course.organizationName || '个人工作区'}</span>
-            <span><Clock3 size={14} /> 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</span>
-            <span><FileText size={14} /> {course.episodes.length} 集 · {notesCount} 份讲稿 · {course.assets.length} 份课程素材</span>
-          </div>
-          <div className="production-queue-grid">
-            <section><h4>课程资料与分集</h4>{course.episodes.length ? <ul>{course.episodes.map((episode) => <li key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong><span>{episode.summary || '未填写分集简介'} · {notesText(episode.lectureNotes || '').trim() ? `${notesText(episode.lectureNotes).length} 字讲稿` : '暂无讲稿'}</span></li>)}</ul> : <p>暂无分集资料</p>}</section>
-            <section><h4>上传素材({course.assets.length})</h4>{course.assets.length ? <ul>{course.assets.map((asset) => <li key={asset.id}><span>{asset.originalName}</span><button type="button" className="production-file-action" onClick={() => downloadPrivateFile(asset.url, asset.originalName)} aria-label={`下载 ${asset.originalName}`}><Download size={14} /> 下载</button></li>)}</ul> : <p>暂无上传素材</p>}</section>
-            <section><h4>数字讲师</h4>{instructor ? <div className="production-instructor"><div className="production-instructor-photos">{instructor.images?.map((photo) => <ProtectedImage key={photo.id} src={photo.url} alt={`${instructor.name} 照片`} />)}</div><strong>{instructor.name}</strong><p>{instructor.note || '无备注'}</p><small>照片 {instructor.images?.length || 0} 张 · 录音 {instructor.audio?.length || 0} 份</small>{instructor.audio?.map((audio) => <button key={audio.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(audio.url, audio.name)}><Download size={14} /> {audio.name}</button>)}</div> : <p>未保存数字讲师快照,请进入详情核对历史讲师资料。</p>}</section>
-          </div>
-          <details className="production-queue-more"><summary>查看完整制作资料</summary>
-            <div className="production-queue-more-content">
-              <div><h4>提交人资料</h4><p>姓名:{course.creator?.contactName || '未填写'}</p><p>电话:{course.creator?.phone || '未填写'}</p><p>微信:{course.creator?.wechat || '未填写'}</p></div>
-              <div><h4>分集讲稿</h4>{course.episodes.map((episode) => <section key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong>{episode.lectureNotes ? <div dangerouslySetInnerHTML={{ __html: notesHtml(episode.lectureNotes) }} /> : <p>未填写讲稿</p>}</section>)}</div>
-            </div>
-          </details>
-          <div className="production-queue-actions">
-            {status === 'WAITING_PRODUCTION' ? <button type="button" className="primary-button" disabled={busyId === course.id} onClick={() => run(course.id, () => api.admin.updateStatus(course.id, 'IN_PRODUCTION'), '已接单,课程进入制作中')}>接单并开始制作</button> : <>
-              <label>上传交付文件<input type="file" multiple onChange={(event) => setFiles((current) => ({ ...current, [course.id]: Array.from(event.target.files || []) }))} /></label>
-              <button type="button" className="secondary-button" disabled={busyId === course.id || !(files[course.id]?.length)} onClick={() => run(course.id, () => api.admin.uploadDeliverables(course.id, files[course.id]), '交付文件已上传')}>上传所选文件</button>
-              {!!course.deliverables?.length && <div className="production-deliverables">{course.deliverables.map((file) => <button key={file.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(file.url, file.originalName)}><Download size={14} /> {file.originalName}</button>)}</div>}
-              <label>实际积分<input type="number" min="1" max="100000" value={points[course.id] ?? String(course.actualPoints ?? course.estimatedPoints)} onChange={(event) => setPoints((current) => ({ ...current, [course.id]: event.target.value }))} /></label>
-              <button type="button" className="secondary-button" disabled={busyId === course.id || !Number.isInteger(Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints)) || Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints) < 1} onClick={() => run(course.id, () => api.admin.updatePoints(course.id, Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints), '制作团队调整实际消耗'), '实际积分已更新')}>保存积分</button>
-              <label>交付说明<textarea value={notes[course.id] || ''} onChange={(event) => setNotes((current) => ({ ...current, [course.id]: event.target.value }))} placeholder="填写交付说明" /></label>
-              <button type="button" className="primary-button" disabled={busyId === course.id || !course.deliverables?.length} onClick={() => { if (window.confirm(`确认完成并交付「${course.name}」?`)) run(course.id, () => api.admin.completeCourse(course.id, notes[course.id] || ''), '课程已完成并交付') }}>确认完成并交付</button>
-              <span>已上传 {course.deliverables?.length || 0} 份交付文件</span>
-            </>}
-          </div>
-        </article>
-      })}</div>}
+    {loading ? <div className="center-loading">正在读取制作队列…</div> : !error && courses.length === 0 ?
+      <div className="empty-state"><Clock3 size={32} /><h3>{query ? '没有匹配的课程' : status === 'WAITING_PRODUCTION' ? '当前没有等待制作的课程' : '当前没有制作中的课程'}</h3></div> :
+      <div className="production-queue-list">{courses.map((course) => <article className="production-queue-card" key={course.id}>
+        <div className="production-queue-card-head"><div><span className="production-queue-index">{course.status === 'WAITING_PRODUCTION' ? '等待制作' : '制作中'}</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div><Link className="primary-button" to={`/admin/production-queue/${course.id}`}>查看课程与制作</Link></div>
+        <div className="production-queue-meta"><span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.organizationName || '个人工作区'}</span><span><Clock3 size={14} /> 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</span><span><FileText size={14} /> {course.episodes.length} 集 · {course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length} 份讲稿 · {course.assets.length} 份素材</span></div>
+      </article>)}</div>}
   </div>
 }

+ 144 - 0
src/pages/admin/AdminProductionWorkspace.tsx

@@ -0,0 +1,144 @@
+import { useEffect, useState } from 'react'
+import { ArrowLeft, Download, Pencil, RefreshCw } from 'lucide-react'
+import { Link, useParams } from 'react-router-dom'
+import { api, downloadPrivateFile } from '../../api'
+import { CourseContentView } from '../../components/CourseContentView'
+import { AdminCourseContentEditor } from '../../components/AdminCourseContentEditor'
+import { CourseProductionProgress } from '../../components/CourseProductionProgress'
+import { StatusBadge } from '../../components/StatusBadge'
+import { formatDateTime } from '../../utils/date'
+import type { Course, CourseDeliverable } from '../../types'
+
+export function AdminProductionWorkspace() {
+  const { id = '' } = useParams()
+  const [course, setCourse] = useState<Course | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+  const [notice, setNotice] = useState('')
+  const [tab, setTab] = useState<'content' | 'progress'>('content')
+  const [editingContent, setEditingContent] = useState(false)
+  const [files, setFiles] = useState<File[]>([])
+  const [episodeId, setEpisodeId] = useState('')
+  const [points, setPoints] = useState('')
+  const [reason, setReason] = useState('根据实际制作需求调整')
+  const [productionNotes, setProductionNotes] = useState('')
+
+  const load = () => {
+    setLoading(true)
+    setError('')
+    api.admin.getCourse(id)
+      .then(({ course: next }) => {
+        setCourse(next)
+        setPoints(String(next.actualPoints ?? next.estimatedPoints))
+        setProductionNotes(next.productionNotes || '')
+      })
+      .catch((cause) => setError((cause as Error).message))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(load, [id])
+
+  const run = async (action: () => Promise<{ course: Course }>, message: string) => {
+    setBusy(true)
+    setError('')
+    setNotice('')
+    try {
+      const { course: next } = await action()
+      setCourse(next)
+      setPoints(String(next.actualPoints ?? next.estimatedPoints))
+      setProductionNotes(next.productionNotes || productionNotes)
+      setNotice(message)
+      return true
+    } catch (cause) {
+      setError((cause as Error).message)
+      return false
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  const download = (file: CourseDeliverable) => {
+    void downloadPrivateFile(file.url, file.originalName).catch((cause) => setError((cause as Error).message))
+  }
+
+  if (loading) return <div className="center-loading">正在读取课程…</div>
+  if (!course) return <div className="page admin-page"><Link to="/admin/production-queue" className="back-link"><ArrowLeft size={16} /> 返回制作队列</Link><div className="notice notice-error" role="alert">{error || '课程不存在'}</div></div>
+
+  const isWaiting = course.status === 'WAITING_PRODUCTION'
+  const isProducing = course.status === 'IN_PRODUCTION'
+  const isCompleted = course.status === 'COMPLETED'
+  const validPoints = Number.isInteger(Number(points)) && Number(points) >= 1 && Number(points) <= 100000
+
+  return <div className="page admin-page admin-production-workspace">
+    <div className="workspace-head">
+      <div>
+        <Link to="/admin/production-queue" className="back-link"><ArrowLeft size={16} /> 返回制作队列</Link>
+        <div className="title-row"><h1>{course.name}</h1><StatusBadge status={course.status} /></div>
+        <p className="admin-production-meta">{course.organizationName || '个人工作区'} · 提交人 {course.creator?.contactName || '未填写'} · 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</p>
+      </div>
+      <div className="admin-production-head-actions"><a href="#production-actions-title" className="secondary-button">前往制作操作</a><button type="button" className="secondary-button" onClick={load} disabled={busy}><RefreshCw size={15} /> 刷新</button></div>
+    </div>
+
+    {error && <div className="notice notice-error" role="alert">{error}</div>}
+    {notice && <div className="notice notice-success" role="status">{notice}</div>}
+
+    <div className="course-workspace-tabs" role="tablist" aria-label="课程详情视图">
+      <button type="button" role="tab" aria-selected={tab === 'content'} className={tab === 'content' ? 'active' : ''} onClick={() => setTab('content')}>课程内容</button>
+      <button type="button" role="tab" aria-selected={tab === 'progress'} className={tab === 'progress' ? 'active' : ''} onClick={() => setTab('progress')}>制作进度 {!!course.progressEvents.length && <span>{course.progressEvents.length}</span>}</button>
+    </div>
+    {tab === 'content' ? <div role="tabpanel">
+      {isProducing && <div className="admin-content-edit-toggle"><button type="button" className="secondary-button" onClick={() => setEditingContent(!editingContent)}><Pencil size={15} /> {editingContent ? '返回课程预览' : '编辑课程素材'}</button></div>}
+      {isProducing && editingContent ? <AdminCourseContentEditor key={course.updatedAt} course={course} busy={busy} save={run} /> : <CourseContentView course={course} />}
+    </div> : <div className="course-progress-tab-panel" role="tabpanel"><CourseProductionProgress course={course} /></div>}
+
+    <section className="admin-production-actions" aria-labelledby="production-actions-title">
+      <div className="admin-production-actions-head"><div><h2 id="production-actions-title">平台制作操作</h2><p>以下操作仅供平台制作团队使用,课程内容与成员看到的保持一致。</p></div></div>
+      <div className="admin-production-actions-grid">
+        <section className="paper-form admin-production-action-card">
+          <h3>接单与联系</h3>
+          <p>联系人:{course.creator?.contactName || '未填写'} 电话:{course.creator?.phone || '未填写'} 微信:{course.creator?.wechat || '未填写'}</p>
+          {isWaiting && <button type="button" className="primary-button" disabled={busy} onClick={() => void run(() => api.admin.updateStatus(id, 'IN_PRODUCTION'), '课程已进入制作中')}>接单并开始制作</button>}
+          {isProducing && <p className="admin-production-state">课程正在制作中</p>}
+          {isCompleted && <p className="admin-production-state">课程已完成交付</p>}
+          {!isWaiting && !isProducing && !isCompleted && <p className="admin-production-state">当前状态无制作操作</p>}
+        </section>
+
+        <section className="paper-form admin-production-action-card">
+          <h3>实际积分</h3>
+          <p>预计 {course.estimatedPoints.toLocaleString('zh-CN')} 积分 · {course.pointsCharged ? '已扣费' : '交付时扣费'}</p>
+          {isProducing ? <>
+            <label>实际消耗<input type="number" min="1" max="100000" value={points} onChange={(event) => setPoints(event.target.value)} /></label>
+            <label>调整原因<input value={reason} onChange={(event) => setReason(event.target.value)} maxLength={500} /></label>
+            <button type="button" className="secondary-button" disabled={busy || !validPoints} onClick={() => void run(() => api.admin.updatePoints(id, Number(points), reason), '实际积分已保存')}>保存积分</button>
+          </> : <strong>{(course.actualPoints ?? course.estimatedPoints).toLocaleString('zh-CN')} 积分</strong>}
+        </section>
+
+        <section className="paper-form admin-production-action-card admin-production-delivery">
+          <h3>交付文件</h3>
+          {course.episodes.map((episode) => {
+            const delivered = course.deliverables?.filter((file) => file.episodeId === episode.id) || []
+            return delivered.length ? <div className="admin-production-file-group" key={episode.id}><h4>EP {String(episode.episodeNumber).padStart(2, '0')} · {episode.title}</h4>{delivered.map((file) => <div className="admin-production-file" key={file.id}><span>{file.originalName}</span><button type="button" onClick={() => download(file)}><Download size={14} /> 下载</button>{isProducing && <button type="button" disabled={busy} onClick={() => { if (window.confirm(`删除交付文件「${file.originalName}」?`)) void run(() => api.admin.deleteDeliverable(id, file.id), '交付文件已删除') }}>删除</button>}</div>)}</div> : null
+          })}
+          {!!course.deliverables?.filter((file) => !file.episodeId).length && <div className="admin-production-file-group"><h4>全套课程通用交付物</h4>{course.deliverables.filter((file) => !file.episodeId).map((file) => <div className="admin-production-file" key={file.id}><span>{file.originalName}</span><button type="button" onClick={() => download(file)}><Download size={14} /> 下载</button>{isProducing && <button type="button" disabled={busy} onClick={() => { if (window.confirm(`删除交付文件「${file.originalName}」?`)) void run(() => api.admin.deleteDeliverable(id, file.id), '交付文件已删除') }}>删除</button>}</div>)}</div>}
+          {!course.deliverables?.length && <p>尚未上传交付文件</p>}
+          {isProducing && <>
+            <label>归属分集<select value={episodeId} onChange={(event) => setEpisodeId(event.target.value)}><option value="">全套课程通用</option>{course.episodes.map((episode) => <option value={episode.id} key={episode.id}>EP {String(episode.episodeNumber).padStart(2, '0')} · {episode.title}</option>)}</select></label>
+            <label>选择交付文件<input type="file" multiple onChange={(event) => { setFiles(Array.from(event.target.files || [])); event.target.value = '' }} /></label>
+            {!!files.length && <p>已选择 {files.length} 份文件</p>}
+            <button type="button" className="secondary-button" disabled={busy || !files.length} onClick={() => void run(() => api.admin.uploadDeliverables(id, files, episodeId || undefined), '交付文件已上传').then((ok) => { if (ok) setFiles([]) })}>上传所选文件</button>
+          </>}
+        </section>
+
+        <section className="paper-form admin-production-action-card">
+          <h3>完成交付</h3>
+          {isProducing ? <>
+            <label>交付说明<textarea value={productionNotes} onChange={(event) => setProductionNotes(event.target.value)} maxLength={2000} placeholder="向成员说明交付内容" /></label>
+            <p>将交付 {course.deliverables?.length || 0} 份文件,并从{course.organizationName || '个人工作区'}扣除 {(course.actualPoints ?? course.estimatedPoints).toLocaleString('zh-CN')} 积分。</p>
+            <button type="button" className="primary-button" disabled={busy || !course.deliverables?.length} onClick={() => { if (window.confirm(`确认完成并交付「${course.name}」?`)) void run(() => api.admin.completeCourse(id, productionNotes), '课程已完成交付') }}>确认完成并交付</button>
+          </> : <p>{course.productionNotes || (isCompleted ? '没有填写交付说明' : '接单后可填写交付说明')}</p>}
+        </section>
+      </div>
+    </section>
+  </div>
+}

+ 51 - 0
src/styles.css

@@ -4697,6 +4697,57 @@ button { cursor:pointer; }
 @media (max-width:900px) { .production-queue-grid { grid-template-columns:1fr 1fr; } }
 @media (max-width:650px) { .production-queue-card-head,.production-queue-toolbar { align-items:stretch; flex-direction:column; } .production-queue-grid,.production-queue-more-content { grid-template-columns:1fr; } .production-queue-toolbar form { width:100%; } .production-queue-toolbar input { min-width:0; flex:1; } }
 
+/* Members and production staff share the same submitted-course content. */
+.shared-course-view { margin-bottom:24px; }
+.shared-episode-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
+.shared-episode-toggle:focus-visible { outline:2px solid #0875bd; outline-offset:-2px; }
+.shared-course-view .ep-assets-box,.shared-course-view .ep-notes-box { min-width:0; }
+.shared-course-asset { display:flex; align-items:center; gap:10px; padding:10px 0; border-top:1px solid #eef1f5; font-size:13px; overflow-wrap:anywhere; }
+.shared-course-asset > span { min-width:0; flex:1; }
+.shared-course-asset button,.shared-instructor-audio button { display:inline-flex; align-items:center; gap:4px; border:0; background:transparent; color:#0875bd; font:inherit; cursor:pointer; }
+.shared-episode-summary { margin:0 0 14px; color:#52627a; white-space:pre-wrap; }
+.shared-lecture-content { max-height:420px; overflow:auto; }
+.shared-instructor-card { display:grid; gap:12px; }
+.shared-instructor-photos { display:flex; gap:8px; overflow:auto; }
+.shared-instructor-photos img { width:72px; height:72px; object-fit:cover; border:1px solid #dce4ec; border-radius:8px; }
+.shared-instructor-audio { display:flex; align-items:center; flex-wrap:wrap; gap:6px; font-size:12px; overflow-wrap:anywhere; }
+.admin-production-meta { color:#607086; font-size:13px; margin:8px 0 0; }
+.admin-production-head-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
+.admin-production-actions { margin:30px 0; padding:24px; border:2px solid #9ccded; border-radius:12px; background:#f4faff; }
+.admin-production-actions-head { margin-bottom:18px; }
+.admin-production-actions-head h2 { margin:0 0 6px; font-size:22px; }
+.admin-production-actions-head p { margin:0; color:#52627a; font-size:13px; }
+.admin-production-actions-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
+.admin-production-action-card { display:flex; flex-direction:column; align-items:flex-start; gap:12px; min-width:0; padding:20px; }
+.admin-production-action-card h3,.admin-production-action-card h4 { margin:0; }
+.admin-production-action-card p { margin:0; color:#52627a; overflow-wrap:anywhere; }
+.admin-production-action-card label { display:grid; gap:6px; width:100%; color:#334155; font-size:13px; }
+.admin-production-action-card input,.admin-production-action-card select,.admin-production-action-card textarea { width:100%; min-width:0; padding:9px 10px; border:1px solid #cfd9e5; border-radius:6px; background:#fff; font:inherit; }
+.admin-production-action-card textarea { min-height:94px; resize:vertical; }
+.admin-production-file-group { width:100%; display:grid; gap:5px; }
+.admin-production-file-group h4 { margin-top:6px; font-size:13px; }
+.admin-production-file { display:flex; gap:10px; align-items:center; padding:8px 0; border-top:1px solid #e8edf3; font-size:13px; }
+.admin-production-file span { min-width:0; flex:1; overflow-wrap:anywhere; }
+.admin-production-file button { display:inline-flex; align-items:center; gap:4px; border:0; background:transparent; color:#0875bd; cursor:pointer; }
+.admin-production-state { font-weight:600; }
+.admin-production-actions button:disabled { opacity:.52; cursor:not-allowed; }
+.admin-content-edit-toggle { display:flex; justify-content:flex-end; margin:0 0 16px; }
+.admin-content-editor { padding:24px; margin-bottom:30px; border:1px solid #b7d8ef; border-radius:12px; background:#f7fbff; }
+.admin-content-editor-head { margin-bottom:18px; }
+.admin-content-editor-head h2 { margin:0 0 5px; font-size:22px; }
+.admin-content-editor-head p { margin:0; color:#52627a; font-size:13px; }
+.admin-content-editor-grid,.admin-content-episodes { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
+.admin-content-episodes { margin-top:16px; }
+.admin-content-episode-head { display:flex; justify-content:space-between; align-items:center; width:100%; }
+.admin-content-episode-head button { border:0; background:none; color:#b42318; cursor:pointer; }
+.admin-content-assets { display:grid; gap:8px; width:100%; }
+.admin-content-assets label { display:grid; gap:6px; }
+.admin-content-notes { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; }
+.admin-content-editor button:disabled { opacity:.52; cursor:not-allowed; }
+@media (max-width:900px) { .shared-course-view,.admin-production-actions-grid { grid-template-columns:minmax(0,1fr); } }
+@media (max-width:900px) { .admin-content-editor-grid,.admin-content-episodes { grid-template-columns:minmax(0,1fr); } }
+@media (max-width:650px) { .shared-episode-toggle .header-right { display:none; } .shared-course-asset { flex-wrap:wrap; } .admin-production-actions,.admin-content-editor { padding:14px; } .admin-production-file { flex-wrap:wrap; } }
+
 .site-footer {
   border-top: 1px solid rgba(226, 232, 240, 0.7);
   background: rgba(248, 249, 252, 0.6);