Procházet zdrojové kódy

feat: 课程素材预览与讲稿备注导入(PPTX/PDF/Word 预览、富文本讲稿、演讲者备注解析、Docker LibreOffice 部署)

bob před 1 měsícem
rodič
revize
644a6602ff

+ 3 - 0
Dockerfile

@@ -8,6 +8,9 @@ RUN npm run build
 FROM node:22-alpine
 WORKDIR /app
 ENV NODE_ENV=production
+RUN apk add --no-cache libreoffice font-noto-cjk \
+    && test -x /usr/bin/soffice \
+    && /usr/bin/soffice --version
 COPY package*.json ./
 RUN npm install --omit=dev
 COPY --from=build /app/dist ./dist

+ 1 - 0
docker-compose.prod.yml

@@ -30,6 +30,7 @@ services:
       DATABASE_URL: postgresql://xinghen:${POSTGRES_PASSWORD}@postgres:5432/xinghen
       UPLOAD_DIR: /app/uploads
       MAX_UPLOAD_MB: 100
+      SOFFICE_PATH: /usr/bin/soffice
       ADMIN_PHONE: ${ADMIN_PHONE}
       ADMIN_PASSWORD: ${ADMIN_PASSWORD}
     expose:

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 782 - 4
package-lock.json


+ 6 - 0
package.json

@@ -15,13 +15,18 @@
     "@fastify/multipart": "^9.0.3",
     "@fastify/static": "^8.1.1",
     "@types/qrcode": "^1.5.6",
+    "dompurify": "^3.4.15",
+    "fast-xml-parser": "^5.11.1",
     "fastify": "^5.2.1",
+    "jszip": "^3.10.2",
     "lucide-react": "^0.468.0",
+    "pdfjs-dist": "^6.3.289",
     "pg": "^8.13.1",
     "qrcode": "^1.5.4",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-router-dom": "^7.1.1",
+    "sanitize-html": "^2.17.7",
     "tsx": "^4.19.2",
     "zod": "^3.24.1"
   },
@@ -30,6 +35,7 @@
     "@types/pg": "^8.11.10",
     "@types/react": "^19.0.2",
     "@types/react-dom": "^19.0.2",
+    "@types/sanitize-html": "^2.16.1",
     "@vitejs/plugin-react": "^4.3.4",
     "concurrently": "^9.1.2",
     "typescript": "~5.7.2",

+ 1 - 0
server/db.ts

@@ -92,6 +92,7 @@ export const mapCourse = (
     description: r.description || '',
     status: r.status,
     productionNotes: r.production_notes || '',
+    assetNotes: r.asset_notes || '',
     estimatedMinutes: Number(r.estimated_minutes) || 1,
     estimatedPoints: Number(r.estimated_points) || 1000,
     actualPoints: r.actual_points == null ? null : Number(r.actual_points),

+ 148 - 11
server/index.ts

@@ -3,20 +3,25 @@ import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify'
 import cors from '@fastify/cors'
 import multipart from '@fastify/multipart'
 import staticPlugin from '@fastify/static'
-import { createWriteStream } from 'node:fs'
-import { mkdir, readFile, unlink } from 'node:fs/promises'
+import { createReadStream, createWriteStream, existsSync } from 'node:fs'
+import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
 import { pipeline } from 'node:stream/promises'
-import { resolve, extname, dirname, join } from 'node:path'
+import { resolve, extname, dirname, join, basename } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { randomBytes, randomUUID, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto'
+import { homedir, tmpdir } from 'node:os'
+import { execFile } from 'node:child_process'
 import { promisify } from 'node:util'
 import { z } from 'zod'
+import sanitizeHtml from 'sanitize-html'
 import { mapCourse, mapDeliverable, mapInstructor, mapUser, pool } from './db.js'
 import { COURSE_TEMPLATES, populateCourseFromTemplate } from './templates.js'
 import { registerOrganizations } from './organizations.js'
 import { registerDigitalInstructors } from './digital-instructors.js'
+import { extractPptxSpeakerNotes } from './pptx-notes.js'
 
 const scrypt = promisify(scryptCb)
+const execFileAsync = promisify(execFile)
 const app = Fastify({ logger: true })
 const port = Number(process.env.PORT || 3001)
 const uploadDir = resolve(process.env.UPLOAD_DIR || './uploads')
@@ -264,6 +269,9 @@ app.addHook('onSend', async (req, reply, payload) => {
 })
 
 app.addHook('preHandler', async (req, reply) => {
+  // 只读 GET 请求不在此处因工作区暂不匹配而强阻断,以便前端可感知课程真实工作区并自动校准对齐(后续业务层已由 ownedCourse 确保访问安全)
+  if (req.method === 'GET') return
+
   const match = req.url.match(/^\/api\/courses\/([0-9a-f-]{36})(?:\/|\?|$)/i)
   if (!match) return
   const { rows } = await pool.query('SELECT organization_id FROM courses WHERE id=$1', [match[1]])
@@ -310,6 +318,84 @@ app.get('/api/files/:stored', { preHandler: accountAuth }, async (req, reply) =>
   return reply.sendFile(stored, uploadDir)
 })
 
+const officePreviewExtensions = new Set(['.pptx', '.ppt', '.docx', '.doc'])
+const previewJobs = new Map<string, Promise<string>>()
+const previewCacheVersion = 'v2'
+const bundledSoffice = join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice')
+const sofficeCommand = process.env.SOFFICE_PATH || (existsSync(bundledSoffice) ? bundledSoffice : 'soffice')
+
+async function officePreview(stored: string) {
+  const previewDir = resolve(uploadDir, '.previews')
+  const destination = resolve(previewDir, `${stored}.${previewCacheVersion}.pdf`)
+  if (await stat(destination).then((s) => s.size > 0).catch(() => false)) return destination
+  const existing = previewJobs.get(stored)
+  if (existing) return existing
+  const job = (async () => {
+    await mkdir(previewDir, { recursive: true })
+    const workDir = await mkdtemp(join(tmpdir(), 'xinghen-preview-'))
+    const staging = resolve(previewDir, stored + '.' + randomUUID() + '.tmp')
+    try {
+      const input = join(workDir, basename(stored))
+      await copyFile(resolve(uploadDir, stored), input)
+      let conversionEnv = process.env
+      if (process.platform === 'darwin' && !process.env.FONTCONFIG_FILE) {
+        const fontConfig = join(workDir, 'fonts.conf')
+        await writeFile(fontConfig, `<?xml version="1.0"?>
+<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
+<fontconfig>
+  <dir>/System/Library/Fonts</dir><dir>/System/Library/Fonts/Supplemental</dir><dir>/Library/Fonts</dir>
+  <cachedir>${join(workDir, 'font-cache')}</cachedir>
+  <match target="pattern"><test name="family" compare="eq"><string>微软雅黑</string></test><edit name="family" mode="assign"><string>Hiragino Sans GB</string></edit></match>
+</fontconfig>`)
+        conversionEnv = { ...process.env, FONTCONFIG_FILE: fontConfig }
+      }
+      await execFileAsync(sofficeCommand, [
+        '-env:UserInstallation=' + new URL('profile/', `file://${workDir}/`).href,
+        '--headless', '--convert-to', 'pdf', '--outdir', workDir, input
+      ], { timeout: 120_000, maxBuffer: 1024 * 1024, env: conversionEnv })
+      const converted = join(workDir, basename(stored, extname(stored)) + '.pdf')
+      if (!(await stat(converted).then((s) => s.size > 0).catch(() => false))) throw new Error('文档转换没有生成预览文件')
+      await copyFile(converted, staging)
+      await rename(staging, destination)
+      return destination
+    } finally {
+      await unlink(staging).catch(() => {})
+      await rm(workDir, { recursive: true, force: true })
+    }
+  })()
+  previewJobs.set(stored, job)
+  try { return await job } finally { previewJobs.delete(stored) }
+}
+
+app.get('/api/courses/:id/assets/:assetId/preview', { preHandler: accountAuth }, async (req, reply) => {
+  const { id, assetId } = req.params as { id: string; assetId: string }
+  const { rows } = await pool.query(`SELECT a.stored_name, a.original_name, c.user_id, c.organization_id
+    FROM course_assets a JOIN courses c ON c.id=a.course_id WHERE a.id=$1 AND a.course_id=$2`, [assetId, id])
+  const asset = rows[0]
+  if (!asset) return reply.code(404).send({ message: '素材不存在' })
+  if ((req as any).user.role !== 'ADMIN') {
+    const membership = asset.organization_id ? await pool.query(
+      'SELECT role FROM organization_members WHERE organization_id=$1 AND user_id=$2',
+      [asset.organization_id, userId(req)]
+    ) : null
+    const member = membership?.rows[0]
+    const allowed = asset.organization_id
+      ? !!member && (member.role === 'MANAGER' || asset.user_id === userId(req))
+      : asset.user_id === userId(req)
+    if (!allowed) return reply.code(403).send({ message: '无权预览该素材' })
+  }
+  if (!officePreviewExtensions.has(extname(asset.original_name).toLowerCase())) {
+    return reply.code(415).send({ message: '该文件无需转换预览' })
+  }
+  try {
+    const path = await officePreview(asset.stored_name)
+    return reply.type('application/pdf').header('Cache-Control', 'private, no-store').send(createReadStream(path))
+  } catch (error) {
+    req.log.error({ err: error, assetId }, 'office preview failed')
+    return reply.code(503).send({ message: '预览生成失败,请下载原文件查看' })
+  }
+})
+
 app.get('/api/health', async () => ({ ok: true }))
 
 // ==================== 认证相关 ====================
@@ -437,6 +523,7 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
 const courseInput = z.object({
   name: z.string().trim().min(1, '请输入课程名称').max(100),
   description: z.string().max(500).default(''),
+  assetNotes: z.string().max(5000).default(''),
   digitalInstructorId: z.string().uuid().nullable().optional()
 })
 
@@ -541,19 +628,31 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
-  const p = courseInput.pick({ name: true, description: true, digitalInstructorId: true }).safeParse(req.body)
+  const p = z.object({
+    name: z.string().trim().min(1, '请输入课程名称').max(100).optional(),
+    description: z.string().max(500).optional(),
+    assetNotes: z.string().max(5000).optional(),
+    digitalInstructorId: z.string().uuid().nullable().optional()
+  }).safeParse(req.body)
+
   if (!p.success) {
     return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
   }
 
   if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
   if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
+
+  const newName = p.data.name !== undefined ? p.data.name : row.name
+  const newDesc = p.data.description !== undefined ? p.data.description : row.description
+  const newAssetNotes = p.data.assetNotes !== undefined ? p.data.assetNotes : (row.asset_notes || '')
+  const newDigitalInstructorId = p.data.digitalInstructorId !== undefined ? (p.data.digitalInstructorId || null) : row.digital_instructor_id
+
   const { rows } = await pool.query(
-    'UPDATE courses SET name = $1, description = $2, digital_instructor_id=COALESCE($4,digital_instructor_id), updated_at = NOW() WHERE id = $3 RETURNING *',
-    [p.data.name, p.data.description, id, p.data.digitalInstructorId || null]
+    'UPDATE courses SET name = $1, description = $2, asset_notes = $3, digital_instructor_id = $4, updated_at = NOW() WHERE id = $5 RETURNING *',
+    [newName, newDesc, newAssetNotes, newDigitalInstructorId, id]
   )
   await refreshCoursePoints(id)
-  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称或需求说明已更新。')
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、需求说明或通用资产备注已更新。')
   return { course: await fullCourse(rows[0]) }
 })
 
@@ -588,7 +687,11 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   // 1. 清理该课程关联的所有素材文件
   const assetsRes = await pool.query('SELECT stored_name FROM course_assets WHERE course_id = $1', [id])
   for (const ast of assetsRes.rows) {
-    if (ast.stored_name) await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+    if (ast.stored_name) {
+      await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+      await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
+      await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
+    }
   }
 
   // 2. 清理讲师头像图片
@@ -667,8 +770,29 @@ app.post('/api/courses/:id/assets', { preHandler: auth }, async (req, reply) =>
     )
   }
 
+  let notesImported = 0
+  const pptxFiles = fileList.filter(f => extname(f.filename).toLowerCase() === '.pptx')
+  if (pptxFiles.length === 1) {
+    const episodes = await pool.query('SELECT id,lecture_notes FROM episodes WHERE course_id=$1 ORDER BY episode_number,created_at', [id])
+    const target = episodeId ? episodes.rows.find(ep => ep.id === episodeId) : episodes.rows.length === 1 ? episodes.rows[0] : null
+    if (target && !target.lecture_notes.trim()) {
+      try {
+        const notes = (await extractPptxSpeakerNotes(resolve(uploadDir, pptxFiles[0].stored)))
+          .map((note, index) => note ? `第 ${index + 1} 页\n${note}` : '')
+          .filter(Boolean)
+        if (notes.length) {
+          const combined = notes.join('\n\n')
+          const updated = await pool.query("UPDATE episodes SET lecture_notes=$1,updated_at=NOW() WHERE id=$2 AND course_id=$3 AND TRIM(lecture_notes)='' RETURNING id", [combined, target.id, id])
+          if (updated.rows[0]) notesImported = notes.length
+        }
+      } catch (error) {
+        req.log.warn({ err: error }, 'pptx speaker notes extraction failed')
+      }
+    }
+  }
+
   await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
-  return { course: await fullCourse(row) }
+  return { course: await fullCourse(row), notesImported }
 })
 
 app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req, reply) => {
@@ -680,7 +804,11 @@ app.delete('/api/courses/:id/assets/:assetId', { preHandler: auth }, async (req,
     'DELETE FROM course_assets WHERE id = $1 AND course_id = $2 RETURNING stored_name',
     [assetId, id]
   )
-  if (rows[0]) await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+  if (rows[0]) {
+    await unlink(resolve(uploadDir, rows[0].stored_name)).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', rows[0].stored_name + '.pdf')).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', `${rows[0].stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
+  }
   await pool.query('UPDATE courses SET updated_at = NOW() WHERE id = $1', [id])
   return { course: await fullCourse(row) }
 })
@@ -795,7 +923,14 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
   const newTitle = p.data.title !== undefined ? p.data.title : cur.title
   const newNumber = p.data.episodeNumber !== undefined ? p.data.episodeNumber : cur.episode_number
   const newSummary = p.data.summary !== undefined ? p.data.summary : cur.summary
-  const newNotes = p.data.lectureNotes !== undefined ? p.data.lectureNotes : cur.lecture_notes
+  const richPrefix = '<!--xinghen-richtext-->'
+  const submittedNotes = p.data.lectureNotes
+  const newNotes = submittedNotes === undefined ? cur.lecture_notes : submittedNotes.startsWith(richPrefix)
+    ? richPrefix + sanitizeHtml(submittedNotes.slice(richPrefix.length), {
+      allowedTags: ['p', 'div', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li'],
+      allowedAttributes: {}
+    })
+    : submittedNotes
 
   await pool.query(
     'UPDATE episodes SET title = $1, episode_number = $2, summary = $3, lecture_notes = $4, updated_at = NOW() WHERE id = $5 AND course_id = $6',
@@ -825,6 +960,8 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
   const astRes = await pool.query('SELECT stored_name FROM course_assets WHERE episode_id = $1', [episodeId])
   for (const ast of astRes.rows) {
     await unlink(resolve(uploadDir, ast.stored_name)).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', ast.stored_name + '.pdf')).catch(() => {})
+    await unlink(resolve(uploadDir, '.previews', `${ast.stored_name}.${previewCacheVersion}.pdf`)).catch(() => {})
   }
 
   await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])

+ 31 - 0
server/pptx-notes.ts

@@ -0,0 +1,31 @@
+import { readFile } from 'node:fs/promises'
+import JSZip from 'jszip'
+import { XMLParser } from 'fast-xml-parser'
+
+const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })
+const list = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
+
+function textNodes(value: unknown): string[] {
+  if (!value || typeof value !== 'object') return []
+  return Object.entries(value).flatMap(([key, child]) => key === 't'
+    ? [typeof child === 'string' ? child : String(child ?? '')]
+    : Array.isArray(child) ? child.flatMap(textNodes) : textNodes(child))
+}
+
+export async function extractPptxSpeakerNotes(path: string): Promise<string[]> {
+  const zip = await JSZip.loadAsync(await readFile(path), { checkCRC32: true })
+  const files = Object.keys(zip.files).filter(name => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(name))
+    .sort((a, b) => Number(a.match(/\d+(?=\.xml$)/)![0]) - Number(b.match(/\d+(?=\.xml$)/)![0]))
+  if (files.length > 500) throw new Error('PPTX 页数超出备注解析限制')
+  const notes: string[] = []
+  for (const name of files) {
+    const xml = await zip.file(name)!.async('string')
+    const shapes = list(parser.parse(xml)?.notes?.cSld?.spTree?.sp)
+    const body = shapes.find((shape: any) => shape?.nvSpPr?.nvPr?.ph?.['@_type'] === 'body') as any
+    const paragraphs = list(body?.txBody?.p)
+      .map((paragraph: unknown) => textNodes(paragraph).join('').trim())
+      .filter(Boolean)
+    notes.push(paragraphs.join('\n'))
+  }
+  return notes
+}

+ 2 - 0
server/schema.sql

@@ -141,6 +141,7 @@ CREATE TABLE IF NOT EXISTS courses (
   description VARCHAR(500) NOT NULL DEFAULT '',
   status VARCHAR(30) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED')),
   production_notes TEXT NOT NULL DEFAULT '',
+  asset_notes TEXT NOT NULL DEFAULT '',
   estimated_minutes INT NOT NULL DEFAULT 1,
   estimated_points INT NOT NULL DEFAULT 1000,
   actual_points INT,
@@ -159,6 +160,7 @@ ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) O
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE RESTRICT;
 CREATE INDEX IF NOT EXISTS idx_courses_organization_created ON courses(organization_id, created_at DESC);
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_notes TEXT NOT NULL DEFAULT '';
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS asset_notes TEXT NOT NULL DEFAULT '';
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_minutes INT NOT NULL DEFAULT 1;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS estimated_points INT NOT NULL DEFAULT 1000;

+ 8 - 4
src/WorkspaceContext.tsx

@@ -16,16 +16,19 @@ const WorkspaceContext = createContext<WorkspaceContextValue>(null!)
 const USER_WORKSPACE_KEY = (userId: string) => `xinghen_workspace_user_${userId}`
 
 export function WorkspaceProvider({ children }: { children: ReactNode }) {
-  const { user } = useAuth()
+  const { user, loading } = useAuth()
   const [organizations, setOrganizations] = useState<Organization[]>([])
   const [workspaceId, setWorkspaceId] = useState<string>(() => {
     return localStorage.getItem('xinghen_workspace') || 'personal'
   })
 
   // 当 user 变动时(登录/登出/切换用户):
-  // 1. 若登出或管理员,重置状态
-  // 2. 若普通用户登录,优先尝试同步预设其上次选择的工作区,避免闪烁
+  // 1. 若仍处于身份验证 loading 中,切勿误判为登出或执行重置
+  // 2. 若登出或管理员,重置状态
+  // 3. 若普通用户登录,优先尝试同步预设其上次选择的工作区,避免闪烁
   useEffect(() => {
+    if (loading) return
+
     if (!user || user.role === 'ADMIN') {
       setOrganizations([])
       if (workspaceId !== 'personal') {
@@ -42,9 +45,10 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) {
     }
 
     refreshOrganizations().catch(() => setOrganizations([]))
-  }, [user?.id])
+  }, [user?.id, loading])
 
   async function refreshOrganizations() {
+    if (loading) return
     if (!user || user.role === 'ADMIN') {
       setOrganizations([])
       if (workspaceId !== 'personal') {

+ 24 - 2
src/api.ts

@@ -41,6 +41,28 @@ export async function privateImageUrl(url: string) {
   return URL.createObjectURL(await response.blob())
 }
 
+export async function fetchPrivateAsset(url: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('素材读取失败,请确认登录状态和课程权限')
+  return response.blob()
+}
+
+export async function fetchOfficePreview(courseId: string, assetId: string) {
+  const response = await fetch(`/api/courses/${encodeURIComponent(courseId)}/assets/${encodeURIComponent(assetId)}/preview`, {
+    headers: {
+      Authorization: `Bearer ${token()}`,
+      'X-Workspace-Id': localStorage.getItem('xinghen_workspace') || 'personal'
+    }
+  })
+  if (!response.ok) {
+    const body = await response.json().catch(() => null)
+    throw new Error(body?.message || '预览生成失败')
+  }
+  return response.blob()
+}
+
 export const api = {
   digitalInstructors: {
     list: (workspaceId?: string) => request<{ instructors: DigitalInstructor[] }>('/digital-instructors', workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
@@ -145,7 +167,7 @@ export const api = {
       body: JSON.stringify(body)
     }),
   getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
-  updateCourse: (id: string, body: Pick<Course, 'name' | 'description'> & { digitalInstructorId?: string | null }) =>
+  updateCourse: (id: string, body: Partial<Pick<Course, 'name' | 'description' | 'assetNotes'>> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>(`/courses/${id}`, {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },
@@ -156,7 +178,7 @@ export const api = {
     const form = new FormData()
     files.forEach((f) => form.append('assets', f))
     if (episodeId) form.append('episodeId', episodeId)
-    return request<{ course: Course }>(`/courses/${id}/assets`, { method: 'POST', body: form })
+    return request<{ course: Course; notesImported: number }>(`/courses/${id}/assets`, { method: 'POST', body: form })
   },
   deleteAsset: (id: string, assetId: string) =>
     request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),

+ 139 - 0
src/components/CourseAssetPreview.tsx

@@ -0,0 +1,139 @@
+import { useEffect, useRef, useState } from 'react'
+import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react'
+import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
+import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
+import { downloadPrivateFile, fetchOfficePreview, fetchPrivateAsset } from '../api'
+import type { CourseAsset } from '../types'
+
+const extension = (name: string) => name.split('.').pop()?.toLowerCase() || ''
+const documentTypes = new Set(['pdf', 'pptx', 'ppt', 'docx', 'doc'])
+const officeTypes = new Set(['pptx', 'ppt', 'docx', 'doc'])
+const imageTypes = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
+const videoTypes = new Set(['mp4', 'webm', 'mov', 'm4v'])
+
+export function canPreviewAsset(asset: CourseAsset) {
+  const type = extension(asset.originalName)
+  return documentTypes.has(type) || imageTypes.has(type) || videoTypes.has(type) ||
+    asset.mimeType.startsWith('image/') || asset.mimeType.startsWith('video/')
+}
+
+export function CourseAssetPreview({ asset, courseId, onClose }: {
+  asset: CourseAsset
+  courseId: string
+  onClose: () => void
+}) {
+  const type = extension(asset.originalName)
+  const isDocument = documentTypes.has(type)
+  const isImage = imageTypes.has(type) || asset.mimeType.startsWith('image/')
+  const [objectUrl, setObjectUrl] = useState('')
+  const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
+  const [page, setPage] = useState(1)
+  const [loading, setLoading] = useState(true)
+  const [rendering, setRendering] = useState(false)
+  const [error, setError] = useState('')
+  const canvas = useRef<HTMLCanvasElement>(null)
+  const canvasWrap = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    let active = true
+    let task: ReturnType<typeof import('pdfjs-dist').getDocument> | null = null
+    setLoading(true)
+    setError('')
+    setPage(1)
+    setDocument(null)
+    setObjectUrl('')
+    ;(async () => {
+      try {
+        const blob = officeTypes.has(type)
+          ? await fetchOfficePreview(courseId, asset.id)
+          : await fetchPrivateAsset(asset.url)
+        if (!active) return
+        if (isDocument) {
+          const pdfjs = await import('pdfjs-dist')
+          pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
+          task = pdfjs.getDocument({ data: new Uint8Array(await blob.arrayBuffer()) })
+          const pdf = await task.promise
+          if (active) setDocument(pdf)
+        } else {
+          const url = URL.createObjectURL(blob)
+          if (active) setObjectUrl(url)
+          else URL.revokeObjectURL(url)
+        }
+      } catch (cause) {
+        if (active) setError((cause as Error).message || '无法预览素材')
+      } finally {
+        if (active) setLoading(false)
+      }
+    })()
+    return () => {
+      active = false
+      if (task) void task.destroy()
+    }
+  }, [asset.id, asset.url, courseId, isDocument, type])
+
+  useEffect(() => {
+    if (!objectUrl) return
+    return () => URL.revokeObjectURL(objectUrl)
+  }, [objectUrl])
+
+  useEffect(() => {
+    if (!document || !canvas.current || !canvasWrap.current) return
+    let cancelled = false
+    let renderTask: RenderTask | null = null
+    setRendering(true)
+    ;(async () => {
+      try {
+        const pdfPage = await document.getPage(page)
+        if (cancelled || !canvas.current || !canvasWrap.current) return
+        const canvasElement = canvas.current
+        const context = canvasElement.getContext('2d')
+        if (!context) throw new Error('浏览器无法绘制预览')
+        const base = pdfPage.getViewport({ scale: 1 })
+        const fitWidth = Math.max(280, Math.min(canvasWrap.current.clientWidth - 32, 1300))
+        const viewport = pdfPage.getViewport({ scale: fitWidth / base.width })
+        const dpr = Math.min(window.devicePixelRatio || 1, 2)
+        canvasElement.width = Math.round(viewport.width * dpr)
+        canvasElement.height = Math.round(viewport.height * dpr)
+        canvasElement.style.width = `${Math.round(viewport.width)}px`
+        canvasElement.style.height = `${Math.round(viewport.height)}px`
+        renderTask = pdfPage.render({ canvas: canvasElement, canvasContext: context, viewport, transform: [dpr, 0, 0, dpr, 0, 0] })
+        await renderTask.promise
+      } catch (cause) {
+        if (!cancelled && !(cause instanceof Error && cause.name === 'RenderingCancelledException')) setError((cause as Error).message)
+      } finally {
+        if (!cancelled) setRendering(false)
+      }
+    })()
+    return () => { cancelled = true; renderTask?.cancel() }
+  }, [document, page])
+
+  useEffect(() => {
+    const keyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose()
+      if (!document) return
+      if (event.key === 'ArrowRight') setPage((current) => Math.min(document.numPages, current + 1))
+      if (event.key === 'ArrowLeft') setPage((current) => Math.max(1, current - 1))
+    }
+    window.addEventListener('keydown', keyDown)
+    return () => window.removeEventListener('keydown', keyDown)
+  }, [document, onClose])
+
+  return <div className="asset-preview-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
+    <div className="asset-preview-dialog" role="dialog" aria-modal="true" aria-label={`预览 ${asset.originalName}`}>
+      <header className="asset-preview-header"><div><strong title={asset.originalName}>{asset.originalName}</strong><span>{isDocument ? officeTypes.has(type) ? '文档预览' : 'PDF 预览' : isImage ? '图片预览' : '视频预览'}</span>{officeTypes.has(type) && <span className="asset-preview-warning">预览可能存在字体或排版差异,请以下载的原文件为准。</span>}</div><button type="button" onClick={onClose} aria-label="关闭预览"><X size={22} /></button></header>
+      <div className="asset-preview-stage" ref={canvasWrap}>
+        {loading && <p className="asset-preview-message">正在加载预览{officeTypes.has(type) ? ',首次转换可能需要一些时间' : ''}…</p>}
+        {error && <div className="asset-preview-message" role="alert"><p>{error}</p><small>可以下载原文件查看。</small></div>}
+        {isDocument && <canvas ref={canvas} className="asset-preview-canvas" style={{ display: document && !error ? 'block' : 'none' }} />}
+        {!isDocument && objectUrl && (isImage
+          ? <img className="asset-preview-media" src={objectUrl} alt={asset.originalName} onError={() => setError('浏览器不支持预览此图片格式')} />
+          : <video className="asset-preview-media" src={objectUrl} controls autoPlay={false} onError={() => setError('浏览器不支持播放此视频格式')} />)}
+        {rendering && !loading && !error && <span className="asset-preview-rendering">正在渲染第 {page} 页…</span>}
+      </div>
+      <footer className="asset-preview-footer">
+        <div>{document && <><button type="button" disabled={page <= 1 || rendering} onClick={() => setPage(page - 1)} aria-label="上一页"><ChevronLeft size={18} /> 上一页</button><span>第 {page} / {document.numPages} 页</span><button type="button" disabled={page >= document.numPages || rendering} onClick={() => setPage(page + 1)} aria-label="下一页">下一页 <ChevronRight size={18} /></button></>}</div>
+        <button type="button" onClick={() => void downloadPrivateFile(asset.url, asset.originalName).catch((cause) => setError((cause as Error).message))}><Download size={17} /> 下载原文件</button>
+      </footer>
+    </div>
+  </div>
+}

+ 42 - 39
src/components/EpisodeModal.tsx

@@ -41,8 +41,8 @@ export function EpisodeModal({
         setActiveTab('single')
         setTitle(episode.title)
         setEpisodeNumber(episode.episodeNumber)
-        setLectureNotes(episode.lectureNotes || '')
-        setShowOptionalFields(!!episode.lectureNotes?.trim())
+        setLectureNotes('')
+        setShowOptionalFields(false)
       } else {
         setActiveTab('single')
         setTitle(`第 ${nextNumber} 集:`)
@@ -94,8 +94,7 @@ export function EpisodeModal({
       if (episode) {
         res = await api.updateEpisode(courseId, episode.id, {
           title: title.trim(),
-          episodeNumber: Number(episodeNumber) || 1,
-          lectureNotes: lectureNotes.trim()
+          episodeNumber: Number(episodeNumber) || 1
         })
       } else {
         res = await api.createEpisode(courseId, {
@@ -215,43 +214,47 @@ export function EpisodeModal({
                 </label>
               </div>
 
-              {/* 选填信息折叠开关 */}
-              <div className="optional-fields-toggle">
-                <button
-                  type="button"
-                  className="toggle-optional-btn"
-                  onClick={() => setShowOptionalFields(!showOptionalFields)}
-                >
-                  {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
-                  <span>{showOptionalFields ? '收起口播讲稿' : '+ 填写分集口播讲稿(选填)'}</span>
-                </button>
-              </div>
+              {/* 选填信息折叠开关(仅在添加分集时显示;编辑分集时请直接在工作台卡片中编辑,避免重复) */}
+              {!episode && (
+                <>
+                  <div className="optional-fields-toggle">
+                    <button
+                      type="button"
+                      className="toggle-optional-btn"
+                      onClick={() => setShowOptionalFields(!showOptionalFields)}
+                    >
+                      {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+                      <span>{showOptionalFields ? '收起口播讲稿' : '+ 填写分集口播讲稿(选填)'}</span>
+                    </button>
+                  </div>
 
-              {showOptionalFields && (
-                <div className="optional-fields-container">
-                  <label className="form-label">
-                    <div className="label-with-badge">
-                      <span>分集口播讲稿正文(选填)</span>
-                      {lectureNotes.trim().length > 0 && (
-                        <span className="lecture-stats-badge">
-                          <FileText size={12} /> {lectureNotes.trim().length} 字 · 预计口播约 {estMinutes} 分钟
-                        </span>
-                      )}
+                  {showOptionalFields && (
+                    <div className="optional-fields-container">
+                      <label className="form-label">
+                        <div className="label-with-badge">
+                          <span>分集口播讲稿正文(选填)</span>
+                          {lectureNotes.trim().length > 0 && (
+                            <span className="lecture-stats-badge">
+                              <FileText size={12} /> {lectureNotes.trim().length} 字 · 预计口播约 {estMinutes} 分钟
+                            </span>
+                          )}
+                        </div>
+                        <textarea
+                          rows={5}
+                          value={lectureNotes}
+                          onChange={(e) => setLectureNotes(e.target.value)}
+                          placeholder="选填。可在线粘贴讲稿,也可在创建分集后随时在工作台编辑或上传 Word/PDF 附件…"
+                          disabled={busy}
+                        />
+                        <small className="hint-text">
+                          {lectureNotes
+                            ? `已录入 ${lectureNotes.length} 字符`
+                            : '创建分集后可在工作台直接在线编辑讲稿或上传附件'}
+                        </small>
+                      </label>
                     </div>
-                    <textarea
-                      rows={5}
-                      value={lectureNotes}
-                      onChange={(e) => setLectureNotes(e.target.value)}
-                      placeholder="选填。可在线粘贴讲稿,也可在创建分集后随时在工作台编辑或上传 Word/PDF 附件…"
-                      disabled={busy}
-                    />
-                    <small className="hint-text">
-                      {lectureNotes
-                        ? `已录入 ${lectureNotes.length} 字符`
-                        : '创建分集后可在工作台直接在线编辑讲稿或上传附件'}
-                    </small>
-                  </label>
-                </div>
+                  )}
+                </>
               )}
             </div>
 

+ 54 - 0
src/components/LectureNotesDialog.tsx

@@ -0,0 +1,54 @@
+import { useEffect, useRef, useState } from 'react'
+import { Bold, Italic, List, ListOrdered, Save, X } from 'lucide-react'
+import DOMPurify from 'dompurify'
+import { notesHtml, notesText, RICH_NOTES_PREFIX } from '../utils/lectureNotes'
+
+const speakingLength = (text: string) => Array.from(text.replace(/\s/g, '')).length
+
+export function LectureNotesDialog({ title, initialValue, onClose, onSave, saving }: {
+  title: string
+  initialValue: string
+  onClose: () => void
+  onSave: (value: string) => Promise<void>
+  saving: boolean
+}) {
+  const editor = useRef<HTMLDivElement>(null)
+  const [length, setLength] = useState(() => speakingLength(notesText(initialValue)))
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    if (editor.current) editor.current.innerHTML = notesHtml(initialValue)
+    editor.current?.focus()
+  }, [initialValue])
+
+  const updateLength = () => setLength(speakingLength(editor.current?.textContent || ''))
+
+  const command = (name: string) => {
+    document.execCommand(name)
+    editor.current?.focus()
+    updateLength()
+  }
+
+  const save = async () => {
+    const html = DOMPurify.sanitize(editor.current?.innerHTML || '')
+    const text = editor.current?.textContent?.trim() || ''
+    setError('')
+    try { await onSave(text ? RICH_NOTES_PREFIX + html : '') }
+    catch (cause) { setError((cause as Error).message) }
+  }
+
+  return <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !saving) onClose() }}>
+    <div className="modal-dialog lecture-dialog" role="dialog" aria-modal="true" aria-label={`编辑${title}口播稿`}>
+      <div className="modal-header"><div><h3>编辑口播稿</h3><small>{title}</small></div><button type="button" className="close-modal-btn" onClick={onClose} disabled={saving} aria-label="关闭"><X size={18} /></button></div>
+      <div className="lecture-toolbar" aria-label="文字格式">
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('bold')} title="加粗"><Bold size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('italic')} title="斜体"><Italic size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('insertUnorderedList')} title="项目列表"><List size={17} /></button>
+        <button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => command('insertOrderedList')} title="编号列表"><ListOrdered size={17} /></button>
+      </div>
+      <div ref={editor} className="lecture-editor" contentEditable suppressContentEditableWarning role="textbox" aria-multiline="true" aria-label="口播稿正文" data-placeholder="在这里撰写或粘贴口播稿…" onInput={updateLength} onPaste={(event) => { event.preventDefault(); document.execCommand('insertText', false, event.clipboardData.getData('text/plain')); requestAnimationFrame(updateLength) }} />
+      {error && <div className="notice notice-error" role="alert">{error}</div>}
+      <div className="lecture-dialog-footer"><div className="lecture-stats"><strong>{length.toLocaleString('zh-CN')} 字</strong><span>预计口播 {length ? (length / 260).toFixed(1) : '0.0'} 分钟</span><small>按每分钟约 260 字估算,实际时长以录制为准</small></div><div><button type="button" className="secondary-button" onClick={onClose} disabled={saving}>取消</button><button type="button" className="primary-button" onClick={save} disabled={saving}><Save size={15} /> {saving ? '保存中…' : '保存讲稿'}</button></div></div>
+    </div>
+  </div>
+}

+ 190 - 62
src/pages/CourseWorkspace.tsx

@@ -26,6 +26,8 @@ import {
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useNavigate, useParams } from 'react-router-dom'
 import { api, downloadPrivateFile } from '../api'
+import { useWorkspace } from '../WorkspaceContext'
+import { canPreviewAsset, CourseAssetPreview } from '../components/CourseAssetPreview'
 import { ProtectedImage } from '../components/ProtectedImage'
 import { InstructorVoicePlayer } from '../components/InstructorVoicePlayer'
 import { StatusBadge } from '../components/StatusBadge'
@@ -35,9 +37,13 @@ import { DeleteCourseModal } from '../components/DeleteCourseModal'
 import { ContactModal } from '../components/ContactModal'
 import { EditCourseModal } from '../components/EditCourseModal'
 import { CourseProductionProgress } from '../components/CourseProductionProgress'
+import { LectureNotesDialog } from '../components/LectureNotesDialog'
+import { notesHtml, notesText } from '../utils/lectureNotes'
 import contactQrCode from '../assets/contact-group-qrcode.jpg'
 import { formatDateTime } from '../utils/date'
-import type { Course, Episode } from '../types'
+import type { Course, CourseAsset, Episode } from '../types'
+
+const assetAccept = '.ppt,.pptx,.pdf,.doc,.docx,image/*,video/mp4,video/webm,video/quicktime,.m4v'
 
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
@@ -45,6 +51,7 @@ const size = (n: number) =>
 export function CourseWorkspace() {
   const { id = '' } = useParams()
   const navigate = useNavigate()
+  const { workspaceId, selectWorkspace } = useWorkspace()
   const globalInputRef = useRef<HTMLInputElement>(null)
 
   const [course, setCourse] = useState<Course | null>(null)
@@ -62,6 +69,7 @@ export function CourseWorkspace() {
   const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
   const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
   const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
+  const [previewAsset, setPreviewAsset] = useState<CourseAsset | null>(null)
 
   useEffect(() => {
     if (!previewImage) return
@@ -78,27 +86,35 @@ export function CourseWorkspace() {
   // 分集展开折叠状态 Map (episodeId -> boolean)
   const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
 
-  // 分集讲稿内联草稿状态 (episodeId -> string)
-  const [notesDraftMap, setNotesDraftMap] = useState<Record<string, string>>({})
   const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
+  const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
 
   // 分集上传文件输入引用 Map (episodeId -> File[])
   const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
   const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
 
+  // 通用资产备注状态
+  const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
+  const [assetNotesDraft, setAssetNotesDraft] = useState('')
+  const [savingAssetNotes, setSavingAssetNotes] = useState(false)
+
   const loadCourse = () => {
+    setError('')
     api
       .getCourse(id)
       .then((r) => {
+        setError('')
         setCourse(r.course)
+        // 自动自愈:当课程所属工作区与当前工作区不一致时,自动对齐切换工作区
+        const courseWorkspaceId = r.course.organizationId || 'personal'
+        if (courseWorkspaceId !== (localStorage.getItem('xinghen_workspace') || 'personal')) {
+          selectWorkspace(courseWorkspaceId)
+        }
         const initialExpand: Record<string, boolean> = {}
-        const initialDrafts: Record<string, string> = {}
         r.course.episodes?.forEach((ep, idx) => {
           if (idx < 2) initialExpand[ep.id] = true
-          initialDrafts[ep.id] = ep.lectureNotes || ''
         })
         setExpandedMap((prev) => ({ ...initialExpand, ...prev }))
-        setNotesDraftMap((prev) => ({ ...initialDrafts, ...prev }))
       })
       .catch((e) => setError(e.message))
   }
@@ -115,13 +131,14 @@ export function CourseWorkspace() {
   // 上传全局通用资产
   async function handleUploadGlobalAssets() {
     if (!globalFiles.length) return
+    if (globalFiles.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
     setBusy(true)
     setError('')
     try {
       const res = await api.uploadAssets(id, globalFiles)
       setCourse(res.course)
       setGlobalFiles([])
-      notify('全局资产上传成功')
+      notify(res.notesImported ? `资产上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '全局资产上传成功')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -129,17 +146,54 @@ export function CourseWorkspace() {
     }
   }
 
+  // 保存通用资产备注
+  async function handleSaveAssetNotes() {
+    if (!course) return
+    setSavingAssetNotes(true)
+    setError('')
+    try {
+      const res = await api.updateCourse(course.id, { assetNotes: assetNotesDraft.trim() })
+      setCourse(res.course)
+      setIsEditingAssetNotes(false)
+      notify('通用资产备注已保存')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setSavingAssetNotes(false)
+    }
+  }
+
+  // 清空通用资产备注
+  async function handleDeleteAssetNotes() {
+    if (!course) return
+    if (!confirm('确定要清空通用资产备注吗?')) return
+    setSavingAssetNotes(true)
+    setError('')
+    try {
+      const res = await api.updateCourse(course.id, { assetNotes: '' })
+      setCourse(res.course)
+      setAssetNotesDraft('')
+      setIsEditingAssetNotes(false)
+      notify('通用资产备注已清空')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setSavingAssetNotes(false)
+    }
+  }
+
   // 上传特定分集的专属资产
   async function handleUploadEpisodeAssets(episodeId: string) {
     const files = epFilesMap[episodeId] || []
     if (!files.length) return
+    if (files.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
     setUploadingEpId(episodeId)
     setError('')
     try {
       const res = await api.uploadAssets(id, files, episodeId)
       setCourse(res.course)
       setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
-      notify('分集素材上传成功')
+      notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '分集素材上传成功')
     } catch (e) {
       setError((e as Error).message)
     } finally {
@@ -148,8 +202,7 @@ export function CourseWorkspace() {
   }
 
   // 保存分集讲稿正文
-  async function handleSaveLectureNotes(episode: Episode) {
-    const newNotes = notesDraftMap[episode.id] ?? episode.lectureNotes
+  async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
     setSavingNotesId(episode.id)
     setError('')
     try {
@@ -158,9 +211,10 @@ export function CourseWorkspace() {
         lectureNotes: newNotes
       })
       setCourse(res.course)
+      setEditingNotesEpisode(null)
       notify(`《${episode.title}》口播讲稿已保存`)
     } catch (e) {
-      setError((e as Error).message)
+      throw e
     } finally {
       setSavingNotesId(null)
     }
@@ -663,8 +717,6 @@ export function CourseWorkspace() {
               {episodes.map((ep) => {
                 const isExpanded = !!expandedMap[ep.id]
                 const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
-                const draftNotes = notesDraftMap[ep.id] ?? (ep.lectureNotes || '')
-                const isNotesDirty = draftNotes !== (ep.lectureNotes || '')
                 const pendingFiles = epFilesMap[ep.id] || []
 
                 return (
@@ -682,7 +734,7 @@ export function CourseWorkspace() {
                       <div className="header-right" onClick={(e) => e.stopPropagation()}>
                         {ep.lectureNotes?.trim() ? (
                           <span className="tag-pill tag-pill-success" title="已录入讲稿">
-                            讲稿 ({ep.lectureNotes.length}字)
+                            讲稿 ({notesText(ep.lectureNotes).length}字)
                           </span>
                         ) : (
                           <span className="tag-pill tag-pill-dim">未添加讲稿</span>
@@ -717,70 +769,36 @@ export function CourseWorkspace() {
                       </div>
                     </div>
 
-                    {/* 展开后的分集详情:讲稿正文编辑 + 分集素材上传 */}
+                    {/* 展开后的分集详情:分集素材上传 + 讲稿正文编辑 */}
                     {isExpanded && (
                       <div className="episode-card-body">
                         <div className="episode-content-heading">
                           <div>
                             <strong>本集制作内容</strong>
-                            <span>口播稿与素材均为选填,可按实际制作方式添加</span>
                           </div>
                         </div>
 
                         <div className="ep-body-grid">
-                          {/* 口播稿(选填) */}
-                          <div className="ep-notes-box">
-                            <div className="box-title-row">
-                              <div className="title-left">
-                                <FileText size={15} />
-                                <strong>口播稿</strong>
-                                <span className="optional-label">选填</span>
-                              </div>
-                              {isNotesDirty && <span className="dirty-badge">未保存</span>}
-                            </div>
-                            <textarea
-                              rows={5}
-                              className="ep-notes-textarea"
-                              value={draftNotes}
-                              onChange={(e) => {
-                                const val = e.target.value
-                                setNotesDraftMap((prev) => ({ ...prev, [ep.id]: val }))
-                              }}
-                              placeholder="可直接输入或粘贴本集的口播稿、字幕文稿…"
-                            />
-                            <div className="notes-footer-row">
-                              <small>共 {draftNotes.length} 字</small>
-                              <button
-                                type="button"
-                                className="primary-button small-button save-notes-btn"
-                                disabled={savingNotesId === ep.id || !isNotesDirty}
-                                onClick={() => handleSaveLectureNotes(ep)}
-                              >
-                                <Save size={13} />
-                                {savingNotesId === ep.id ? '保存中…' : '保存讲稿'}
-                              </button>
-                            </div>
-                          </div>
-
-                          {/* 本集素材(选填) */}
+                          {/* 本集素材 */}
                           <div className="ep-assets-box">
                             <div className="box-title-row">
                               <div className="title-left">
                                 <FileSpreadsheet size={15} />
                                 <strong>上传素材</strong>
-                                <span className="optional-label">选填</span>
                                 {epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
                               </div>
                               <label className="ep-file-label compact-upload-button">
                                 <Upload size={14} />
-                                <span>选择文件</span>
+                                <span>添加文件(可多选)</span>
                                 <input
                                   type="file"
+                                  accept={assetAccept}
                                   multiple
                                   hidden
                                   onChange={(e) => {
                                     const selected = Array.from(e.target.files || [])
-                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
+                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: [...(prev[ep.id] || []), ...selected] }))
+                                    e.target.value = ''
                                   }}
                                 />
                               </label>
@@ -795,6 +813,8 @@ export function CourseWorkspace() {
                                     <strong title={a.originalName}>{a.originalName}</strong>
                                     <span>{size(a.size)}</span>
                                   </div>
+                                  {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
+                                  <button type="button" className="icon-action-btn" title="下载素材" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={14} /></button>
                                   <button
                                     type="button"
                                     className="icon-action-btn delete-btn"
@@ -806,7 +826,7 @@ export function CourseWorkspace() {
                                 </div>
                               ))}
                               {!epAssets.length && (
-                                <p className="empty-sub-tip">如有需要,可上传本集使用的 PPT、Word、图片等制作素材。</p>
+                                <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频,支持一次多选和分次添加。</p>
                               )}
                             </div>
 
@@ -844,6 +864,19 @@ export function CourseWorkspace() {
                               )}
                             </div>
                           </div>
+
+                          {/* 口播稿 */}
+                          <div className="ep-notes-box">
+                            <div className="box-title-row">
+                              <div className="title-left">
+                                <FileText size={15} />
+                                <strong>口播稿</strong>
+                              </div>
+                              <button type="button" className="secondary-button small-button" onClick={() => setEditingNotesEpisode(ep)}><Edit3 size={14} /> 编辑</button>
+                            </div>
+                            {ep.lectureNotes?.trim() ? <div className="lecture-preview" dangerouslySetInnerHTML={{ __html: notesHtml(ep.lectureNotes) }} /> : <p className="empty-sub-tip">尚未添加口播稿。点击“编辑”打开大幅编辑窗口。</p>}
+                            {ep.lectureNotes?.trim() && <small className="lecture-count">共 {notesText(ep.lectureNotes).length} 字</small>}
+                          </div>
                         </div>
                       </div>
                     )}
@@ -852,26 +885,118 @@ export function CourseWorkspace() {
               })}
             </section>
 
-            {/* 课程通用 / 全局素材专区 */}
+            {/* 通用资产专区 */}
             <section className="paper-form global-assets-panel">
               <div className="panel-heading-row">
                 <div>
-                  <h3>全套课程通用资产 ({globalAssets.length})</h3>
+                  <h3>通用资产 ({globalAssets.length})</h3>
                   <p className="panel-intro">
                     如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
                   </p>
                 </div>
+                {!isEditingAssetNotes && !course.assetNotes?.trim() && canEditCourseContent && (
+                  <button
+                    type="button"
+                    className="secondary-button small-button add-asset-notes-btn"
+                    onClick={() => {
+                      setAssetNotesDraft('')
+                      setIsEditingAssetNotes(true)
+                    }}
+                  >
+                    <Plus size={14} /> 添加备注
+                  </button>
+                )}
               </div>
 
+              {/* 通用资产备注展示与编辑 */}
+              {(isEditingAssetNotes || !!course.assetNotes?.trim()) && (
+                <div className="asset-notes-card">
+                  <div className="asset-notes-header">
+                    <div className="notes-header-left">
+                      <FileText size={15} />
+                      <strong>通用备注</strong>
+                    </div>
+                    {canEditCourseContent && !isEditingAssetNotes && (
+                      <div className="notes-actions">
+                        <button
+                          type="button"
+                          className="icon-action-btn"
+                          title="修改备注"
+                          onClick={() => {
+                            setAssetNotesDraft(course.assetNotes || '')
+                            setIsEditingAssetNotes(true)
+                          }}
+                        >
+                          <Edit3 size={13} />
+                        </button>
+                        <button
+                          type="button"
+                          className="icon-action-btn delete-btn"
+                          title="清空备注"
+                          onClick={handleDeleteAssetNotes}
+                          disabled={savingAssetNotes}
+                        >
+                          <Trash2 size={13} />
+                        </button>
+                      </div>
+                    )}
+                  </div>
+
+                  {isEditingAssetNotes ? (
+                    <div className="asset-notes-edit-box">
+                      <textarea
+                        rows={4}
+                        className="asset-notes-textarea"
+                        value={assetNotesDraft}
+                        onChange={(e) => setAssetNotesDraft(e.target.value)}
+                        placeholder="输入通用制作备注、课件风格要求、全局格式规范等说明文字…"
+                        autoFocus
+                      />
+                      <div className="notes-footer-row">
+                        <small>共 {assetNotesDraft.length} 字</small>
+                        <div className="notes-btn-group">
+                          <button
+                            type="button"
+                            className="secondary-button small-button"
+                            onClick={() => {
+                              setIsEditingAssetNotes(false)
+                              setAssetNotesDraft(course.assetNotes || '')
+                            }}
+                            disabled={savingAssetNotes}
+                          >
+                            取消
+                          </button>
+                          <button
+                            type="button"
+                            className="primary-button small-button"
+                            onClick={handleSaveAssetNotes}
+                            disabled={savingAssetNotes}
+                          >
+                            <Save size={13} />
+                            {savingAssetNotes ? '保存中…' : '保存备注'}
+                          </button>
+                        </div>
+                      </div>
+                    </div>
+                  ) : (
+                    <div className="asset-notes-text">
+                      {course.assetNotes}
+                    </div>
+                  )}
+                </div>
+              )}
+
               <div className="asset-list">
                 {globalAssets.map((a) => (
                   <div className="asset-row" key={a.id}>
                     <FileText />
                     <div>
                       <strong>{a.originalName}</strong>
-                      <span>{size(a.size)} · 全局通用</span>
+                      <span>{size(a.size)} · 通用素材</span>
                     </div>
-                    <button title="删除" onClick={() => handleDeleteAsset(a.id)}>
+                    {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
+                    <button type="button" title="下载" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={16} /></button>
+                    <button type="button" title="删除" aria-label={`删除 ${a.originalName}`} onClick={() => handleDeleteAsset(a.id)}>
                       <Trash2 size={16} />
                     </button>
                   </div>
@@ -880,15 +1005,16 @@ export function CourseWorkspace() {
 
               <div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
                 <FilePlus2 />
-                <h3>选择全局通用资产</h3>
-                <small>支持任意常见课件/文档文件,单个文件不超过 200 MB</small>
+                <h3>添加通用附件(可多选)</h3>
+                <small>支持 PPTX、PDF、Word、图片和视频;可分次添加,每次最多 20 份</small>
               </div>
               <input
                 hidden
                 multiple
                 ref={globalInputRef}
                 type="file"
-                onChange={(e) => setGlobalFiles(Array.from(e.target.files || []))}
+                accept={assetAccept}
+                onChange={(e) => { setGlobalFiles((prev) => [...prev, ...Array.from(e.target.files || [])]); e.target.value = '' }}
               />
 
               {globalFiles.length > 0 && (
@@ -1067,6 +1193,8 @@ export function CourseWorkspace() {
       />
 
       {/* 单集添加/编辑弹窗 */}
+      {previewAsset && <CourseAssetPreview key={previewAsset.id} asset={previewAsset} courseId={id} onClose={() => setPreviewAsset(null)} />}
+      {editingNotesEpisode && <LectureNotesDialog key={editingNotesEpisode.id} title={editingNotesEpisode.title} initialValue={course.episodes?.find((episode) => episode.id === editingNotesEpisode.id)?.lectureNotes || ''} onClose={() => setEditingNotesEpisode(null)} onSave={(value) => handleSaveLectureNotes(editingNotesEpisode, value)} saving={savingNotesId === editingNotesEpisode.id} />}
       <EpisodeModal
         isOpen={showEpisodeModal}
         onClose={() => {

+ 38 - 24
src/pages/admin/AdminCourseDetail.tsx

@@ -38,6 +38,7 @@ import { InstructorVoicePlayer } from '../../components/InstructorVoicePlayer'
 import { StatusBadge } from '../../components/StatusBadge'
 import { CourseProductionProgress } from '../../components/CourseProductionProgress'
 import type { Course, CourseStatus } from '../../types'
+import { notesHtml, notesText } from '../../utils/lectureNotes'
 
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
@@ -741,7 +742,7 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
                         </div>
                         <div className="header-right">
                           <span className="text-pill">
-                            {ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '未录入讲稿'}
+                            {ep.lectureNotes ? `${notesText(ep.lectureNotes).length} 字讲稿` : '未录入讲稿'}
                           </span>
                           <span className="text-pill">{epAssets.length} 份课件</span>
                           {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -763,7 +764,7 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
                                   className={`copy-notes-btn ${noteCopied ? 'copied' : ''}`}
                                   onClick={(e) => {
                                     e.stopPropagation()
-                                    handleCopyNotes(ep.id, ep.lectureNotes)
+                                    handleCopyNotes(ep.id, notesText(ep.lectureNotes))
                                   }}
                                   title="复制讲稿全文"
                                 >
@@ -780,7 +781,7 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
                               )}
                             </div>
                             {ep.lectureNotes?.trim() ? (
-                              <pre className="notes-pre-content">{ep.lectureNotes}</pre>
+                              <div className="notes-pre-content" dangerouslySetInnerHTML={{ __html: notesHtml(ep.lectureNotes) }} />
                             ) : (
                               <p className="empty-sub-tip">该集用户未录入纯文本讲稿,请查阅下方附件文件。</p>
                             )}
@@ -817,31 +818,44 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
               </div>
             ) : null}
 
-            {/* 全局通用原始素材 */}
-            {globalAssets.length > 0 && (
+            {/* 全局通用原始素材与通用资产备注 */}
+            {(globalAssets.length > 0 || !!course.assetNotes?.trim()) && (
               <div className="admin-global-assets-section">
-                <h4 className="sub-section-title">全套课程通用素材 ({globalAssets.length})</h4>
-                <div className="asset-download-grid">
-                  {globalAssets.map((asset) => (
-                    <div className="asset-download-card" key={asset.id}>
-                      <FileText size={24} className="tone-primary-icon" />
-                      <div className="asset-download-info">
-                        <strong title={asset.originalName}>{asset.originalName}</strong>
-                        <small>{size(asset.size)} · 全套通用素材</small>
-                      </div>
-                      <button
-                        onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
-                        className="primary-button small-button"
-                      >
-                        <Download size={14} /> 下载素材
-                      </button>
+                <h4 className="sub-section-title">通用资产 ({globalAssets.length})</h4>
+                {course.assetNotes && (
+                  <div className="admin-asset-notes-banner" style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '12px 14px', marginBottom: '14px' }}>
+                    <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#1e293b', fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
+                      <FileText size={15} />
+                      <span>通用备注</span>
                     </div>
-                  ))}
-                </div>
+                    <p style={{ margin: 0, color: '#334155', fontSize: '13px', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
+                      {course.assetNotes}
+                    </p>
+                  </div>
+                )}
+                {globalAssets.length > 0 && (
+                  <div className="asset-download-grid">
+                    {globalAssets.map((asset) => (
+                      <div className="asset-download-card" key={asset.id}>
+                        <FileText size={24} className="tone-primary-icon" />
+                        <div className="asset-download-info">
+                          <strong title={asset.originalName}>{asset.originalName}</strong>
+                          <small>{size(asset.size)} · 通用素材</small>
+                        </div>
+                        <button
+                          onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
+                          className="primary-button small-button"
+                        >
+                          <Download size={14} /> 下载素材
+                        </button>
+                      </div>
+                    ))}
+                  </div>
+                )}
               </div>
             )}
 
-            {!episodes.length && !globalAssets.length && (
+            {!episodes.length && !globalAssets.length && !course.assetNotes && (
               <div className="empty-sub-tip">用户尚未录入分集或上传任何素材文件。</div>
             )}
           </section>
@@ -950,7 +964,7 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
                     <div className="outline-info">
                       <strong>{ep.title}</strong>
                       <small>
-                        {ep.lectureNotes ? `${ep.lectureNotes.length} 字讲稿` : '无文本讲稿'} ·{' '}
+                        {ep.lectureNotes ? `${notesText(ep.lectureNotes).length} 字讲稿` : '无文本讲稿'} ·{' '}
                         {course.assets?.filter((a) => a.episodeId === ep.id).length || 0} 份附件
                       </small>
                     </div>

+ 4 - 3
src/pages/admin/AdminProductionQueue.tsx

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
 import { api, downloadPrivateFile } from '../../api'
 import { ProtectedImage } from '../../components/ProtectedImage'
 import { formatDateTime } from '../../utils/date'
+import { notesHtml, notesText } from '../../utils/lectureNotes'
 import type { Course } from '../../types'
 
 export function AdminProductionQueue() {
@@ -65,7 +66,7 @@ export function AdminProductionQueue() {
       <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) => episode.lectureNotes?.trim()).length
+        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>
@@ -76,14 +77,14 @@ export function AdminProductionQueue() {
             <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 || '未填写分集简介'} · {episode.lectureNotes?.trim() ? `${episode.lectureNotes.length} 字讲稿` : '暂无讲稿'}</span></li>)}</ul> : <p>暂无分集资料</p>}</section>
+            <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><p>{episode.lectureNotes || '未填写讲稿'}</p></section>)}</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">

+ 131 - 0
src/styles.css

@@ -2674,6 +2674,53 @@ button { cursor:pointer; }
   box-shadow: 0 0 0 3px rgba(85, 176, 247, 0.12);
 }
 
+.lecture-preview { max-height: 130px; overflow: auto; padding: 10px 12px; background: #fff; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }
+.lecture-preview p { margin: 0 0 8px; }
+.lecture-preview ul, .lecture-preview ol { padding-left: 24px; }
+.lecture-count { color: #64748b; margin-top: 8px; }
+.modal-dialog.lecture-dialog { width: min(960px, calc(100vw - 32px)); height: min(88vh, 900px); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; overflow: hidden; background: #fff; border: 1px solid #dbe4ee; border-radius: 12px; box-shadow: 0 24px 64px rgba(15, 23, 42, .24); }
+.lecture-dialog .modal-header { flex: none; padding: 18px 24px; }
+.lecture-dialog .modal-header small { display: block; margin-top: 5px; color: #64748b; }
+.lecture-toolbar { display: flex; flex: none; gap: 6px; padding: 10px 20px; border-bottom: 1px solid #e2e8f0; background: #fff; }
+.lecture-toolbar button { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #dbe4ee; border-radius: 6px; background: #fff; color: #334155; cursor: pointer; }
+.lecture-toolbar button:hover { background: #eaf7ff; color: #0875bd; }
+.lecture-editor { flex: 1; min-height: 0; overflow: auto; margin: 16px 20px; padding: 18px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; font-size: 15px; line-height: 1.8; outline: none; overflow-wrap: anywhere; white-space: pre-wrap; }
+.lecture-editor:focus { border-color: #0875bd; box-shadow: 0 0 0 3px rgba(85,176,247,.12); }
+.lecture-editor:empty::before { content: attr(data-placeholder); color: #94a3b8; }
+.lecture-dialog-footer { flex: none; padding: 14px 20px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #fff; border-top: 1px solid #edf2f7; }
+.lecture-dialog-footer small { color: #64748b; }
+.lecture-dialog-footer > div { display: flex; gap: 8px; }
+.lecture-dialog-footer .lecture-stats { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; color: #334155; }
+.lecture-stats strong { color: #0875bd; font-size: 15px; }
+.lecture-stats span { font-size: 13px; }
+.lecture-stats small { display: block; width: 100%; font-size: 11px; }
+@media (max-width: 600px) { .modal-dialog.lecture-dialog { height: calc(100dvh - 24px); max-height: none; } .lecture-dialog .modal-header { padding: 14px 16px; } .lecture-toolbar { padding: 8px 12px; } .lecture-editor { margin: 12px; padding: 12px; } .lecture-dialog-footer { align-items: stretch; flex-direction: column; padding: 10px 12px 14px; } .lecture-dialog-footer > div { justify-content: flex-end; } }
+
+.asset-preview-backdrop { position: fixed; inset: 0; z-index: 1100; display: grid; place-items: center; padding: 12px; background: rgba(15,23,42,.88); }
+.asset-preview-dialog { width: min(1450px, 100%); height: min(100%, 1000px); display: flex; flex-direction: column; overflow: hidden; border-radius: 10px; background: #f1f5f9; box-shadow: 0 24px 72px rgba(0,0,0,.3); }
+.asset-preview-header, .asset-preview-footer { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; background: #fff; }
+.asset-preview-header { border-bottom: 1px solid #e2e8f0; }
+.asset-preview-header > div { min-width: 0; display: flex; flex-direction: column; }
+.asset-preview-header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
+.asset-preview-header span { color: #64748b; font-size: 12px; }
+.asset-preview-header .asset-preview-warning { color: #9a5b16; }
+.asset-preview-header button, .asset-preview-footer button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #dbe4ee; border-radius: 6px; padding: 7px 10px; background: #fff; color: #334155; cursor: pointer; }
+.asset-preview-header button { border: 0; }
+.asset-preview-footer { border-top: 1px solid #e2e8f0; }
+.asset-preview-footer > div { display: flex; align-items: center; gap: 10px; }
+.asset-preview-footer button:disabled { opacity: .45; cursor: default; }
+.asset-preview-stage { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; background: #e8edf3; }
+.asset-preview-canvas { max-width: 100%; height: auto; margin: auto; background: #fff; box-shadow: 0 4px 20px rgba(15,23,42,.15); }
+.asset-preview-media { max-width: 100%; max-height: 100%; object-fit: contain; }
+.asset-preview-message { text-align: center; color: #475569; }
+.asset-preview-rendering { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); padding: 5px 12px; border-radius: 20px; background: #334155; color: #fff; font-size: 12px; }
+.asset-preview-action { flex: none; border: 1px solid #bee4ff; border-radius: 6px; padding: 5px 9px; background: #eff8ff; color: #0875bd; cursor: pointer; font-size: 12px; }
+.asset-preview-action:hover { background: #d3edff; }
+.asset-row > button:not(:first-of-type) { margin-left: 0; }
+.asset-row .asset-preview-action { border: 1px solid #bee4ff; background: #eff8ff; color: #0875bd; }
+.asset-row .asset-preview-action:hover { background: #d3edff; color: #075f9b; }
+@media (max-width: 600px) { .asset-preview-backdrop { padding: 0; } .asset-preview-dialog { height: 100dvh; border-radius: 0; } .asset-preview-stage { padding: 8px; } .asset-preview-footer { padding: 10px; } .asset-preview-footer > div { gap: 4px; font-size: 12px; } .asset-preview-footer button { padding: 6px; font-size: 12px; } }
+
 .notes-footer-row {
   display: flex;
   justify-content: space-between;
@@ -2856,6 +2903,90 @@ button { cursor:pointer; }
   border: 1px solid #e2e8f0;
 }
 
+/* 通用资产备注卡片 */
+.asset-notes-card {
+  display: flex;
+  flex-direction: column;
+  background: #fbfcfe;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 14px 16px;
+  margin-bottom: 14px;
+}
+
+.asset-notes-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10px;
+}
+
+.asset-notes-header .notes-header-left {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  color: #1e293b;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.asset-notes-header .notes-actions {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.asset-notes-text {
+  font-size: 13px;
+  line-height: 1.6;
+  color: #334155;
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: #ffffff;
+  border: 1px solid #f1f5f9;
+  border-radius: 6px;
+  padding: 10px 12px;
+}
+
+.asset-notes-edit-box {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.asset-notes-textarea {
+  width: 100%;
+  border: 1px solid #cbd5e1;
+  border-radius: 6px;
+  padding: 10px 12px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #1e293b;
+  background: #ffffff;
+  resize: vertical;
+  min-height: 90px;
+  transition: border-color 0.15s ease;
+  box-sizing: border-box;
+}
+
+.asset-notes-textarea:focus {
+  outline: none;
+  border-color: #0284c7;
+  box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.12);
+}
+
+.add-asset-notes-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.notes-btn-group {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
 /* 提交统计提示 */
 .submit-summary-info {
   margin-bottom: 10px;

+ 1 - 0
src/types.ts

@@ -120,6 +120,7 @@ export interface Course {
   description: string
   status: CourseStatus
   productionNotes?: string
+  assetNotes?: string
   estimatedMinutes: number
   estimatedPoints: number
   actualPoints: number | null

+ 21 - 0
src/utils/lectureNotes.ts

@@ -0,0 +1,21 @@
+import DOMPurify from 'dompurify'
+
+export const RICH_NOTES_PREFIX = '<!--xinghen-richtext-->'
+
+export function notesText(value: string) {
+  if (!value.startsWith(RICH_NOTES_PREFIX)) return value
+  const element = document.createElement('div')
+  element.innerHTML = DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
+  return element.textContent || ''
+}
+
+export function notesHtml(value: string) {
+  if (value.startsWith(RICH_NOTES_PREFIX)) return DOMPurify.sanitize(value.slice(RICH_NOTES_PREFIX.length))
+  const element = document.createElement('div')
+  value.split(/\n/).forEach((line) => {
+    const paragraph = document.createElement('p')
+    paragraph.textContent = line || ' '
+    element.appendChild(paragraph)
+  })
+  return element.innerHTML
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů