Selaa lähdekoodia

feat: 完善课程制作进度、课程信息编辑与后台管理功能,优化UI交互

bob 1 viikko sitten
vanhempi
sitoutus
ac4e6e36ff

+ 1 - 0
.gitignore

@@ -10,3 +10,4 @@ uploads/*
 .env
 deploy-package.tar.gz
 .deploy-known-hosts
+*.tsbuildinfo

+ 0 - 9
package-lock.json

@@ -62,7 +62,6 @@
       "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "@babel/code-frame": "^7.29.7",
         "@babel/generator": "^7.29.7",
@@ -1497,7 +1496,6 @@
       "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "csstype": "^3.2.2"
       }
@@ -1697,7 +1695,6 @@
         }
       ],
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "baseline-browser-mapping": "^2.11.12",
         "caniuse-lite": "^1.0.30001809",
@@ -2550,7 +2547,6 @@
       "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz",
       "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "pg-connection-string": "^2.14.0",
         "pg-pool": "^3.14.0",
@@ -2648,7 +2644,6 @@
       "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=12"
       },
@@ -2788,7 +2783,6 @@
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
       "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -2798,7 +2792,6 @@
       "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
       "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "scheduler": "^0.27.0"
       },
@@ -3260,7 +3253,6 @@
       "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
       "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "~0.28.0"
       },
@@ -3332,7 +3324,6 @@
       "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
       "dev": true,
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "esbuild": "^0.25.0",
         "fdir": "^6.4.4",

+ 16 - 2
server/db.ts

@@ -13,6 +13,7 @@ export const mapUser = (r: any) => ({
   wechat: r.wechat || '',
   contactName: r.contact_name || '',
   bio: r.bio || '',
+  pointsBalance: Number(r.points_balance) || 0,
   createdAt: r.created_at,
   updatedAt: r.updated_at
 })
@@ -33,7 +34,8 @@ export const mapInstructor = (r: any) => ({
   name: r.name,
   organization: r.organization || '',
   introduction: r.introduction || '',
-  imageUrl: r.image_stored_name ? `/uploads/${r.image_stored_name}` : null
+  imageUrl: r.image_urls?.[0] || (r.image_stored_name ? `/uploads/${r.image_stored_name}` : null),
+  imageUrls: r.image_urls?.length ? r.image_urls : (r.image_stored_name ? [`/uploads/${r.image_stored_name}`] : [])
 })
 
 export const mapDeliverable = (r: any) => ({
@@ -83,10 +85,22 @@ export const mapCourse = (
     userId: r.user_id,
     name: r.name,
     category: r.category || '',
-    audience: r.audience || '',
     description: r.description || '',
     status: r.status,
     productionNotes: r.production_notes || '',
+    estimatedMinutes: Number(r.estimated_minutes) || 1,
+    estimatedPoints: Number(r.estimated_points) || 1000,
+    actualPoints: r.actual_points == null ? null : Number(r.actual_points),
+    pointsCharged: Boolean(r.points_charged),
+    progressEvents: (r.progress_events || []).map((event: any) => ({
+      id: event.id,
+      actorType: event.actor_type,
+      eventType: event.event_type,
+      title: event.title,
+      description: event.description || '',
+      metadata: event.metadata || {},
+      createdAt: event.created_at
+    })),
     createdAt: r.created_at,
     submittedAt: r.submitted_at || null,
     completedAt: r.completed_at || null,

+ 211 - 22
server/index.ts

@@ -112,11 +112,16 @@ const phonePassword = z.object({
 })
 
 const fullCourse = async (row: any, includeCreator = false) => {
-  const [a, i, d, e] = await Promise.all([
+  const latest = await pool.query('SELECT * FROM courses WHERE id = $1', [row.id])
+  row = { ...row, ...(latest.rows[0] || {}) }
+  const [a, i, instructorImages, d, e, progress] = await Promise.all([
     pool.query('SELECT * FROM course_assets WHERE course_id = $1 ORDER BY created_at', [row.id]),
     pool.query('SELECT * FROM instructors WHERE course_id = $1 ORDER BY created_at', [row.id]),
+    pool.query(`SELECT ii.* FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id
+      WHERE i.course_id = $1 ORDER BY ii.sort_order, ii.created_at`, [row.id]),
     pool.query('SELECT * FROM course_deliverables WHERE course_id = $1 ORDER BY created_at', [row.id]),
-    pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id])
+    pool.query('SELECT * FROM episodes WHERE course_id = $1 ORDER BY episode_number ASC, created_at ASC', [row.id]),
+    pool.query('SELECT * FROM course_progress_events WHERE course_id = $1 ORDER BY created_at DESC', [row.id])
   ])
 
   let episodesRows = e.rows
@@ -136,7 +141,30 @@ const fullCourse = async (row: any, includeCreator = false) => {
     const u = await pool.query('SELECT * FROM users WHERE id = $1', [row.user_id])
     creatorUser = u.rows[0] || null
   }
-  return mapCourse(row, a.rows, i.rows, d.rows, creatorUser, episodesRows)
+  const instructors = i.rows.map((instructor) => ({
+    ...instructor,
+    image_urls: instructorImages.rows
+      .filter((image) => image.instructor_id === instructor.id)
+      .map((image) => `/uploads/${image.stored_name}`)
+  }))
+  return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
+}
+
+const addProgressEvent = (courseId: string, actorType: 'USER' | 'ADMIN' | 'SYSTEM', eventType: string, title: string, description = '', metadata: any = {}) =>
+  pool.query(
+    'INSERT INTO course_progress_events(id, course_id, actor_type, event_type, title, description, metadata) VALUES($1,$2,$3,$4,$5,$6,$7)',
+    [randomUUID(), courseId, actorType, eventType, title, description, JSON.stringify(metadata)]
+  )
+
+async function refreshCoursePoints(courseId: string) {
+  const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM episodes WHERE course_id = $1', [courseId])
+  const episodeCount = Math.max(1, Number(rows[0]?.count) || 0)
+  const points = episodeCount * 1000
+  await pool.query(
+    'UPDATE courses SET estimated_points = $1, updated_at = NOW() WHERE id = $2',
+    [points, courseId]
+  )
+  return { episodeCount, points }
 }
 
 const ownedCourse = async (id: string, uid: string) =>
@@ -153,7 +181,7 @@ app.post('/api/auth/register', async (req, reply) => {
     const id = randomUUID()
     const hash = await hashPassword(p.data.password)
     const { rows } = await pool.query(
-      'INSERT INTO users(id, phone, password_hash, role) VALUES($1, $2, $3, $4) RETURNING *',
+      'INSERT INTO users(id, phone, password_hash, role, points_balance) VALUES($1, $2, $3, $4, 10000) RETURNING *',
       [id, p.data.phone, hash, 'USER']
     )
     return reply.code(201).send({ token: await session(id), user: mapUser(rows[0]) })
@@ -235,7 +263,6 @@ app.post('/api/me/password', { preHandler: auth }, async (req, reply) => {
 const courseInput = z.object({
   name: z.string().trim().min(1, '请输入课程名称').max(100),
   category: z.string().max(50).default(''),
-  audience: z.string().max(100).default(''),
   description: z.string().max(500).default('')
 })
 
@@ -254,8 +281,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   const id = randomUUID()
   const d = p.data
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
-    [id, userId(req), d.name, d.category, d.audience, d.description]
+    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
+    [id, userId(req), d.name, d.category, d.description]
   )
 
   // 课程创建后默认创建第 1 集内容,确保分集不为空
@@ -266,6 +293,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
     [defaultEpId, id, defaultTitle, '', '']
   )
 
+  await addProgressEvent(id, 'USER', 'COURSE_CREATED', '课程已创建', '课程草稿创建成功。')
+
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
 
@@ -279,12 +308,15 @@ app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
   const courseId = randomUUID()
 
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, name, category, audience, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
-    [courseId, userId(req), template.name, template.category, template.audience || '', template.description]
+    'INSERT INTO courses(id, user_id, name, category, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
+    [courseId, userId(req), template.name, template.category, template.description]
   )
 
   await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
 
+  await refreshCoursePoints(courseId)
+  await addProgressEvent(courseId, 'USER', 'COURSE_CREATED', '从模板创建课程', '已按模板生成课程内容。')
+
   return reply.code(201).send({ course: await fullCourse(rows[0]) })
 })
 
@@ -295,6 +327,41 @@ app.get('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   return { course: await fullCourse(row) }
 })
 
+app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+
+  const p = courseInput.pick({ name: true, category: true, description: true }).safeParse(req.body)
+  if (!p.success) {
+    return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
+  }
+
+  const { rows } = await pool.query(
+    'UPDATE courses SET name = $1, category = $2, description = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5 RETURNING *',
+    [p.data.name, p.data.category, p.data.description, id, userId(req)]
+  )
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
+  return { course: await fullCourse(rows[0]) }
+})
+
+app.post('/api/courses/:id/cancel-submission', { preHandler: auth }, async (req, reply) => {
+  const { id } = req.params as any
+  const row = await ownedCourse(id, userId(req))
+  if (!row) return reply.code(404).send({ message: '课程不存在' })
+  if (row.status !== 'WAITING_PRODUCTION') {
+    return reply.code(409).send({ message: '只有等待制作的课程可以恢复为草稿' })
+  }
+
+  const { rows } = await pool.query(
+    "UPDATE courses SET status = 'DRAFT', submitted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2 RETURNING *",
+    [id, userId(req)]
+  )
+  await addProgressEvent(id, 'USER', 'SUBMISSION_CANCELLED', '已撤回制作申请', '课程恢复为草稿,可继续修改后重新提交。')
+  return { course: await fullCourse(rows[0]) }
+})
+
 app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
   const row = await ownedCourse(id, userId(req))
@@ -313,8 +380,16 @@ app.delete('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
 
   // 2. 清理讲师头像图片
   const instRes = await pool.query('SELECT image_stored_name FROM instructors WHERE course_id = $1', [id])
+  const instImagesRes = await pool.query(
+    'SELECT ii.stored_name FROM instructor_images ii JOIN instructors i ON i.id = ii.instructor_id WHERE i.course_id = $1',
+    [id]
+  )
+  const instructorStoredNames = new Set(instImagesRes.rows.map((image) => image.stored_name))
   for (const ins of instRes.rows) {
-    if (ins.image_stored_name) await unlink(resolve(uploadDir, ins.image_stored_name)).catch(() => {})
+    if (ins.image_stored_name) instructorStoredNames.add(ins.image_stored_name)
+  }
+  for (const storedName of instructorStoredNames) {
+    await unlink(resolve(uploadDir, storedName as string)).catch(() => {})
   }
 
   // 3. 清理交付成品文件
@@ -424,6 +499,9 @@ app.post('/api/courses/:id/episodes', { preHandler: auth }, async (req, reply) =
     [epId, id, epNumber, p.data.title, p.data.summary, p.data.lectureNotes]
   )
 
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '新增课程分集', '课程结构已更新。')
+
   return reply.code(201).send({ course: await fullCourse(row) })
 })
 
@@ -470,6 +548,9 @@ app.post('/api/courses/:id/episodes/batch', { preHandler: auth }, async (req, re
     return reply.code(400).send({ message: '请指定集数或分集列表' })
   }
 
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '批量新增课程分集', '课程结构已更新。')
+
   return reply.code(201).send({ course: await fullCourse(row) })
 })
 
@@ -478,7 +559,9 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
   const { id, episodeId } = req.params as any
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
-  if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改分集' })
+  if (!['DRAFT', 'WAITING_PRODUCTION'].includes(row.status)) {
+    return reply.code(409).send({ message: '课程已进入制作或完成归档,无法修改分集' })
+  }
 
   const p = z.object({
     title: z.string().trim().min(1, '标题不能为空').max(200).optional(),
@@ -503,6 +586,9 @@ app.patch('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (r
     [newTitle, newNumber, newSummary, newNotes, episodeId, id]
   )
 
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程内容已修改', '分集内容已更新。')
+
   return { course: await fullCourse(row) }
 })
 
@@ -526,6 +612,8 @@ app.delete('/api/courses/:id/episodes/:episodeId', { preHandler: auth }, async (
   }
 
   await pool.query('DELETE FROM episodes WHERE id = $1 AND course_id = $2', [episodeId, id])
+  await refreshCoursePoints(id)
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '删除课程分集', '课程结构已更新。')
   return { course: await fullCourse(row) }
 })
 
@@ -535,14 +623,14 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
   if (!row) return reply.code(404).send({ message: '课程不存在' })
   if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
   const fields: any = {}
-  let image: any = null
+  const images: any[] = []
   for await (const part of req.parts()) {
     if (part.type === 'file') {
       if (!part.mimetype.startsWith('image/')) return reply.code(400).send({ message: '讲师图片必须是图片格式' })
       const ext = extname(part.filename).slice(0, 12)
       const stored = 'instructor-' + randomUUID() + ext
       await pipeline(part.file, createWriteStream(resolve(uploadDir, stored)))
-      image = { original: part.filename, stored, mime: part.mimetype }
+      images.push({ original: part.filename, stored, mime: part.mimetype })
     } else {
       fields[part.fieldname] = part.value
     }
@@ -555,14 +643,16 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
     })
     .safeParse(fields)
   if (!p.success) {
-    if (image) await unlink(resolve(uploadDir, image.stored)).catch(() => {})
+    await Promise.all(images.map((image) => unlink(resolve(uploadDir, image.stored)).catch(() => {})))
     return reply.code(400).send({ message: '请填写讲师姓名' })
   }
   const d = p.data
+  const instructorId = randomUUID()
+  const image = images[0]
   const { rows } = await pool.query(
     'INSERT INTO instructors(id, course_id, name, organization, introduction, image_original_name, image_stored_name, image_mime_type) VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *',
     [
-      randomUUID(),
+      instructorId,
       id,
       d.name,
       d.organization,
@@ -572,6 +662,12 @@ app.post('/api/courses/:id/instructors', { preHandler: auth }, async (req, reply
       image?.mime || null
     ]
   )
+  for (const [index, uploadedImage] of images.entries()) {
+    await pool.query(
+      'INSERT INTO instructor_images(id, instructor_id, original_name, stored_name, mime_type, sort_order) VALUES($1, $2, $3, $4, $5, $6)',
+      [randomUUID(), instructorId, uploadedImage.original, uploadedImage.stored, uploadedImage.mime, index]
+    )
+  }
   return reply.code(201).send({ instructor: mapInstructor(rows[0]), course: await fullCourse(row) })
 })
 
@@ -580,21 +676,29 @@ app.delete('/api/courses/:id/instructors/:instructorId', { preHandler: auth }, a
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
   if (row.status !== 'DRAFT') return reply.code(409).send({ message: '课程已提交,不可修改讲师信息' })
+  const imageRows = await pool.query('SELECT stored_name FROM instructor_images WHERE instructor_id = $1', [instructorId])
   const { rows } = await pool.query(
     'DELETE FROM instructors WHERE id = $1 AND course_id = $2 RETURNING image_stored_name',
     [instructorId, id]
   )
-  if (rows[0]?.image_stored_name) await unlink(resolve(uploadDir, rows[0].image_stored_name)).catch(() => {})
+  const storedNames = new Set(imageRows.rows.map((image) => image.stored_name))
+  if (rows[0]?.image_stored_name) storedNames.add(rows[0].image_stored_name)
+  await Promise.all([...storedNames].map((storedName) => unlink(resolve(uploadDir, storedName as string)).catch(() => {})))
   return { course: await fullCourse(row) }
 })
 
 app.post('/api/courses/:id/submit', { preHandler: auth }, async (req, reply) => {
   const { id } = req.params as any
+  const estimate = await refreshCoursePoints(id)
+  const balance = Number((req as any).user.points_balance) || 0
+  const estimatedPoints = estimate.points
+  if (balance < estimatedPoints) return reply.code(409).send({ message: `积分不足:本课程需要 ${estimatedPoints} 积分,当前余额 ${balance} 积分` })
   const { rows } = await pool.query(
     "UPDATE courses SET status = 'WAITING_PRODUCTION', submitted_at = NOW(), updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status = 'DRAFT' RETURNING *",
     [id, userId(req)]
   )
   if (!rows[0]) return reply.code(409).send({ message: '课程已经提交或不存在' })
+  await addProgressEvent(id, 'USER', 'SUBMITTED', '已提交制作', '课程已进入制作队列。')
   return { course: await fullCourse(rows[0]) }
 })
 
@@ -703,12 +807,28 @@ app.post('/api/admin/courses/:id/status', { preHandler: adminAuth }, async (req,
     status: z.enum(['DRAFT', 'WAITING_PRODUCTION', 'IN_PRODUCTION', 'COMPLETED', 'REJECTED'])
   }).safeParse(req.body)
   if (!p.success) return reply.code(400).send({ message: '无效的课程状态' })
+  if (p.data.status === 'COMPLETED') return reply.code(400).send({ message: '请通过“确认交付”完成课程并结算积分' })
 
   const { rows } = await pool.query(
     'UPDATE courses SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
     [p.data.status, id]
   )
   if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  const statusNames: Record<string, string> = { DRAFT: '草稿', WAITING_PRODUCTION: '等待制作', IN_PRODUCTION: '正在制作', COMPLETED: '已完成', REJECTED: '已驳回' }
+  await addProgressEvent(id, 'ADMIN', 'STATUS_CHANGED', '制作状态已更新', `管理员将课程状态更新为“${statusNames[p.data.status]}”。`, { status: p.data.status })
+  return { course: await fullCourse(rows[0], true) }
+})
+
+app.patch('/api/admin/courses/:id/points', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const p = z.object({ points: z.number().int().min(1).max(100000), reason: z.string().max(500).default('根据实际制作需求调整') }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '请输入有效的积分消耗(至少 1 积分)' })
+  const previous = await pool.query('SELECT * FROM courses WHERE id = $1', [id])
+  if (!previous.rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  if (previous.rows[0].points_charged) return reply.code(409).send({ message: '课程已完成扣费,不能再修改积分' })
+  const { rows } = await pool.query('UPDATE courses SET actual_points = $1, updated_at = NOW() WHERE id = $2 RETURNING *', [p.data.points, id])
+  const oldPoints = previous.rows[0].actual_points ?? previous.rows[0].estimated_points
+  await addProgressEvent(id, 'ADMIN', 'POINTS_UPDATED', '积分消耗已调整', `管理员将制作消耗从 ${oldPoints} 积分调整为 ${p.data.points} 积分。${p.data.reason}`, { previousPoints: Number(oldPoints), points: p.data.points })
   return { course: await fullCourse(rows[0], true) }
 })
 
@@ -756,6 +876,8 @@ app.post('/api/admin/courses/:id/deliverables', { preHandler: adminAuth }, async
     )
   }
 
+  await addProgressEvent(id, 'ADMIN', 'DELIVERABLE_UPLOADED', '管理员上传了制作成果', `新增 ${fileList.length} 个交付文件。`)
+
   return { course: await fullCourse(check.rows[0], true) }
 })
 
@@ -781,11 +903,23 @@ app.post('/api/admin/courses/:id/complete', { preHandler: adminAuth }, async (re
   }).safeParse(req.body || {})
   if (!p.success) return reply.code(400).send({ message: '交付说明格式不正确' })
 
-  const { rows } = await pool.query(
-    "UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *",
-    [p.data.productionNotes, id]
-  )
-  if (!rows[0]) return reply.code(404).send({ message: '课程不存在' })
+  const client = await pool.connect()
+  let rows: any[] = []
+  try {
+    await client.query('BEGIN')
+    const current = await client.query('SELECT * FROM courses WHERE id = $1 FOR UPDATE', [id])
+    if (!current.rows[0]) { await client.query('ROLLBACK'); return reply.code(404).send({ message: '课程不存在' }) }
+    const cost = Number(current.rows[0].actual_points ?? current.rows[0].estimated_points) || 100
+    if (!current.rows[0].points_charged) {
+      const charged = await client.query('UPDATE users SET points_balance = points_balance - $1, updated_at = NOW() WHERE id = $2 AND points_balance >= $1 RETURNING points_balance', [cost, current.rows[0].user_id])
+      if (!charged.rows[0]) { await client.query('ROLLBACK'); return reply.code(409).send({ message: `用户积分不足,完成制作需要扣除 ${cost} 积分` }) }
+    }
+    const updated = await client.query("UPDATE courses SET status = 'COMPLETED', production_notes = $1, completed_at = NOW(), points_charged = TRUE, updated_at = NOW() WHERE id = $2 RETURNING *", [p.data.productionNotes, id])
+    rows = updated.rows
+    await client.query('COMMIT')
+  } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
+  const cost = Number(rows[0].actual_points ?? rows[0].estimated_points) || 100
+  await addProgressEvent(id, 'ADMIN', 'COMPLETED', '课程制作完成', `课程已交付,实际扣除 ${cost} 积分。`, { points: cost })
   return { course: await fullCourse(rows[0], true) }
 })
 
@@ -823,7 +957,62 @@ app.get('/api/admin/users', { preHandler: adminAuth }, async (req) => {
   return { users }
 })
 
-// 9. 修改用户角色(赋予或撤销管理员权限)
+// 9. 管理员创建普通用户
+app.post('/api/admin/users', { preHandler: adminAuth }, async (req, reply) => {
+  const p = z.object({
+    phone: z.string().regex(/^1\d{10}$/, '请输入正确的手机号码'),
+    password: z.string().min(6, '密码至少 6 位').max(72),
+    contactName: z.string().trim().max(50, '姓名不能超过 50 个字').optional().default(''),
+    organization: z.string().trim().max(100, '单位不能超过 100 个字').optional().default('')
+  }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+
+  try {
+    const id = randomUUID()
+    const passwordHash = await hashPassword(p.data.password)
+    const { rows } = await pool.query(
+      `INSERT INTO users(id, phone, password_hash, role, contact_name, organization, points_balance)
+       VALUES($1, $2, $3, 'USER', $4, $5, 10000) RETURNING *`,
+      [id, p.data.phone, passwordHash, p.data.contactName, p.data.organization]
+    )
+    return reply.code(201).send({ user: mapUser(rows[0]) })
+  } catch (e: any) {
+    if (e.code === '23505') return reply.code(409).send({ message: '该手机号已注册' })
+    throw e
+  }
+})
+
+// 10. 管理员重置用户密码;同时注销该用户已有会话
+app.patch('/api/admin/users/:id/password', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const p = z.object({
+    password: z.string().min(6, '密码至少 6 位').max(72)
+  }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+
+  const client = await pool.connect()
+  try {
+    await client.query('BEGIN')
+    const { rowCount } = await client.query(
+      'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2',
+      [await hashPassword(p.data.password), id]
+    )
+    if (!rowCount) {
+      await client.query('ROLLBACK')
+      return reply.code(404).send({ message: '用户不存在' })
+    }
+    await client.query('DELETE FROM sessions WHERE user_id = $1', [id])
+    await client.query('COMMIT')
+    return { ok: true }
+  } catch (error) {
+    await client.query('ROLLBACK')
+    throw error
+  } finally {
+    client.release()
+  }
+})
+
+// 11. 修改用户角色(赋予或撤销管理员权限)
 app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, reply) => {
   const { id } = req.params as any
   const currentAdmin = (req as any).user

+ 39 - 1
server/schema.sql

@@ -7,11 +7,14 @@ CREATE TABLE IF NOT EXISTS users (
   wechat VARCHAR(80) NOT NULL DEFAULT '',
   contact_name VARCHAR(80) NOT NULL DEFAULT '',
   bio VARCHAR(500) NOT NULL DEFAULT '',
+  points_balance INT NOT NULL DEFAULT 10000 CHECK (points_balance >= 0),
   created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
   updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
 );
 
 ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
+ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INT NOT NULL DEFAULT 10000;
+ALTER TABLE users ALTER COLUMN points_balance SET DEFAULT 10000;
 
 CREATE TABLE IF NOT EXISTS sessions (
   token_hash CHAR(64) PRIMARY KEY,
@@ -26,10 +29,13 @@ CREATE TABLE IF NOT EXISTS courses (
   user_id UUID REFERENCES users(id) ON DELETE CASCADE,
   name VARCHAR(100) NOT NULL,
   category VARCHAR(50) NOT NULL DEFAULT '',
-  audience VARCHAR(100) NOT NULL DEFAULT '',
   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 '',
+  estimated_minutes INT NOT NULL DEFAULT 1,
+  estimated_points INT NOT NULL DEFAULT 1000,
+  actual_points INT,
+  points_charged BOOLEAN NOT NULL DEFAULT FALSE,
   ppt_original_name VARCHAR(255),
   ppt_stored_name VARCHAR(255),
   ppt_size BIGINT,
@@ -43,6 +49,11 @@ CREATE TABLE IF NOT EXISTS courses (
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
 ALTER TABLE courses ADD COLUMN IF NOT EXISTS production_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;
+ALTER TABLE courses ALTER COLUMN estimated_points SET DEFAULT 1000;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS actual_points INT;
+ALTER TABLE courses ADD COLUMN IF NOT EXISTS points_charged BOOLEAN NOT NULL DEFAULT FALSE;
 
 DO $$
 BEGIN
@@ -95,6 +106,17 @@ CREATE TABLE IF NOT EXISTS instructors (
 );
 CREATE INDEX IF NOT EXISTS idx_instructors_course ON instructors(course_id, created_at);
 
+CREATE TABLE IF NOT EXISTS instructor_images (
+  id UUID PRIMARY KEY,
+  instructor_id UUID NOT NULL REFERENCES instructors(id) ON DELETE CASCADE,
+  original_name VARCHAR(255) NOT NULL,
+  stored_name VARCHAR(255) NOT NULL,
+  mime_type VARCHAR(100) NOT NULL,
+  sort_order INTEGER NOT NULL DEFAULT 0,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_instructor_images_instructor ON instructor_images(instructor_id, sort_order, created_at);
+
 CREATE TABLE IF NOT EXISTS course_deliverables (
   id UUID PRIMARY KEY,
   course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
@@ -108,3 +130,19 @@ CREATE TABLE IF NOT EXISTS course_deliverables (
 ALTER TABLE course_deliverables ADD COLUMN IF NOT EXISTS episode_id UUID REFERENCES episodes(id) ON DELETE SET NULL;
 CREATE INDEX IF NOT EXISTS idx_deliverables_course ON course_deliverables(course_id, created_at);
 CREATE INDEX IF NOT EXISTS idx_deliverables_episode ON course_deliverables(episode_id, created_at);
+
+CREATE TABLE IF NOT EXISTS course_progress_events (
+  id UUID PRIMARY KEY,
+  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
+  actor_type VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
+  event_type VARCHAR(40) NOT NULL,
+  title VARCHAR(120) NOT NULL,
+  description TEXT NOT NULL DEFAULT '',
+  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_course_progress_events ON course_progress_events(course_id, created_at DESC);
+
+-- 默认按每集 1000 积分计费,并同步刷新已有课程的默认值。
+UPDATE courses c
+SET estimated_points = GREATEST(1, (SELECT COUNT(*) FROM episodes e WHERE e.course_id = c.id)) * 1000;

+ 1 - 8
server/templates.ts

@@ -58,19 +58,16 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
       {
         episodeNumber: 1,
         title: '第一讲:江山就是人民,人民就是江山',
-        summary: '阐释人民至上的理论渊源与历史必然性。',
         lectureNotes: '同志们好!今天我们开始第一讲的专题学习。坚持人民至上,是我们党的根本立场,也是马克思主义唯物史观的集中体现……'
       },
       {
         episodeNumber: 2,
         title: '第二讲:把人民对美好生活的向往作为奋斗目标',
-        summary: '结合新时代生动实践,论述高质量发展中保障和改善民生。',
         lectureNotes: '在第二讲中,我们将从实践维度来深入领会。治国有常,利民为本。如何把发展成果转化为人民群众实实在在的幸福感……'
       },
       {
         episodeNumber: 3,
         title: '第三讲:紧紧依靠人民创造历史伟业',
-        summary: '总结提炼群众路线与青年党员担当使命。',
         lectureNotes: '第三讲,我们聚焦新时代青年理论骨干的使命担当。历史是人民书写的,一切成就归功于人民……'
       }
     ],
@@ -108,7 +105,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
     category: '高校公开课',
-    audience: '理工科大一新生 / 考研复习群体',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
@@ -155,19 +151,16 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
       {
         episodeNumber: 1,
         title: '第 1 集:极限的直观与严密定义 (ε-δ 语言)',
-        summary: '从割线切线问题切入,通过动画直观展现数列与函数极限。',
         lectureNotes: '同学们好!欢迎来到《深入浅出微积分》第1集。今天我们要探索微积分大厦的基石——极限。首先我们来看一个割线逼近切线的动态图像……'
       },
       {
         episodeNumber: 2,
         title: '第 2 集:导数的几何意义与瞬时变化率',
-        summary: '剖析速度与切线斜率的统一物理数学背景,推导核心求导法则。',
         lectureNotes: '在第2集中,我们将从瞬时速度的物理问题出发,正式引出导数定义。请大家看屏幕左侧的割线斜率变化……'
       },
       {
         episodeNumber: 3,
         title: '第 3 集:微分中值定理与函数单调性',
-        summary: '罗尔定理与拉格朗日中值定理的几何证明及在极值分析中的应用。',
         lectureNotes: '第3集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
       }
     ],
@@ -256,7 +249,7 @@ export async function populateCourseFromTemplate(
       await pool.query(
         `INSERT INTO episodes(id, course_id, episode_number, title, summary, lecture_notes)
          VALUES($1, $2, $3, $4, $5, $6)`,
-        [epId, courseId, ep.episodeNumber, ep.title, ep.summary || '', ep.lectureNotes || '']
+        [epId, courseId, ep.episodeNumber, ep.title, '', ep.lectureNotes || '']
       )
     }
   }

+ 10 - 2
src/App.tsx

@@ -33,7 +33,7 @@ export default function App() {
 
       {/* ================= 普通用户体系 ================= */}
       <Route path="login" element={<AuthPage />} />
-      {user ? (
+      {user && user.role !== 'ADMIN' ? (
         <Route element={<Layout />}>
           <Route index element={<Dashboard />} />
           <Route path="courses/new" element={<NewCourse />} />
@@ -44,7 +44,15 @@ export default function App() {
       ) : null}
 
       {/* 默认兜底重定向 */}
-      <Route path="*" element={<Navigate to={user ? '/' : '/login'} replace />} />
+      <Route
+        path="*"
+        element={
+          <Navigate
+            to={!user ? '/login' : user.role === 'ADMIN' ? '/admin' : '/'}
+            replace
+          />
+        }
+      />
     </Routes>
   )
 }

+ 29 - 3
src/api.ts

@@ -57,13 +57,19 @@ export const api = {
 
   // 普通用户课程操作
   listCourses: () => request<{ courses: Course[] }>('/courses'),
-  createCourse: (body: Pick<Course, 'name' | 'category' | 'audience' | 'description'>) =>
+  createCourse: (body: Pick<Course, 'name' | 'category' | 'description'>) =>
     request<{ course: Course }>('/courses', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(body)
     }),
   getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
+  updateCourse: (id: string, body: Pick<Course, 'name' | 'category' | 'description'>) =>
+    request<{ course: Course }>(`/courses/${id}`, {
+      method: 'PATCH',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body)
+    }),
   deleteCourse: (id: string) => request<{ ok: boolean; message?: string }>(`/courses/${id}`, { method: 'DELETE' }),
   uploadAssets: (id: string, files: File[], episodeId?: string) => {
     const form = new FormData()
@@ -108,18 +114,20 @@ export const api = {
 
   addInstructor: (
     id: string,
-    data: { name: string; organization: string; introduction: string; image?: File }
+    data: { name: string; organization: string; introduction: string; images?: File[] }
   ) => {
     const f = new FormData()
     f.append('name', data.name)
     f.append('organization', data.organization)
     f.append('introduction', data.introduction)
-    if (data.image) f.append('image', data.image)
+    data.images?.forEach((image) => f.append('images', image))
     return request<{ course: Course }>(`/courses/${id}/instructors`, { method: 'POST', body: f })
   },
   deleteInstructor: (id: string, instructorId: string) =>
     request<{ course: Course }>(`/courses/${id}/instructors/${instructorId}`, { method: 'DELETE' }),
   submitCourse: (id: string) => request<{ course: Course }>(`/courses/${id}/submit`, { method: 'POST' }),
+  cancelCourseSubmission: (id: string) =>
+    request<{ course: Course }>(`/courses/${id}/cancel-submission`, { method: 'POST' }),
 
   // 管理员后台专区 API
   admin: {
@@ -144,6 +152,12 @@ export const api = {
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ status })
       }),
+    updatePoints: (id: string, points: number, reason: string) =>
+      request<{ course: Course }>(`/admin/courses/${id}/points`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ points, reason })
+      }),
     uploadDeliverables: (id: string, files: File[], episodeId?: string) => {
       const form = new FormData()
       files.forEach((f) => form.append('deliverables', f))
@@ -162,6 +176,18 @@ export const api = {
       const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
       return request<{ users: User[] }>(`/admin/users${qs}`)
     },
+    createUser: (body: { phone: string; password: string; contactName?: string; organization?: string }) =>
+      request<{ user: User }>('/admin/users', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body)
+      }),
+    resetUserPassword: (id: string, password: string) =>
+      request<{ ok: boolean }>(`/admin/users/${id}/password`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ password })
+      }),
     updateUserRole: (id: string, role: 'USER' | 'ADMIN') =>
       request<{ user: User }>(`/admin/users/${id}/role`, {
         method: 'PATCH',

+ 3 - 4
src/components/AddInstructorModal.tsx

@@ -19,6 +19,7 @@ export function AddInstructorModal({
 }: AddInstructorModalProps) {
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
+  const [images, setImages] = useState<File[]>([])
 
   useEffect(() => {
     function handleKeyDown(e: KeyboardEvent) {
@@ -36,7 +37,6 @@ export function AddInstructorModal({
     e.preventDefault()
     const form = e.currentTarget
     const f = new FormData(form)
-    const image = form.elements.namedItem('image') as HTMLInputElement
 
     const name = String(f.get('name') || '').trim()
     if (!name) {
@@ -51,7 +51,7 @@ export function AddInstructorModal({
         name,
         organization: String(f.get('organization') || '').trim(),
         introduction: String(f.get('introduction') || '').trim(),
-        image: image?.files?.[0]
+        images
       })
       onSuccess(res.course)
       onClose()
@@ -70,7 +70,6 @@ export function AddInstructorModal({
       >
         <div className="modal-header">
           <div>
-            <span className="modal-badge">INSTRUCTORS</span>
             <h3>添加讲师信息</h3>
           </div>
           <button
@@ -119,7 +118,7 @@ export function AddInstructorModal({
               />
             </label>
 
-            <InstructorImageField disabled={busy} />
+            <InstructorImageField disabled={busy} onFileChange={setImages} />
           </div>
 
           <div className="modal-footer">

+ 32 - 0
src/components/CourseProductionProgress.tsx

@@ -0,0 +1,32 @@
+import type { Course } from '../types'
+import { StatusBadge } from './StatusBadge'
+
+export function CourseProductionProgress({ course }: { course: Course }) {
+  return (
+    <section className="paper-form production-progress-card">
+      <div className="progress-card-head">
+        <div>
+          <div className="progress-title-row">
+            <h3>课程制作进度</h3>
+            <StatusBadge status={course.status} />
+          </div>
+        </div>
+      </div>
+      <div className="progress-timeline">
+        {(course.progressEvents || []).map((event) => (
+          <div className="progress-event" key={event.id}>
+            <span className="progress-dot" />
+            <div>
+              <div className="progress-event-title">
+                <strong>{event.title}</strong>
+                <time>{new Date(event.createdAt).toLocaleString('zh-CN')}</time>
+              </div>
+              {event.description && <p>{event.description}</p>}
+            </div>
+          </div>
+        ))}
+        {!course.progressEvents?.length && <p className="empty-tip">课程创建后,制作动态会显示在这里。</p>}
+      </div>
+    </section>
+  )
+}

+ 111 - 0
src/components/EditCourseModal.tsx

@@ -0,0 +1,111 @@
+import { FormEvent, useEffect, useState } from 'react'
+import { Save, X } from 'lucide-react'
+import { api } from '../api'
+import type { Course } from '../types'
+
+interface EditCourseModalProps {
+  course: Course
+  isOpen: boolean
+  onClose: () => void
+  onSuccess: (course: Course) => void
+}
+
+export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCourseModalProps) {
+  const [name, setName] = useState(course.name)
+  const [category, setCategory] = useState(course.category)
+  const [description, setDescription] = useState(course.description)
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    if (!isOpen) return
+    setName(course.name)
+    setCategory(course.category)
+    setDescription(course.description)
+    setError('')
+  }, [isOpen, course])
+
+  useEffect(() => {
+    function handleKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape' && isOpen && !busy) onClose()
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [isOpen, busy, onClose])
+
+  if (!isOpen) return null
+
+  async function handleSubmit(e: FormEvent) {
+    e.preventDefault()
+    if (!name.trim()) {
+      setError('请输入课程名称')
+      return
+    }
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.updateCourse(course.id, {
+        name: name.trim(),
+        category,
+        description: description.trim()
+      })
+      onSuccess(res.course)
+      onClose()
+    } catch (err) {
+      setError((err as Error).message || '保存课程信息失败,请重试')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="modal-backdrop" onClick={() => !busy && onClose()}>
+      <div className="modal-dialog edit-course-modal" onClick={(e) => e.stopPropagation()}>
+        <div className="modal-header">
+          <div>
+            <h3>修改课程信息</h3>
+          </div>
+          <button type="button" className="close-modal-btn" onClick={onClose} disabled={busy} title="关闭">
+            <X size={18} />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="modal-form-content">
+          <div className="modal-body edit-course-modal-body">
+            {error && <div className="notice notice-error">{error}</div>}
+            <label className="form-label required">
+              <span>课程名称</span>
+              <input autoFocus required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} disabled={busy} />
+            </label>
+            <label className="form-label">
+              <span>课程分类</span>
+              <select value={category} onChange={(e) => setCategory(e.target.value)} disabled={busy}>
+                {category && !['党政专题', '高校公开课', '企业培训', '职业教育', '其他'].includes(category) && (
+                  <option value={category}>{category}</option>
+                )}
+                <option value="党政专题">党政专题</option>
+                <option value="高校公开课">高校公开课</option>
+                <option value="企业培训">企业培训</option>
+                <option value="职业教育">职业教育</option>
+                <option value="其他">其他</option>
+              </select>
+            </label>
+            <label className="form-label">
+              <span className="edit-course-field-heading">
+                <span>课程简介与制作要求</span>
+                <small className="field-count">{description.length}/500</small>
+              </span>
+              <textarea rows={7} maxLength={500} value={description} onChange={(e) => setDescription(e.target.value)} disabled={busy} placeholder="填写课程概述、目标人群、运镜风格、语速或技术规格要求等" />
+            </label>
+          </div>
+          <div className="modal-footer">
+            <button type="button" className="secondary-button" onClick={onClose} disabled={busy}>取消</button>
+            <button type="submit" className="primary-button" disabled={busy || !name.trim()}>
+              <Save size={16} /> {busy ? '正在保存…' : '保存修改'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  )
+}

+ 2 - 21
src/components/EpisodeModal.tsx

@@ -25,7 +25,6 @@ export function EpisodeModal({
   // 单集表单状态
   const [title, setTitle] = useState('')
   const [episodeNumber, setEpisodeNumber] = useState(nextNumber)
-  const [summary, setSummary] = useState('')
   const [lectureNotes, setLectureNotes] = useState('')
   const [showOptionalFields, setShowOptionalFields] = useState(false)
 
@@ -42,14 +41,12 @@ export function EpisodeModal({
         setActiveTab('single')
         setTitle(episode.title)
         setEpisodeNumber(episode.episodeNumber)
-        setSummary(episode.summary || '')
         setLectureNotes(episode.lectureNotes || '')
-        setShowOptionalFields(!!(episode.summary?.trim() || episode.lectureNotes?.trim()))
+        setShowOptionalFields(!!episode.lectureNotes?.trim())
       } else {
         setActiveTab('single')
         setTitle(`第 ${nextNumber} 集:`)
         setEpisodeNumber(nextNumber)
-        setSummary('')
         setLectureNotes('')
         setShowOptionalFields(false)
         setBatchCount(3)
@@ -98,14 +95,12 @@ export function EpisodeModal({
         res = await api.updateEpisode(courseId, episode.id, {
           title: title.trim(),
           episodeNumber: Number(episodeNumber) || 1,
-          summary: summary.trim(),
           lectureNotes: lectureNotes.trim()
         })
       } else {
         res = await api.createEpisode(courseId, {
           title: title.trim(),
           episodeNumber: Number(episodeNumber) || nextNumber,
-          summary: summary.trim(),
           lectureNotes: lectureNotes.trim()
         })
       }
@@ -130,7 +125,6 @@ export function EpisodeModal({
           episodes: parsedBatchTitles.map((t, idx) => ({
             title: t,
             episodeNumber: nextNumber + idx,
-            summary: '',
             lectureNotes: ''
           }))
         })
@@ -154,7 +148,6 @@ export function EpisodeModal({
       <div className="modal-dialog episode-modal" onClick={(e) => e.stopPropagation()}>
         <div className="modal-header">
           <div>
-            <span className="modal-badge">EPISODES</span>
             <h3>{episode ? '编辑课程分集' : '添加课程分集'}</h3>
           </div>
           <button
@@ -230,24 +223,12 @@ export function EpisodeModal({
                   onClick={() => setShowOptionalFields(!showOptionalFields)}
                 >
                   {showOptionalFields ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
-                  <span>{showOptionalFields ? '收起选填信息' : '+ 填写分集简介或口播讲稿(选填)'}</span>
+                  <span>{showOptionalFields ? '收起口播讲稿' : '+ 填写分集口播讲稿(选填)'}</span>
                 </button>
               </div>
 
               {showOptionalFields && (
                 <div className="optional-fields-container">
-                  <label className="form-label">
-                    <span>分集简介 / 制作要求(选填)</span>
-                    <input
-                      type="text"
-                      maxLength={500}
-                      value={summary}
-                      onChange={(e) => setSummary(e.target.value)}
-                      placeholder="本集核心要点、运镜重点或特殊说明"
-                      disabled={busy}
-                    />
-                  </label>
-
                   <label className="form-label">
                     <div className="label-with-badge">
                       <span>分集口播讲稿正文(选填)</span>

+ 37 - 60
src/components/InstructorImageField.tsx

@@ -4,37 +4,33 @@ import sampleStandImg from "../assets/sample/人物_站.jpg"
 import sampleSitImg from "../assets/sample/人物_坐.jpg"
 
 interface InstructorImageFieldProps {
-  onFileChange?: (file: File | null) => void
+  onFileChange?: (files: File[]) => void
   disabled?: boolean
 }
 
 export function InstructorImageField({ onFileChange, disabled }: InstructorImageFieldProps) {
   const fileInputRef = useRef<HTMLInputElement>(null)
-  const [selectedFile, setSelectedFile] = useState<File | null>(null)
-  const [previewUrl, setPreviewUrl] = useState<string | null>(null)
+  const [selectedFiles, setSelectedFiles] = useState<Array<{ file: File; previewUrl: string }>>([])
   const [isDragging, setIsDragging] = useState(false)
   const [activeModalSample, setActiveModalSample] = useState<"stand" | "sit" | null>(null)
   const [showFullGuide, setShowFullGuide] = useState(false)
 
-  function handleFile(file: File | undefined) {
-    if (!file) return
-    if (!file.type.startsWith("image/")) {
+  function handleFiles(files: File[]) {
+    if (!files.length) return
+    const validFiles = files.filter((file) => file.type.startsWith("image/"))
+    if (validFiles.length !== files.length) {
       alert("请上传图片文件(JPG、PNG、WEBP 等)")
-      return
-    }
-    if (previewUrl) {
-      URL.revokeObjectURL(previewUrl)
-    }
-    setSelectedFile(file)
-    setPreviewUrl(URL.createObjectURL(file))
-    if (onFileChange) {
-      onFileChange(file)
     }
+    if (!validFiles.length) return
+    setSelectedFiles((current) => {
+      const next = [...current, ...validFiles.map((file) => ({ file, previewUrl: URL.createObjectURL(file) }))]
+      onFileChange?.(next.map((item) => item.file))
+      return next
+    })
   }
 
   function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
-    const file = e.target.files?.[0]
-    handleFile(file)
+    handleFiles(Array.from(e.target.files || []))
   }
 
   function handleDragOver(e: DragEvent<HTMLDivElement>) {
@@ -54,30 +50,19 @@ export function InstructorImageField({ onFileChange, disabled }: InstructorImage
     e.stopPropagation()
     setIsDragging(false)
     if (disabled) return
-    const file = e.dataTransfer.files?.[0]
-    if (file) {
-      handleFile(file)
-      // 同步到 input
-      if (fileInputRef.current) {
-        const dataTransfer = new DataTransfer()
-        dataTransfer.items.add(file)
-        fileInputRef.current.files = dataTransfer.files
-      }
-    }
+    handleFiles(Array.from(e.dataTransfer.files || []))
   }
 
-  function clearFile() {
-    if (previewUrl) {
-      URL.revokeObjectURL(previewUrl)
-    }
-    setSelectedFile(null)
-    setPreviewUrl(null)
-    if (fileInputRef.current) {
-      fileInputRef.current.value = ""
-    }
-    if (onFileChange) {
-      onFileChange(null)
-    }
+  function removeFile(index: number) {
+    setSelectedFiles((current) => {
+      URL.revokeObjectURL(current[index].previewUrl)
+      const next = current.filter((_, itemIndex) => itemIndex !== index)
+      const dataTransfer = new DataTransfer()
+      next.forEach((item) => dataTransfer.items.add(item.file))
+      if (fileInputRef.current) fileInputRef.current.files = dataTransfer.files
+      onFileChange?.(next.map((item) => item.file))
+      return next
+    })
   }
 
   const formatSize = (bytes: number) => {
@@ -93,16 +78,16 @@ export function InstructorImageField({ onFileChange, disabled }: InstructorImage
       <input
         ref={fileInputRef}
         type="file"
-        name="image"
+        name="images"
         accept="image/*"
+        multiple
         style={{ display: "none" }}
         onChange={handleInputChange}
         disabled={disabled}
       />
 
       {/* 上传区域 */}
-      {!selectedFile ? (
-        <div
+      <div
           className={`instructor-upload-zone ${isDragging ? "dragging" : ""}`}
           onDragOver={handleDragOver}
           onDragLeave={handleDragLeave}
@@ -113,32 +98,24 @@ export function InstructorImageField({ onFileChange, disabled }: InstructorImage
             <UploadCloud size={24} />
           </div>
           <div className="upload-zone-text">
-            <strong>点击或拖拽上传讲师出镜照</strong>
-            <span>支持 JPG / PNG / WEBP,建议 1080P 以上高清绿幕/纯色原图</span>
+            <strong>{selectedFiles.length ? '继续添加讲师出镜照' : '点击或拖拽上传讲师出镜照'}</strong>
+            <span>支持一次选择多张 JPG / PNG / WEBP,建议 1080P 以上高清绿幕/纯色原图</span>
           </div>
         </div>
-      ) : (
-        <div className="instructor-image-preview-card">
-          <img src={previewUrl || ""} alt="讲师照片预览" className="preview-thumbnail" />
+      {selectedFiles.map(({ file, previewUrl }, index) => (
+        <div className="instructor-image-preview-card" key={`${file.name}-${file.lastModified}-${index}`}>
+          <img src={previewUrl} alt={`讲师照片预览 ${index + 1}`} className="preview-thumbnail" />
           <div className="preview-info">
-            <span className="file-name" title={selectedFile.name}>
-              {selectedFile.name}
+            <span className="file-name" title={file.name}>
+              {file.name}
             </span>
-            <span className="file-size">{formatSize(selectedFile.size)} · 已就绪</span>
+            <span className="file-size">{formatSize(file.size)} · 第 {index + 1} 张 · 已就绪</span>
           </div>
           <div className="preview-actions">
-            <button
-              type="button"
-              className="action-btn change-btn"
-              onClick={() => fileInputRef.current?.click()}
-              disabled={disabled}
-            >
-              更换
-            </button>
             <button
               type="button"
               className="action-btn remove-btn"
-              onClick={clearFile}
+              onClick={() => removeFile(index)}
               disabled={disabled}
               title="移除照片"
             >
@@ -146,7 +123,7 @@ export function InstructorImageField({ onFileChange, disabled }: InstructorImage
             </button>
           </div>
         </div>
-      )}
+      ))}
 
       {/* 规范示例展示卡片 */}
       <div className="sample-guide-section">

+ 1 - 0
src/components/Layout.tsx

@@ -19,6 +19,7 @@ export function Layout() {
           </NavLink>
         </nav>
         <div className="top-actions">
+          {user?.role !== 'ADMIN' && <span className="points-balance-badge">积分 {user?.pointsBalance ?? 0}</span>}
           <NavLink to="/profile" className="user-link">
             <UserRound size={16} />
             <span>{user?.contactName || user?.phone}</span>

+ 0 - 8
src/data/courseTemplates.ts

@@ -7,7 +7,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
     id: 'party-building-standard',
     name: '《坚持人民至上根本立场》党课视频',
     category: '党政专题',
-    audience: '党员干部 / 青年理论学习小组',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
     summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
@@ -58,19 +57,16 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
       {
         episodeNumber: 1,
         title: '第一讲:江山就是人民,人民就是江山',
-        summary: '阐释人民至上的理论渊源与历史必然性。',
         lectureNotes: '同志们好!今天我们开始第一讲的专题学习。坚持人民至上,是我们党的根本立场,也是马克思主义唯物史观的集中体现……'
       },
       {
         episodeNumber: 2,
         title: '第二讲:把人民对美好生活的向往作为奋斗目标',
-        summary: '结合新时代生动实践,论述高质量发展中保障和改善民生。',
         lectureNotes: '在第二讲中,我们将从实践维度来深入领会。治国有常,利民为本。如何把发展成果转化为人民群众实实在在的幸福感……'
       },
       {
         episodeNumber: 3,
         title: '第三讲:紧紧依靠人民创造历史伟业',
-        summary: '总结提炼群众路线与青年党员担当使命。',
         lectureNotes: '第三讲,我们聚焦新时代青年理论骨干的使命担当。历史是人民书写的,一切成就归功于人民……'
       }
     ],
@@ -108,7 +104,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
     category: '高校公开课',
-    audience: '理工科大一新生 / 考研复习群体',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',
@@ -155,19 +150,16 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
       {
         episodeNumber: 1,
         title: '第 1 集:极限的直观与严密定义 (ε-δ 语言)',
-        summary: '从割线切线问题切入,通过动画直观展现数列与函数极限。',
         lectureNotes: '同学们好!欢迎来到《深入浅出微积分》第1集。今天我们要探索微积分大厦的基石——极限。首先我们来看一个割线逼近切线的动态图像……'
       },
       {
         episodeNumber: 2,
         title: '第 2 集:导数的几何意义与瞬时变化率',
-        summary: '剖析速度与切线斜率的统一物理数学背景,推导核心求导法则。',
         lectureNotes: '在第2集中,我们将从瞬时速度的物理问题出发,正式引出导数定义。请大家看屏幕左侧的割线斜率变化……'
       },
       {
         episodeNumber: 3,
         title: '第 3 集:微分中值定理与函数单调性',
-        summary: '罗尔定理与拉格朗日中值定理的几何证明及在极值分析中的应用。',
         lectureNotes: '第3集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
       }
     ],

+ 28 - 3
src/pages/AuthPage.tsx

@@ -11,15 +11,24 @@ export function AuthPage() {
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
 
-  if (user) return <Navigate to="/" replace />
+  if (user) {
+    return <Navigate to={user.role === 'ADMIN' ? '/admin' : '/'} replace />
+  }
 
   async function submit(e: FormEvent<HTMLFormElement>) {
     e.preventDefault()
     const f = new FormData(e.currentTarget)
+    const password = String(f.get('password'))
+
+    if (mode === 'register' && password !== String(f.get('confirmPassword'))) {
+      setError('两次输入的密码不一致')
+      return
+    }
+
     setBusy(true)
     setError('')
     try {
-      await authenticate(mode, String(f.get('phone')), String(f.get('password')))
+      await authenticate(mode, String(f.get('phone')), password)
     } catch (x) {
       setError(x instanceof Error ? x.message : '操作失败')
     } finally {
@@ -43,7 +52,6 @@ export function AuthPage() {
           </div>
         </section>
         <form className="auth-card" onSubmit={submit}>
-          <span className="section-kicker">WELCOME TO XINGHEN</span>
           <h2>{mode === 'login' ? '登录星痕' : '创建账号'}</h2>
           <p>{mode === 'login' ? '使用手机号和密码继续' : '注册后即可开始'}</p>
           <label>
@@ -66,12 +74,29 @@ export function AuthPage() {
               <input
                 name="password"
                 type="password"
+                autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
                 required
                 minLength={6}
                 placeholder="至少 6 位密码"
               />
             </div>
           </label>
+          {mode === 'register' && (
+            <label>
+              确认密码
+              <div className="input-with-icon">
+                <LockKeyhole />
+                <input
+                  name="confirmPassword"
+                  type="password"
+                  autoComplete="new-password"
+                  required
+                  minLength={6}
+                  placeholder="请再次输入密码"
+                />
+              </div>
+            </label>
+          )}
           {error && <div className="notice notice-error">{error}</div>}
           <button className="primary-button full-button" disabled={busy}>
             {busy ? '请稍候…' : mode === 'login' ? '登录' : '注册'}

+ 116 - 21
src/pages/CourseWorkspace.tsx

@@ -17,7 +17,6 @@ import {
   Plus,
   Save,
   Send,
-  Sparkles,
   Trash2,
   Upload,
   UserPlus,
@@ -31,6 +30,8 @@ import { AddInstructorModal } from '../components/AddInstructorModal'
 import { EpisodeModal } from '../components/EpisodeModal'
 import { DeleteCourseModal } from '../components/DeleteCourseModal'
 import { ContactModal } from '../components/ContactModal'
+import { EditCourseModal } from '../components/EditCourseModal'
+import { CourseProductionProgress } from '../components/CourseProductionProgress'
 import contactQrCode from '../assets/contact-group-qrcode.jpg'
 import type { Course, Episode } from '../types'
 
@@ -46,12 +47,15 @@ export function CourseWorkspace() {
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
   const [successNotice, setSuccessNotice] = useState('')
+  const [activeWorkspaceTab, setActiveWorkspaceTab] = useState<'content' | 'progress'>('content')
 
   // 弹窗状态
   const [showAddInstructorModal, setShowAddInstructorModal] = useState(false)
   const [showEpisodeModal, setShowEpisodeModal] = useState(false)
   const [showDeleteModal, setShowDeleteModal] = useState(false)
   const [showContactModal, setShowContactModal] = useState(false)
+  const [showEditCourseModal, setShowEditCourseModal] = useState(false)
+  const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
   const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
 
   // 全局资产待上传
@@ -196,6 +200,22 @@ export function CourseWorkspace() {
     }
   }
 
+  async function cancelSubmission() {
+    if (!confirm('确定取消等待制作并将课程恢复到草稿箱吗?恢复后可以继续编辑并重新提交。')) return
+    setBusy(true)
+    setError('')
+    try {
+      const res = await api.cancelCourseSubmission(id)
+      setCourse(res.course)
+      setIsEditingWaitingCourse(false)
+      notify('课程已恢复到草稿箱')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setBusy(false)
+    }
+  }
+
   // 切换折叠
   const toggleExpand = (epId: string) => {
     setExpandedMap((prev) => ({ ...prev, [epId]: !prev[epId] }))
@@ -216,6 +236,7 @@ export function CourseWorkspace() {
   const isProducing = course.status === 'IN_PRODUCTION'
   const isCompleted = course.status === 'COMPLETED'
   const isDraft = !isWaiting && !isProducing && !isCompleted
+  const canEditCourseContent = isDraft || (isWaiting && isEditingWaitingCourse)
 
   // 全局资产(未关联 episodeId)
   const globalAssets = course.assets?.filter((a) => !a.episodeId) || []
@@ -233,9 +254,12 @@ export function CourseWorkspace() {
           <div className="title-row">
             <h1>{course.name}</h1>
             <StatusBadge status={course.status} />
+            <button type="button" className="edit-course-info-btn" onClick={() => setShowEditCourseModal(true)}>
+              <Edit3 size={14} /> 修改
+            </button>
           </div>
           <p>
-            {course.category || '综合分类'}{course.audience ? ` · ${course.audience}` : ''}
+            {course.category || '综合分类'}
           </p>
         </div>
       </div>
@@ -247,15 +271,41 @@ export function CourseWorkspace() {
         </div>
       )}
 
+      <div className="course-workspace-tabs" role="tablist" aria-label="课程详情视图">
+        <button
+          type="button"
+          role="tab"
+          aria-selected={activeWorkspaceTab === 'content'}
+          className={activeWorkspaceTab === 'content' ? 'active' : ''}
+          onClick={() => setActiveWorkspaceTab('content')}
+        >
+          课程内容
+        </button>
+        <button
+          type="button"
+          role="tab"
+          aria-selected={activeWorkspaceTab === 'progress'}
+          className={activeWorkspaceTab === 'progress' ? 'active' : ''}
+          onClick={() => setActiveWorkspaceTab('progress')}
+        >
+          制作进度
+          {!!course.progressEvents?.length && <span>{course.progressEvents.length}</span>}
+        </button>
+      </div>
+
+      {activeWorkspaceTab === 'progress' ? (
+        <div className="course-progress-tab-panel" role="tabpanel">
+          <CourseProductionProgress course={course} />
+        </div>
+      ) : (
+        <div className="course-content-tab-panel" role="tabpanel">
+
       {/* 制作完成交付成果面板 */}
       {isCompleted && (
         <section className="success-panel completed-panel">
           <div className="success-badge-icon">
             <PackageCheck size={48} />
           </div>
-          <p className="eyebrow">
-            <Sparkles size={14} /> PRODUCTION COMPLETED
-          </p>
           <h2>课程制作已完成!</h2>
           <p>制作专家已完成全套课程打磨与成片制作,请查收下方各分集交付成果及制作说明。</p>
 
@@ -371,7 +421,6 @@ export function CourseWorkspace() {
           <div className="producing-orbit-icon">
             <LoaderCircle className="spin" size={48} />
           </div>
-          <p className="eyebrow">IN PRODUCTION</p>
           <h2>课程正在精心制作中</h2>
           <p>
             平台课程制作师已接单并正在处理您的 {episodes.length > 0 ? `${episodes.length} 个分集讲稿与课件素材` : '课件素材'},制作完成后将第一时间在此处按集交付成品。
@@ -411,10 +460,9 @@ export function CourseWorkspace() {
       )}
 
       {/* 等待制作面板 */}
-      {isWaiting && (
+      {isWaiting && !isEditingWaitingCourse && (
         <section className="success-panel">
           <CheckCircle2 size={48} />
-          <p className="eyebrow">SUBMISSION RECEIVED</p>
           <h2>课程已提交,等待排期制作</h2>
           <p>已收录 {episodes.length} 个分集、{course.assets.length} 份课程资产和 {course.instructors.length} 位讲师信息。</p>
           <div className="receipt">
@@ -487,16 +535,44 @@ export function CourseWorkspace() {
           </div>
 
           <div className="success-panel-actions">
+            <button
+              type="button"
+              className="primary-button"
+              onClick={() => setIsEditingWaitingCourse(true)}
+            >
+              <Edit3 size={15} />
+              修改课程信息
+            </button>
             <Link className="secondary-button" to="/">
               返回课程列表
             </Link>
+            <button
+              type="button"
+              className="cancel-waiting-button"
+              onClick={cancelSubmission}
+              disabled={busy}
+            >
+              {busy ? '正在恢复…' : '取消等待制作'}
+            </button>
           </div>
         </section>
       )}
 
-      {/* 草稿阶段分集管理、素材上传与讲师编辑 */}
-      {isDraft && (
-        <div className="asset-layout workspace-grid">
+      {/* 草稿或等待制作课程的分集管理、素材上传与讲师编辑 */}
+      {canEditCourseContent && (
+        <>
+        {isWaiting && (
+          <div className="waiting-edit-notice">
+            <div>
+              <CheckCircle2 size={18} />
+              <span><strong>正在修改已提交课程</strong>,保存的调整会直接同步到等待制作的课程中。</span>
+            </div>
+            <button type="button" className="secondary-button small-button" onClick={() => setIsEditingWaitingCourse(false)}>
+              完成修改
+            </button>
+          </div>
+        )}
+        <div className="workspace-grid">
           {/* 左侧主要区域:分集架构与资产上传 */}
           <main className="episodes-workspace-main">
             {/* 顶部统计与操作卡 */}
@@ -504,7 +580,6 @@ export function CourseWorkspace() {
               <div className="panel-heading-row">
                 <div>
                   <span className="step-number">02</span>
-                  <p className="eyebrow">EPISODES & ASSETS</p>
                   <h2>课程分集与素材管理</h2>
                   <p className="panel-intro">
                     课程默认已包含第 1 集,可按需添加更多分集(如 12 集微积分),为每一集在线录入口播讲稿,并上传该集的专属课件与素材文件。
@@ -642,8 +717,6 @@ export function CourseWorkspace() {
                     {/* 展开后的分集详情:讲稿正文编辑 + 分集素材上传 */}
                     {isExpanded && (
                       <div className="episode-card-body">
-                        {ep.summary && <p className="ep-summary-text"><strong>分集要点:</strong>{ep.summary}</p>}
-
                         <div className="episode-content-heading">
                           <div>
                             <strong>本集制作内容</strong>
@@ -780,7 +853,6 @@ export function CourseWorkspace() {
             <section className="paper-form global-assets-panel">
               <div className="panel-heading-row">
                 <div>
-                  <span className="section-kicker">GLOBAL ASSETS</span>
                   <h3>全套课程通用资产 ({globalAssets.length})</h3>
                   <p className="panel-intro">
                     如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
@@ -846,7 +918,6 @@ export function CourseWorkspace() {
           <aside className="instructor-panel workspace-sidebar">
             <div className="aside-title">
               <div>
-                <span className="section-kicker">INSTRUCTORS</span>
                 <h3>讲师信息 ({course.instructors.length})</h3>
               </div>
               <button
@@ -862,7 +933,11 @@ export function CourseWorkspace() {
             <div className="instructor-list">
               {course.instructors.map((t) => (
                 <div className="instructor-card" key={t.id}>
-                  {t.imageUrl ? <img src={t.imageUrl} alt={t.name} /> : <span className="avatar">{t.name[0]}</span>}
+                  {t.imageUrls?.length ? (
+                    <div className="instructor-photo-stack">
+                      {t.imageUrls.map((url, index) => <img src={url} alt={`${t.name} 出镜照 ${index + 1}`} key={url} />)}
+                    </div>
+                  ) : <span className="avatar">{t.name[0]}</span>}
                   <div>
                     <strong>{t.name}</strong>
                     <small>{t.organization || '未填写单位'}</small>
@@ -894,13 +969,21 @@ export function CourseWorkspace() {
               <div className="submit-summary-info">
                 <p>已划分 <strong>{episodes.length}</strong> 个分集,收录 <strong>{course.assets.length}</strong> 份素材文件。</p>
               </div>
-              <button className="primary-button full-button highlight-button" onClick={submit} disabled={busy}>
-                <Send size={16} />
-                提交制作
-              </button>
+              {isDraft ? (
+                <button className="primary-button full-button highlight-button" onClick={submit} disabled={busy}>
+                  <Send size={16} />
+                  提交制作
+                </button>
+              ) : (
+                <button className="primary-button full-button" onClick={() => setIsEditingWaitingCourse(false)}>
+                  <CheckCircle2 size={16} />
+                  完成修改
+                </button>
+              )}
             </div>
           </aside>
         </div>
+        </>
       )}
 
       {/* 课程详情最底部:低调的辅助操作区 / 删除课程 */}
@@ -941,8 +1024,20 @@ export function CourseWorkspace() {
           </div>
         </div>
       </section>
+        </div>
+      )}
 
       {/* 独立添加讲师信息弹窗 */}
+      <EditCourseModal
+        course={course}
+        isOpen={showEditCourseModal}
+        onClose={() => setShowEditCourseModal(false)}
+        onSuccess={(updatedCourse) => {
+          setCourse(updatedCourse)
+          notify('课程信息已更新')
+        }}
+      />
+
       <AddInstructorModal
         isOpen={showAddInstructorModal}
         onClose={() => setShowAddInstructorModal(false)}

+ 0 - 1
src/pages/Dashboard.tsx

@@ -53,7 +53,6 @@ export function Dashboard() {
       <section className="content-section">
         <div className="section-heading">
           <div>
-            <span className="section-kicker">WORKSPACE</span>
             <h2>我的课程</h2>
           </div>
           <div className="metrics">

+ 76 - 102
src/pages/HelpPage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { Children, isValidElement, useEffect, useMemo, useState, type ReactNode } from 'react'
 import {
   ArrowLeft,
   ChevronDown,
@@ -8,7 +8,6 @@ import {
   MessageCircle,
   MessageSquare,
   Search,
-  Sparkles,
   UploadCloud,
   UserCheck,
   Video,
@@ -20,9 +19,23 @@ interface FAQItem {
   id: string
   category: 'getting-started' | 'materials' | 'production' | 'account'
   question: string
-  answer: string | React.ReactNode
+  answer: ReactNode
 }
 
+const FEATURE_LIST = [
+  { icon: Layers, title: '结构化大纲编排', description: '支持按集管理课程知识点、逐字稿讲义与预估时长,告别混乱的文档传输与版本断层。' },
+  { icon: UserCheck, title: '讲师出镜形象配置', description: '支持为每门课程建立专属讲师库,录入站姿与坐姿立绘素材,保障课件出镜形象专业统一。' },
+  { icon: UploadCloud, title: '全格式课件归集', description: 'PPT、PDF、教学讲义集中云端归档,自动匹配分集内容,让每一次制作都有迹可循。' },
+  { icon: Video, title: '专业制作与成果交付', description: '一键提交进入流水线制作,在线实时跟踪制作状态,成片交付支持在线即时预览与下载。' },
+]
+
+const WORKFLOW_LIST = [
+  { step: '01', title: '创建课程', description: '录入课程名称、分类和简介,或直接选择系统提供的官方范例模板一键快速生成。' },
+  { step: '02', title: '编排大纲与台词', description: '添加分集,填写每集知识主题与口播讲义,设定课程节奏。' },
+  { step: '03', title: '上传讲师与课件', description: '上传讲师高清照片(坐姿/站姿)及配套 PPT 课件等制作素材。' },
+  { step: '04', title: '提交制作与交付', description: '确认无误后提交,顾问团队排期制作,完成后在工作区一键获取成品。' },
+]
+
 const CATEGORIES = [
   { key: 'all', label: '全部问题' },
   { key: 'getting-started', label: '入门与创建' },
@@ -47,7 +60,7 @@ const FAQ_LIST: FAQItem[] = [
       <div>
         <p style={{ margin: '0 0 8px' }}>制作一门完整的课程通常包含以下 4 个步骤:</p>
         <ol style={{ margin: 0, paddingLeft: '20px', lineHeight: 1.8 }}>
-          <li><strong>创建课程</strong>:填写课程主题、分类、目标受众与简介,也可直接从官方模板一键生成;</li>
+          <li><strong>创建课程</strong>:填写课程主题、分类与简介,也可直接从官方模板一键生成;</li>
           <li><strong>编排分集大纲</strong>:添加各个分集的章节名称、预计时长与口播逐字稿;</li>
           <li><strong>配置讲师与上传素材</strong>:上传讲师出镜形象照片(坐姿/站姿)及配套 PPT 课件;</li>
           <li><strong>提交制作</strong>:确认材料无误后点击“提交制作”,制作团队将跟进合成并交付最终视频。</li>
@@ -144,6 +157,19 @@ const FAQ_LIST: FAQItem[] = [
   },
 ]
 
+function nodeToText(node: ReactNode): string {
+  if (typeof node === 'string' || typeof node === 'number') return String(node)
+  if (!isValidElement<{ children?: ReactNode }>(node)) return ''
+  return Children.toArray(node.props.children).map(nodeToText).join(' ')
+}
+
+function matchesSearch(values: string[], keyword: string) {
+  const terms = keyword.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
+  if (terms.length === 0) return true
+  const content = values.join(' ').toLocaleLowerCase()
+  return terms.every((term) => content.includes(term))
+}
+
 export function HelpPage() {
   const [activeCategory, setActiveCategory] = useState<string>('all')
   const [searchKeyword, setSearchKeyword] = useState<string>('')
@@ -154,19 +180,24 @@ export function HelpPage() {
     setOpenId((prev) => (prev === id ? null : id))
   }
 
-  const filteredFaqs = FAQ_LIST.filter((item) => {
-    const matchCategory = activeCategory === 'all' || item.category === activeCategory
-    const q = searchKeyword.trim().toLowerCase()
-    if (!q) return matchCategory
-
-    const questionMatch = item.question.toLowerCase().includes(q)
-    const answerMatch =
-      typeof item.answer === 'string'
-        ? item.answer.toLowerCase().includes(q)
-        : true
+  const isSearching = searchKeyword.trim().length > 0
+  const filteredFeatures = useMemo(
+    () => FEATURE_LIST.filter((item) => matchesSearch([item.title, item.description], searchKeyword)),
+    [searchKeyword],
+  )
+  const filteredWorkflow = useMemo(
+    () => WORKFLOW_LIST.filter((item) => matchesSearch([item.title, item.description], searchKeyword)),
+    [searchKeyword],
+  )
+  const filteredFaqs = useMemo(() => FAQ_LIST.filter((item) => {
+    const matchCategory = isSearching || activeCategory === 'all' || item.category === activeCategory
+    return matchCategory && matchesSearch([item.question, nodeToText(item.answer)], searchKeyword)
+  }), [activeCategory, isSearching, searchKeyword])
+  const searchResultCount = filteredFeatures.length + filteredWorkflow.length + filteredFaqs.length
 
-    return matchCategory && (questionMatch || answerMatch)
-  })
+  useEffect(() => {
+    if (isSearching && filteredFaqs.length > 0) setOpenId(filteredFaqs[0].id)
+  }, [isSearching, searchKeyword])
 
   return (
     <div className="page help-page">
@@ -177,9 +208,6 @@ export function HelpPage() {
       {/* 顶部介绍区 */}
       <section className="help-hero">
         <div className="help-hero-content">
-          <p className="eyebrow">
-            <Sparkles size={14} /> HELP & DOCUMENTATION
-          </p>
           <h1>帮助与使用指南</h1>
           <p className="help-hero-desc">
             了解星痕智能课程工坊的运作模式、素材规范与制作交付流程,让好课制作更轻松。
@@ -188,6 +216,7 @@ export function HelpPage() {
           <div className="help-search-bar">
             <Search size={18} className="search-icon" />
             <input
+              aria-label="搜索帮助页面内容"
               type="text"
               placeholder="搜索常见问题,例如:PPT要求、制作周期、讲师照片..."
               value={searchKeyword}
@@ -203,113 +232,51 @@ export function HelpPage() {
               </button>
             )}
           </div>
+          <div className="help-search-status" aria-live="polite">
+            {isSearching && `找到 ${searchResultCount} 条与“${searchKeyword.trim()}”相关的内容`}
+          </div>
         </div>
       </section>
 
       {/* 网站介绍与核心能力 */}
-      <section className="help-section">
+      {(!isSearching || filteredFeatures.length > 0) && <section className="help-section">
         <div className="help-section-header">
-          <span className="section-kicker">ABOUT XINGHEN</span>
           <h2>认识星痕课程工坊</h2>
           <p>删去繁复,以工业级标准化流程助力数字化课程高效产出</p>
         </div>
 
-        <div className="help-feature-grid">
-          <div className="help-feature-card">
-            <div className="feature-icon-box">
-              <Layers size={22} />
-            </div>
-            <h3>结构化大纲编排</h3>
-            <p>
-              支持按集管理课程知识点、逐字稿讲义与预估时长,告别混乱的文档传输与版本断层。
-            </p>
-          </div>
-
-          <div className="help-feature-card">
-            <div className="feature-icon-box">
-              <UserCheck size={22} />
-            </div>
-            <h3>讲师出镜形象配置</h3>
-            <p>
-              支持为每门课程建立专属讲师库,录入站姿与坐姿立绘素材,保障课件出镜形象专业统一。
-            </p>
-          </div>
-
-          <div className="help-feature-card">
-            <div className="feature-icon-box">
-              <UploadCloud size={22} />
-            </div>
-            <h3>全格式课件归集</h3>
-            <p>
-              PPT、PDF、教学讲义集中云端归档,自动匹配分集内容,让每一次制作都有迹可循。
-            </p>
-          </div>
-
-          <div className="help-feature-card">
-            <div className="feature-icon-box">
-              <Video size={22} />
-            </div>
-            <h3>专业制作与成果交付</h3>
-            <p>
-              一键提交进入流水线制作,在线实时跟踪制作状态,成片交付支持在线即时预览与下载。
-            </p>
-          </div>
+        <div className={`help-feature-grid ${isSearching ? 'is-search-results' : ''}`}>
+          {filteredFeatures.map(({ icon: Icon, title, description }) => <div className="help-feature-card" key={title}>
+            <div className="feature-icon-box"><Icon size={22} /></div>
+            <h3>{title}</h3><p>{description}</p>
+          </div>)}
         </div>
-      </section>
+      </section>}
 
       {/* 快速入门 4 步流程 */}
-      <section className="help-section">
+      {(!isSearching || filteredWorkflow.length > 0) && <section className="help-section">
         <div className="help-section-header">
-          <span className="section-kicker">WORKFLOW</span>
           <h2>课程制作四步法</h2>
           <p>从创意构思到拿到成品成片,只需要清晰的四步</p>
         </div>
 
-        <div className="help-steps-grid">
-          <div className="help-step-item">
-            <div className="step-badge">01</div>
-            <div className="step-item-body">
-              <h4>创建课程</h4>
-              <p>录入课程定位、受众或直接选择系统提供的官方范例模板一键快速生成。</p>
-            </div>
-          </div>
-
-          <div className="help-step-item">
-            <div className="step-badge">02</div>
-            <div className="step-item-body">
-              <h4>编排大纲与台词</h4>
-              <p>添加分集,填写每集知识主题与口播讲义,设定课程节奏。</p>
-            </div>
-          </div>
-
-          <div className="help-step-item">
-            <div className="step-badge">03</div>
-            <div className="step-item-body">
-              <h4>上传讲师与课件</h4>
-              <p>上传讲师高清照片(坐姿/站姿)及配套 PPT 课件等制作素材。</p>
-            </div>
-          </div>
-
-          <div className="help-step-item">
-            <div className="step-badge">04</div>
-            <div className="step-item-body">
-              <h4>提交制作与交付</h4>
-              <p>确认无误后提交,顾问团队排期制作,完成后在工作区一键获取成品。</p>
-            </div>
-          </div>
+        <div className={`help-steps-grid ${isSearching ? 'is-search-results' : ''}`}>
+          {filteredWorkflow.map(({ step, title, description }) => <div className="help-step-item" key={step}>
+            <div className="step-badge">{step}</div>
+            <div className="step-item-body"><h4>{title}</h4><p>{description}</p></div>
+          </div>)}
         </div>
-      </section>
+      </section>}
 
       {/* 常见问题 FAQ */}
-      <section className="help-section">
+      {(!isSearching || filteredFaqs.length > 0) && <section className="help-section">
         <div className="help-section-header">
-          <span className="section-kicker">FAQ & ANSWERS</span>
           <h2>常见问题解答</h2>
           <p>遇到疑问?这里整理了大家最常咨询的操作与制作问题</p>
         </div>
 
         {/* 分类过滤标签 */}
-        <div className="faq-category-tabs">
+        {!isSearching && <div className="faq-category-tabs">
           {CATEGORIES.map((cat) => (
             <button
               key={cat.key}
@@ -320,7 +287,7 @@ export function HelpPage() {
               {cat.label}
             </button>
           ))}
-        </div>
+        </div>}
 
         {/* 问答列表 */}
         <div className="faq-accordion-list">
@@ -359,7 +326,14 @@ export function HelpPage() {
             })
           )}
         </div>
-      </section>
+      </section>}
+
+      {isSearching && searchResultCount === 0 && <div className="faq-empty-state help-search-empty">
+        <FileQuestion size={36} />
+        <h4>未找到相关内容</h4>
+        <p>试着缩短关键词、使用其他说法,或清空搜索查看全部帮助内容。</p>
+        <button type="button" className="secondary-button" onClick={() => setSearchKeyword('')}>查看全部内容</button>
+      </div>}
 
       {/* 底部联系支持卡片 */}
       <section className="help-contact-banner">

+ 1 - 2
src/pages/NewCourse.tsx

@@ -85,7 +85,6 @@ export function NewCourse() {
       const { course } = await api.createCourse({
         name: name.trim(),
         category,
-        audience: '',
         description: description.trim()
       })
       navigate(`/courses/${course.id}`)
@@ -364,7 +363,7 @@ export function NewCourse() {
                       {currentTemplate.episodes.map((ep, i) => (
                         <div key={i} className="ep-subrow">
                           <span>{ep.title}</span>
-                          <small>{ep.summary || '包含完整口播讲稿与课件'}</small>
+                          <small>包含完整口播讲稿与课件</small>
                         </div>
                       ))}
                     </div>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 545 - 210
src/pages/admin/AdminCourseDetail.tsx


+ 38 - 17
src/pages/admin/AdminCourses.tsx

@@ -1,11 +1,15 @@
 import {
   ArrowRight,
+  Building2,
   Clock3,
   FileText,
   Filter,
+  Layers,
   PackageCheck,
+  Phone,
   RotateCw,
   Search,
+  User,
   Users,
   Wrench
 } from 'lucide-react'
@@ -46,9 +50,8 @@ export function AdminCourses() {
     <div className="page admin-page">
       <div className="admin-header-row">
         <div>
-          <span className="section-kicker">COURSE WORKSHOP</span>
           <h2>课程制作管理</h2>
-          <p className="admin-subtitle">管理全平台用户创建的课程,跟踪排期与制作进度</p>
+          <p className="admin-subtitle">管理全平台用户创建并提交的课程,跟踪制作排期与交付进度</p>
         </div>
         <button className="secondary-button" onClick={loadCourses} title="刷新列表">
           <RotateCw size={15} /> 刷新
@@ -121,10 +124,10 @@ export function AdminCourses() {
           <table className="admin-table">
             <thead>
               <tr>
-                <th>课程名称</th>
-                <th>分类 / 受众</th>
-                <th>提交用户</th>
-                <th>素材 / 讲师 / 交付</th>
+                <th>课程名称与大纲</th>
+                <th>课程分类</th>
+                <th style={{ minWidth: 200 }}>提交制作用户信息</th>
+                <th>素材 / 讲师 / 交付</th>
                 <th>当前状态</th>
                 <th>提交 / 交付时间</th>
                 <th style={{ textAlign: 'right' }}>操作</th>
@@ -133,21 +136,39 @@ export function AdminCourses() {
             <tbody>
               {courses.map((course) => (
                 <tr key={course.id}>
-                  <td style={{ maxWidth: 280 }}>
+                  <td style={{ maxWidth: 260 }}>
                     <div className="table-main-title">
-                      <strong>{course.name}</strong>
-                      {course.description && <small>{course.description}</small>}
+                      <strong className="course-table-name">{course.name}</strong>
+                      {course.description && (
+                        <small className="course-table-desc" title={course.description}>
+                          {course.description}
+                        </small>
+                      )}
+                      <div className="table-episodes-badge">
+                        <Layers size={11} /> 共 {course.episodes?.length || 1} 集
+                      </div>
                     </div>
                   </td>
                   <td>
-                    <span>{course.category || '未分类'}</span>
-                    {course.audience ? <small className="sub-text">{course.audience}</small> : null}
+                    <span className="category-pill">{course.category || '未分类'}</span>
                   </td>
                   <td>
-                    <div className="user-cell">
-                      <strong>{course.creator?.contactName || course.creator?.phone || '未知用户'}</strong>
-                      <small className="sub-text">{course.creator?.organization || '个人用户'}</small>
-                      <small className="sub-text">📞 {course.creator?.phone}</small>
+                    {/* 醒目的提交用户信息单元格 */}
+                    <div className="user-highlight-cell">
+                      <div className="user-avatar-mini">
+                        {(course.creator?.contactName || course.creator?.phone || '用')[0]}
+                      </div>
+                      <div className="user-info-stack">
+                        <strong className="user-contact-name">
+                          {course.creator?.contactName || '未填写联系人'}
+                        </strong>
+                        <span className="user-org-text" title={course.creator?.organization || '个人创作者'}>
+                          <Building2 size={12} /> {course.creator?.organization || '个人创作者'}
+                        </span>
+                        <span className="user-phone-text">
+                          <Phone size={12} /> {course.creator?.phone || '无手机号'}
+                        </span>
+                      </div>
                     </div>
                   </td>
                   <td>
@@ -160,7 +181,7 @@ export function AdminCourses() {
                       </span>
                       {course.deliverables && course.deliverables.length > 0 && (
                         <span className="badge-count deliverable-badge">
-                          <PackageCheck size={12} /> {course.deliverables.length} 交付
+                          <PackageCheck size={12} /> {course.deliverables.length} 交付
                         </span>
                       )}
                     </div>
@@ -174,7 +195,7 @@ export function AdminCourses() {
                         提: {course.submittedAt ? new Date(course.submittedAt).toLocaleDateString('zh-CN') : '未提交'}
                       </small>
                       {course.completedAt && (
-                        <small title="交付时间" style={{ color: '#2e7d32' }}>
+                        <small title="交付时间" style={{ color: '#2e7d32', fontWeight: 500 }}>
                           成: {new Date(course.completedAt).toLocaleDateString('zh-CN')}
                         </small>
                       )}

+ 1 - 4
src/pages/admin/AdminDashboard.tsx

@@ -42,7 +42,6 @@ export function AdminDashboard() {
     <div className="page admin-page">
       <div className="admin-header-row">
         <div>
-          <span className="section-kicker">CONTROL CENTER</span>
           <h2>管理概览看板</h2>
           <p className="admin-subtitle">监控平台用户动态与课程制作全周期流转状态</p>
         </div>
@@ -113,7 +112,6 @@ export function AdminDashboard() {
       <div className="admin-section-block">
         <div className="section-heading">
           <div>
-            <span className="section-kicker">PRODUCTION QUEUE</span>
             <h3>待制作与进行中课程</h3>
           </div>
           <Link to="/admin/courses" className="text-link">
@@ -133,7 +131,7 @@ export function AdminDashboard() {
               <thead>
                 <tr>
                   <th>课程名称</th>
-                  <th>分类 / 受众</th>
+                  <th>课程分类</th>
                   <th>提交用户</th>
                   <th>素材与讲师</th>
                   <th>状态</th>
@@ -152,7 +150,6 @@ export function AdminDashboard() {
                     </td>
                     <td>
                       <span>{course.category || '未分类'}</span>
-                      {course.audience ? <small className="sub-text">{course.audience}</small> : null}
                     </td>
                     <td>
                       <strong>{course.creator?.contactName || '未填姓名'}</strong>

+ 0 - 7
src/pages/admin/AdminLayout.tsx

@@ -1,8 +1,5 @@
 import {
   BookOpen,
-  ChevronRight,
-  ExternalLink,
-  Layers,
   LayoutDashboard,
   LogOut,
   ShieldCheck,
@@ -58,10 +55,6 @@ export function AdminLayout() {
         </nav>
 
         <div className="admin-top-actions">
-          <NavLink to="/" className="client-switch-button" target="_blank" rel="noreferrer" title="打开前台工坊查看用户视角">
-            <span>前台工坊</span>
-            <ExternalLink size={14} />
-          </NavLink>
           <div className="admin-user-info">
             <strong>{user.contactName || '系统管理员'}</strong>
             <small>{user.phone}</small>

+ 191 - 5
src/pages/admin/AdminUsers.tsx

@@ -4,12 +4,16 @@ import {
   CheckCircle2,
   Clock3,
   RotateCw,
+  Copy,
+  Plus,
+  RefreshCw,
   Search,
   Shield,
   ShieldAlert,
   ShieldCheck,
   UserCheck,
-  Users
+  Users,
+  X
 } from 'lucide-react'
 import { useEffect, useState } from 'react'
 import { Link } from 'react-router-dom'
@@ -25,6 +29,25 @@ export function AdminUsers() {
   const [error, setError] = useState('')
   const [successMsg, setSuccessMsg] = useState('')
   const [busyId, setBusyId] = useState<string | null>(null)
+  const [showCreate, setShowCreate] = useState(false)
+  const [creating, setCreating] = useState(false)
+  const [createError, setCreateError] = useState('')
+  const [createdLogin, setCreatedLogin] = useState<{ phone: string; password: string } | null>(null)
+  const [resetLogin, setResetLogin] = useState<{ phone: string; password: string } | null>(null)
+  const [copied, setCopied] = useState(false)
+
+  const generatePassword = () => {
+    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
+    const values = crypto.getRandomValues(new Uint32Array(10))
+    return Array.from(values, (value) => chars[value % chars.length]).join('')
+  }
+
+  const closeCreate = () => {
+    setShowCreate(false)
+    setCreatedLogin(null)
+    setCopied(false)
+    setCreateError('')
+  }
 
   const loadUsers = () => {
     setLoading(true)
@@ -67,17 +90,85 @@ export function AdminUsers() {
     }
   }
 
+  const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault()
+    const form = new FormData(e.currentTarget)
+    const login = {
+      phone: String(form.get('phone') || '').trim(),
+      password: String(form.get('password') || '')
+    }
+    setCreating(true)
+    setCreateError('')
+    try {
+      await api.admin.createUser({
+        ...login,
+        contactName: String(form.get('contactName') || '').trim(),
+        organization: String(form.get('organization') || '').trim()
+      })
+      setCreatedLogin(login)
+      setSuccessMsg(`用户 ${login.phone} 创建成功`)
+      loadUsers()
+    } catch (err: any) {
+      setCreateError(err.message || '创建用户失败')
+    } finally {
+      setCreating(false)
+    }
+  }
+
+  const copyLogin = async () => {
+    const login = createdLogin || resetLogin
+    if (!login) return
+    const text = `手机号:${login.phone}\n密码:${login.password}`
+    try {
+      await navigator.clipboard.writeText(text)
+    } catch {
+      const textarea = document.createElement('textarea')
+      textarea.value = text
+      textarea.style.position = 'fixed'
+      textarea.style.opacity = '0'
+      document.body.appendChild(textarea)
+      textarea.select()
+      document.execCommand('copy')
+      textarea.remove()
+    }
+    setCopied(true)
+  }
+
+  const handleResetPassword = async (targetUser: User) => {
+    const displayName = targetUser.contactName || targetUser.phone
+    if (!confirm(`确定要重置用户【${displayName}】的密码吗?重置后该用户当前登录状态将失效。`)) return
+
+    const password = generatePassword()
+    setBusyId(targetUser.id)
+    setError('')
+    setSuccessMsg('')
+    setCopied(false)
+    try {
+      await api.admin.resetUserPassword(targetUser.id, password)
+      setResetLogin({ phone: targetUser.phone, password })
+      setSuccessMsg(`用户 ${targetUser.phone} 的密码已重置`)
+    } catch (err: any) {
+      setError(err.message || '重置密码失败')
+    } finally {
+      setBusyId(null)
+    }
+  }
+
   return (
     <div className="page admin-page">
       <div className="admin-header-row">
         <div>
-          <span className="section-kicker">USER MANAGEMENT</span>
           <h2>用户管理中心</h2>
           <p className="admin-subtitle">查看全平台注册用户、课程创建活跃度及管理后台权限配置</p>
         </div>
-        <button className="secondary-button" onClick={loadUsers} title="刷新列表">
-          <RotateCw size={15} /> 刷新
-        </button>
+        <div className="action-button-group">
+          <button className="secondary-button" onClick={loadUsers} title="刷新列表">
+            <RotateCw size={15} /> 刷新
+          </button>
+          <button className="primary-button" onClick={() => setShowCreate(true)}>
+            <Plus size={15} /> 新增用户
+          </button>
+        </div>
       </div>
 
       {error && <div className="notice notice-error">{error}</div>}
@@ -180,6 +271,15 @@ export function AdminUsers() {
                         课程 ({u.coursesCount || 0})
                       </Link>
 
+                      <button
+                        type="button"
+                        disabled={busyId === u.id}
+                        className="secondary-button small-button"
+                        onClick={() => handleResetPassword(u)}
+                      >
+                        {busyId === u.id ? '处理中…' : '重置密码'}
+                      </button>
+
                       {/* 权限切换按钮 */}
                       {u.id !== currentUser?.id && (
                         <button
@@ -201,6 +301,92 @@ export function AdminUsers() {
           </table>
         </div>
       )}
+
+      {showCreate && (
+        <div className="modal-backdrop" role="presentation" onMouseDown={(e) => e.target === e.currentTarget && closeCreate()}>
+          <div className="modal-dialog create-user-modal" role="dialog" aria-modal="true" aria-labelledby="create-user-title">
+            <div className="modal-header">
+              <div>
+                <h3 id="create-user-title">{createdLogin ? '用户创建成功' : '新增用户'}</h3>
+              </div>
+              <button className="close-modal-btn" onClick={closeCreate} aria-label="关闭"><X size={18} /></button>
+            </div>
+
+            {createdLogin ? (
+              <div className="create-user-body">
+                <p className="create-user-hint">请复制以下登录信息并发送给用户。密码关闭窗口后将不再显示。</p>
+                <div className="login-credentials">
+                  <div><span>手机号</span><strong>{createdLogin.phone}</strong></div>
+                  <div><span>初始密码</span><strong>{createdLogin.password}</strong></div>
+                </div>
+                <button className="primary-button copy-login-button" type="button" onClick={copyLogin}>
+                  {copied ? <CheckCircle2 size={16} /> : <Copy size={16} />}
+                  {copied ? '已复制登录信息' : '一键复制手机号和密码'}
+                </button>
+              </div>
+            ) : (
+              <form onSubmit={handleCreateUser}>
+                <div className="create-user-body">
+                  {createError && <div className="notice notice-error">{createError}</div>}
+                  <label className="create-user-field required">
+                    <span>手机号</span>
+                    <input name="phone" inputMode="numeric" autoComplete="off" placeholder="请输入 11 位手机号" pattern="1[0-9]{10}" required />
+                  </label>
+                  <label className="create-user-field required">
+                    <span>初始密码</span>
+                    <div className="password-generate-row">
+                      <input name="password" defaultValue={generatePassword()} minLength={6} maxLength={72} autoComplete="new-password" required />
+                      <button type="button" className="secondary-button" onClick={(e) => {
+                        const input = e.currentTarget.previousElementSibling as HTMLInputElement
+                        input.value = generatePassword()
+                      }}><RefreshCw size={14} /> 换一个</button>
+                    </div>
+                    <small>至少 6 位,可使用自动生成的安全密码</small>
+                  </label>
+                  <div className="create-user-grid">
+                    <label className="create-user-field"><span>姓名(选填)</span><input name="contactName" maxLength={50} placeholder="用户姓名" /></label>
+                    <label className="create-user-field"><span>单位(选填)</span><input name="organization" maxLength={100} placeholder="所属单位" /></label>
+                  </div>
+                </div>
+                <div className="modal-footer">
+                  <button type="button" className="secondary-button" onClick={closeCreate}>取消</button>
+                  <button type="submit" className="primary-button" disabled={creating}>{creating ? '正在创建…' : '创建用户'}</button>
+                </div>
+              </form>
+            )}
+          </div>
+        </div>
+      )}
+
+      {resetLogin && (
+        <div className="modal-backdrop" role="presentation" onMouseDown={(e) => {
+          if (e.target === e.currentTarget) {
+            setResetLogin(null)
+            setCopied(false)
+          }
+        }}>
+          <div className="modal-dialog create-user-modal" role="dialog" aria-modal="true" aria-labelledby="reset-password-title">
+            <div className="modal-header">
+              <div><h3 id="reset-password-title">密码重置成功</h3></div>
+              <button className="close-modal-btn" onClick={() => {
+                setResetLogin(null)
+                setCopied(false)
+              }} aria-label="关闭"><X size={18} /></button>
+            </div>
+            <div className="create-user-body">
+              <p className="create-user-hint">请复制以下登录信息并发送给用户。新密码关闭窗口后将不再显示,用户已有登录状态已失效。</p>
+              <div className="login-credentials">
+                <div><span>手机号</span><strong>{resetLogin.phone}</strong></div>
+                <div><span>新密码</span><strong>{resetLogin.password}</strong></div>
+              </div>
+              <button className="primary-button copy-login-button" type="button" onClick={copyLogin}>
+                {copied ? <CheckCircle2 size={16} /> : <Copy size={16} />}
+                {copied ? '已复制登录信息' : '一键复制手机号和密码'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   )
 }

+ 677 - 86
src/styles.css

@@ -130,7 +130,95 @@ button { cursor:pointer; }
 .title-row { display:flex; align-items:center; gap:15px; flex-wrap:wrap; }
 .title-row h1 { margin:0; font-size:27px; font-weight:500; }
 .title-row h2 { margin:0; font-size:23px; font-weight:500; }
+.edit-course-info-btn { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); background:#fff; color:#667085; border-radius:7px; padding:7px 10px; font-size:12px; cursor:pointer; transition:.2s; }
+.edit-course-info-btn:hover { color:var(--primary); border-color:var(--primary); background:#f8faff; }
 .workspace-head>div>p { color:#929cad; font-size:12px; margin:8px 0 0; }
+
+.modal-dialog.edit-course-modal {
+  width: min(620px, 95vw);
+  max-height: 90vh;
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(0, 0, 0, 0.22);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.edit-course-modal-body {
+  padding: 22px 24px;
+  overflow-y: auto;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 18px;
+}
+.edit-course-modal-body .form-label {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  color: #334155;
+  font-size: 13px;
+  font-weight: 500;
+}
+.edit-course-modal-body .form-label.required > span::after {
+  content: " *";
+  color: #ef4444;
+}
+.edit-course-modal-body input,
+.edit-course-modal-body select,
+.edit-course-modal-body textarea {
+  width: 100%;
+  box-sizing: border-box;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+  background: #f8fafc;
+  padding: 10px 14px;
+  color: #0f172a;
+  font: inherit;
+  font-size: 14px;
+  transition: all .15s ease;
+}
+.edit-course-modal-body select {
+  min-height: 42px;
+  cursor: pointer;
+}
+.edit-course-modal-body textarea {
+  min-height: 150px;
+  resize: vertical;
+  line-height: 1.55;
+}
+.edit-course-modal-body input:focus,
+.edit-course-modal-body select:focus,
+.edit-course-modal-body textarea:focus {
+  outline: none;
+  border-color: #3b82f6;
+  background: #ffffff;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, .12);
+}
+.edit-course-field-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+.edit-course-modal-body .field-count {
+  color: #98a2b3;
+  font-size: 11px;
+  font-weight: 400;
+}
+@media (max-width: 540px) {
+  .edit-course-modal-body { padding: 18px; }
+  .modal-dialog.edit-course-modal .modal-header,
+  .modal-dialog.edit-course-modal .modal-footer { padding-left: 18px; padding-right: 18px; }
+}
+.waiting-edit-notice { margin:32px 0 18px; padding:14px 16px; border:1px solid #cfe0fa; background:#f5f9ff; border-radius:10px; display:flex; align-items:center; justify-content:space-between; gap:16px; color:#315b91; font-size:13px; }
+.waiting-edit-notice>div { display:flex; align-items:center; gap:9px; }
+.waiting-edit-notice .secondary-button { flex-shrink:0; }
+.cancel-waiting-button { border:0; background:transparent; color:#98a2b3; padding:8px 10px; font-size:12px; cursor:pointer; }
+.cancel-waiting-button:hover:not(:disabled) { color:#b42318; text-decoration:underline; }
+.cancel-waiting-button:disabled { cursor:not-allowed; opacity:.6; }
+@media (max-width: 640px) { .waiting-edit-notice { align-items:flex-start; flex-direction:column; } }
 .success-panel { margin:55px auto 0; max-width:720px; text-align:center; background:#fff; border:1px solid #e4e9f1; border-radius:14px; padding:50px 55px; box-shadow:0 22px 70px rgba(35,53,88,.06); }
 .success-panel .eyebrow { justify-content:center; margin-top:18px!important; margin-bottom:11px!important; }
 .success-panel h2 { font-size:28px; font-weight:400; margin:0 0 13px; }
@@ -202,10 +290,43 @@ button { cursor:pointer; }
 .instructor-list { margin-top:14px; }
 .instructor-card { display:flex; gap:11px; padding:13px 0; border-bottom:1px solid #edf0f4; align-items:start; }
 .instructor-card img,.avatar,.avatar-placeholder { width:54px; height:65px; object-fit:cover; border-radius:7px; background:#e9effa; display:grid; place-items:center; flex:none; font-weight:600; color:#4a5568; }
+.instructor-photo-stack,.admin-instructor-photos { display:flex; flex-wrap:wrap; gap:5px; flex:none; }
+.instructor-photo-stack { width:113px; }
+.instructor-photo-stack img { margin:0; }
+.admin-instructor-photos { max-width:117px; }
 .instructor-card>div { display:flex; flex-direction:column; gap:4px; min-width:0; }
 .instructor-card small,.instructor-card p,.empty-tip,.submit-box p { font-size:11px; color:#8d98aa; }
 .instructor-card p { margin:3px 0; line-height:1.5; color:#4a5568; }
 .submit-box { border-top:1px solid #edf0f4; margin-top:20px; padding-top:18px; }
+
+.production-progress-card { margin: 18px 0; }
+.points-balance-badge { padding:6px 10px; border-radius:999px; background:#fff0e7; color:#c7541a; font-size:12px; font-weight:700; }
+.course-workspace-tabs { display:flex; gap:6px; margin:20px 0 4px; padding:5px; width:max-content; max-width:100%; border:1px solid #e4e9f1; border-radius:10px; background:#f6f8fb; }
+.course-workspace-tabs button { display:flex; align-items:center; gap:7px; padding:9px 18px; border:0; border-radius:7px; background:transparent; color:#697386; font-size:13px; font-weight:600; cursor:pointer; }
+.course-workspace-tabs button.active { background:#fff; color:#172033; box-shadow:0 2px 8px rgba(28,44,76,.09); }
+.course-workspace-tabs button span { min-width:20px; padding:1px 6px; border-radius:999px; background:#e8edf5; color:#667085; font-size:10px; }
+.course-workspace-tabs button.active span { background:#fff0e7; color:#c7541a; }
+.course-progress-tab-panel { max-width:920px; }
+.course-progress-tab-panel .production-progress-card { margin-top:16px; }
+.progress-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }
+.points-summary { text-align:right; display:flex; flex-direction:column; gap:3px; }
+.points-summary strong { color:#e06b2d; font-size:24px; }
+.points-summary span,.points-summary small,.points-rule { color:#788397; font-size:12px; }
+.progress-title-row { display:flex; align-items:center; gap:10px; }
+.progress-title-row h3 { margin:4px 0 0; }
+.points-charged-label { color:#16845b !important; font-weight:700; }
+.points-rule { margin:10px 0 16px; }
+.progress-timeline { display:flex; flex-direction:column; gap:0; }
+.progress-event { display:grid; grid-template-columns:18px 1fr; gap:10px; padding:10px 0; border-top:1px solid #edf0f4; }
+.progress-dot { width:9px; height:9px; margin-top:5px; border-radius:50%; background:#e06b2d; box-shadow:0 0 0 4px #fff0e7; }
+.progress-event-title { display:flex; justify-content:space-between; gap:16px; }
+.progress-event-title time { color:#9aa3b2; font-size:11px; white-space:nowrap; }
+.progress-event p { margin:4px 0 0; color:#667085; font-size:12px; }
+.admin-points-panel { display:flex; justify-content:space-between; align-items:end; gap:28px; margin:18px 0; }
+.admin-points-form { display:grid; grid-template-columns:140px minmax(240px,1fr) auto; align-items:end; gap:12px; flex:1; max-width:720px; }
+.admin-points-form label { font-size:12px; }
+.admin-progress-card { margin-bottom:18px; }
+@media (max-width: 760px) { .progress-card-head,.admin-points-panel { flex-direction:column; } .points-summary { text-align:left; } .admin-points-form { grid-template-columns:1fr; width:100%; } }
 .selected-stack { margin-top:12px; }
 .selected-file { display:flex; align-items:center; border:1px solid #dfe6f1; background:#f9fbff; border-radius:9px; padding:12px 14px; margin-top:8px; gap:10px; }
 .selected-file div { display:flex; flex-direction:column; min-width:0; }
@@ -214,7 +335,7 @@ button { cursor:pointer; }
 .selected-file button { margin-left:auto; border:0; background:transparent; color:#9ca6b6; cursor:pointer; }
 .full-button { width:100%; margin-top:14px; }
 
-/* ==================== ADMIN 管理端专属样式 ==================== */
+/* ==================== ADMIN 管理端专属现代设计系统 ==================== */
 
 .admin-shell { min-height:100vh; background:#f4f6fa; }
 .admin-topbar { height:70px; padding:0 clamp(20px,4vw,60px); display:flex; align-items:center; border-bottom:1px solid #dde3ed; background:#ffffff; box-shadow:0 1px 4px rgba(0,0,0,.03); position:sticky; top:0; z-index:20; }
@@ -231,11 +352,17 @@ button { cursor:pointer; }
 .admin-user-info { display:flex; flex-direction:column; text-align:right; font-size:12px; }
 .admin-user-info strong { color:#1e293b; }
 .admin-user-info small { color:#94a3b8; font-size:10px; }
-.admin-page { padding-top:28px; }
-.admin-header-row { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:25px; }
-.admin-header-row h2 { margin:5px 0 0; font-size:24px; font-weight:600; color:#0f172a; }
+.admin-page { padding-top:24px; padding-bottom:60px; }
+.admin-header-row { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:22px; }
+.admin-header-row h2 { margin:5px 0 0; font-size:24px; font-weight:700; color:#0f172a; }
 .admin-subtitle { color:#64748b; font-size:13px; margin:4px 0 0; }
 
+/* 导航面包屑 */
+.admin-breadcrumb-bar { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; font-size:13px; }
+.admin-breadcrumb-bar .back-link { display:inline-flex; align-items:center; gap:6px; color:#475569; font-weight:500; text-decoration:none; padding:6px 12px; border-radius:8px; background:#fff; border:1px solid #e2e8f0; transition:.15s; }
+.admin-breadcrumb-bar .back-link:hover { color:var(--blue); border-color:#cbd5e1; transform:translateX(-2px); }
+.breadcrumb-id { color:#94a3b8; font-size:12px; font-family:ui-monospace, monospace; }
+
 /* 统计卡片网格 */
 .stats-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:16px; margin-bottom:28px; }
 .stat-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; display:flex; align-items:center; gap:16px; box-shadow:0 2px 6px rgba(0,0,0,.02); transition:.2s ease; }
@@ -256,27 +383,36 @@ button { cursor:pointer; }
 /* 过滤与搜索条 */
 .admin-filter-bar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-bottom:20px; flex-wrap:wrap; }
 .filter-tabs { display:flex; background:#e2e8f0; padding:3px; border-radius:8px; gap:3px; }
-.tab-item { border:0; background:transparent; color:#64748b; font-size:13px; font-weight:500; padding:6px 14px; border-radius:6px; transition:.15s; }
+.tab-item { border:0; background:transparent; color:#64748b; font-size:13px; font-weight:500; padding:6px 14px; border-radius:6px; transition:.15s; cursor:pointer; }
 .tab-item.active { background:#fff; color:#0f172a; font-weight:600; box-shadow:0 1px 3px rgba(0,0,0,.08); }
 .search-form { display:flex; gap:8px; width:100%; max-width:380px; }
 .search-form .input-with-icon { flex:1; }
-.search-form input { border:1px solid #cbd5e1; border-radius:8px; padding:9px 12px 9px 36px; font-size:13px; background:#fff; outline:none; }
-.search-form input:focus { border-color:var(--blue); }
+.search-form input { border:1px solid #cbd5e1; border-radius:8px; padding:9px 12px 9px 36px; font-size:13px; background:#fff; outline:none; width:100%; box-sizing:border-box; }
+.search-form input:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(37,99,235,.1); }
 .user-stat-summary { color:#64748b; font-size:13px; }
 
 /* 管理端表格 */
-.admin-table-wrapper { width:100%; overflow-x:auto; background:#fff; border:1px solid #e2e8f0; border-radius:12px; }
+.admin-table-wrapper { width:100%; overflow-x:auto; background:#fff; border:1px solid #e2e8f0; border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.02); }
 .admin-table { width:100%; border-collapse:collapse; text-align:left; font-size:13px; }
-.admin-table th { background:#f8fafc; color:#475569; font-weight:600; font-size:12px; padding:14px 18px; border-bottom:1px solid #e2e8f0; white-space:nowrap; }
+.admin-table th { background:#f8fafc; color:#475569; font-weight:600; font-size:12px; padding:15px 18px; border-bottom:1px solid #e2e8f0; white-space:nowrap; letter-spacing:0.02em; }
 .admin-table td { padding:16px 18px; border-bottom:1px solid #f1f5f9; vertical-align:middle; color:#1e293b; }
 .admin-table tr:last-child td { border-bottom:0; }
-.admin-table tr:hover td { background:#fafcff; }
-.table-main-title strong { display:block; font-size:14px; font-weight:500; color:#0f172a; margin-bottom:3px; }
-.table-main-title small { display:block; color:#64748b; font-size:11px; line-height:1.4; max-width:360px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.sub-text { display:block; color:#64748b; font-size:11px; margin-top:2px; }
-.user-cell strong { display:block; font-size:13px; font-weight:500; }
-.user-bio-tip { color:#94a3b8; font-size:10px; display:block; margin-top:2px; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.time-cell small { display:block; font-size:11px; color:#64748b; }
+.admin-table tr:hover td { background:#f8fbff; }
+
+/* 列表项元素 */
+.course-table-name { display:block; font-size:14px; font-weight:600; color:#0f172a; margin-bottom:4px; line-height:1.4; }
+.course-table-desc { display:block; color:#64748b; font-size:12px; line-height:1.4; max-width:320px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-bottom:4px; }
+.table-episodes-badge { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:#475569; background:#f1f5f9; padding:2px 8px; border-radius:4px; margin-top:2px; }
+.category-pill { display:inline-block; font-size:12px; font-weight:500; color:#1e40af; background:#eff6ff; padding:3px 8px; border-radius:6px; border:1px solid #dbeafe; }
+
+/* 列表表格中的醒目用户信息单元格 */
+.user-highlight-cell { display:flex; align-items:flex-start; gap:10px; }
+.user-avatar-mini { width:32px; height:32px; border-radius:50%; background:#eff6ff; color:var(--blue); border:1px solid #bfdbfe; font-size:13px; font-weight:700; display:grid; place-items:center; flex-shrink:0; margin-top:2px; }
+.user-info-stack { display:flex; flex-direction:column; gap:2px; min-width:0; }
+.user-contact-name { font-size:13px; font-weight:700; color:#0f172a; }
+.user-org-text { font-size:11px; color:#475569; display:inline-flex; align-items:center; gap:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:180px; }
+.user-phone-text { font-size:12px; font-weight:600; color:#1e40af; display:inline-flex; align-items:center; gap:4px; }
+
 .badge-group { display:flex; flex-wrap:wrap; gap:6px; }
 .badge-count { display:inline-flex; align-items:center; gap:4px; font-size:11px; color:#475569; background:#f1f5f9; padding:3px 7px; border-radius:4px; }
 .deliverable-badge { background:#e8f5e9; color:#16a34a; font-weight:500; }
@@ -285,62 +421,345 @@ button { cursor:pointer; }
 .role-user { background:#f1f5f9; color:#475569; }
 .action-button-group { display:flex; justify-content:flex-end; gap:8px; }
 
-/* 制作详情工作台 */
-.admin-detail-header { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:24px 28px; margin-bottom:24px; display:flex; justify-content:space-between; align-items:center; gap:20px; flex-wrap:wrap; }
-.admin-detail-title h2 { margin:0 0 6px; font-size:22px; font-weight:600; }
-.status-action-box { display:flex; align-items:center; gap:12px; }
-.action-buttons-row { display:flex; align-items:center; gap:10px; }
-.status-pill { display:inline-flex; align-items:center; gap:6px; font-size:13px; font-weight:500; padding:6px 14px; border-radius:20px; }
-.status-pill-producing { background:#dbeafe; color:#1e40af; }
-.status-pill-completed { background:#dcfce7; color:#166534; }
-
-.admin-detail-grid { display:grid; grid-template-columns:minmax(0,1.7fr) minmax(320px,1.1fr); gap:24px; align-items:start; }
-.admin-panel { padding:30px; margin-bottom:24px; }
-.panel-desc { font-size:13px; color:#64748b; line-height:1.6; margin:-10px 0 20px; }
-.deliverable-list-section h4 { margin:0 0 12px; font-size:14px; font-weight:600; color:#334155; }
-.deliverable-items { display:flex; flex-direction:column; gap:10px; margin-bottom:18px; }
-.deliv-item { display:flex; align-items:center; gap:12px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:12px 16px; }
-.deliv-meta { flex:1; min-width:0; }
-.deliv-meta strong { display:block; font-size:13px; color:#1e293b; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.deliv-meta small { color:#64748b; font-size:11px; }
-.icon-success { color:#16a34a; }
-.delete-button:hover { color:#ef4444; background:#fee2e2; }
-.upload-deliv-trigger { margin:16px 0; }
-.pending-upload-stack { display:flex; flex-direction:column; gap:8px; margin-top:12px; padding:12px; background:#eff6ff; border:1px dashed #93c5fd; border-radius:8px; font-size:12px; }
-.file-chip { color:#1e40af; font-weight:500; }
-.empty-sub-tip { font-size:12px; color:#94a3b8; padding:12px 0; }
-.admin-delivery-footer { margin-top:20px; }
-
-/* 用户素材网格 */
-.asset-download-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; }
-.asset-download-card { background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:14px; display:flex; align-items:center; gap:12px; }
-.tone-primary-icon { color:#2563eb; flex-shrink:0; }
-.asset-download-info { flex:1; min-width:0; }
-.asset-download-info strong { display:block; font-size:13px; color:#1e293b; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
-.asset-download-info small { color:#64748b; font-size:11px; }
-
-/* 右侧侧边栏卡片 */
-.admin-sidebar-card { padding:24px; margin-bottom:20px; }
-.user-profile-summary { display:flex; align-items:center; gap:14px; margin-bottom:18px; padding-bottom:16px; border-bottom:1px solid #f1f5f9; }
-.user-avatar-large { width:48px; height:48px; border-radius:50%; background:#edf3ff; color:var(--blue); display:grid; place-items:center; flex-shrink:0; }
-.user-summary-text strong { display:block; font-size:15px; color:#0f172a; }
-.user-summary-text small { color:#64748b; font-size:12px; }
-.contact-list { display:flex; flex-direction:column; gap:10px; font-size:13px; }
-.contact-item { display:flex; gap:8px; color:#475569; }
-.contact-item strong { color:#0f172a; }
-.contact-item.flex-col { flex-direction:column; gap:4px; margin-top:6px; }
-.contact-item p { margin:0; font-size:12px; color:#334155; line-height:1.5; background:#f8fafc; padding:10px; border-radius:6px; }
-.course-full-desc { font-size:13px; color:#334155; line-height:1.7; margin:0 0 16px; white-space:pre-line; }
-.time-meta-box { display:flex; flex-direction:column; gap:8px; padding:12px; background:#f8fafc; border-radius:8px; font-size:11px; color:#64748b; border:1px solid #f1f5f9; }
-.time-meta-box div { display:flex; justify-content:space-between; }
-.time-meta-box span { font-weight:500; color:#1e293b; }
-.instructor-sidebar-list { display:flex; flex-direction:column; gap:14px; }
-.inst-card-detail { display:flex; gap:12px; padding-bottom:14px; border-bottom:1px solid #f1f5f9; }
-.inst-card-detail:last-child { border-bottom:0; padding-bottom:0; }
-.inst-photo { width:56px; height:68px; object-fit:cover; border-radius:6px; flex-shrink:0; }
-.inst-info strong { display:block; font-size:13px; color:#0f172a; }
-.inst-info small { color:#64748b; font-size:11px; display:block; margin:2px 0 6px; }
-.inst-info p { margin:0; font-size:11px; color:#475569; line-height:1.5; }
+/* ==================== 课程详情工作台 Hero 与用户信息横幅 ==================== */
+
+.admin-hero-card { background:#ffffff; border:1px solid #e2e8f0; border-radius:16px; padding:24px 28px; margin-bottom:24px; box-shadow:0 4px 20px rgba(0,0,0,.03); display:flex; flex-direction:column; gap:20px; }
+.admin-hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; flex-wrap:wrap; }
+.admin-hero-title-area { flex:1; min-width:280px; }
+.admin-hero-title-row { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:10px; }
+.admin-course-name { margin:0; font-size:24px; font-weight:700; color:#0f172a; letter-spacing:-0.01em; }
+.admin-meta-tags { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
+.meta-tag { display:inline-flex; align-items:center; gap:5px; font-size:12px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 10px; border-radius:6px; }
+.meta-tag strong { color:#0f172a; font-weight:600; }
+
+.admin-hero-actions { display:flex; align-items:center; gap:12px; }
+.hero-action-btn { min-height:40px; padding:0 20px; font-size:14px; font-weight:600; box-shadow:0 2px 8px rgba(37,99,235,.2); }
+.hero-status-action-row { display:flex; align-items:center; gap:12px; }
+.status-pill { display:inline-flex; align-items:center; gap:6px; font-size:13px; font-weight:600; padding:7px 16px; border-radius:20px; }
+.status-pill-producing { background:#eff6ff; color:#1d4ed8; border:1px solid #bfdbfe; }
+.status-pill-completed { background:#f0fdf4; color:#15803d; border:1px solid #bbf7d0; }
+
+/* 🌟 核心高亮:提交用户信息横幅 (Creator Banner) */
+.admin-creator-banner {
+  background: linear-gradient(135deg, #f0f7ff 0%, #f7faff 50%, #ffffff 100%);
+  border: 1.5px solid #bfdbfe;
+  border-radius: 14px;
+  padding: 18px 22px;
+  box-shadow: 0 4px 16px rgba(37, 99, 235, 0.05);
+  position: relative;
+}
+
+.creator-banner-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  background: var(--blue);
+  color: #ffffff;
+  font-size: 11px;
+  font-weight: 700;
+  padding: 3px 10px;
+  border-radius: 6px;
+  letter-spacing: 0.04em;
+  margin-bottom: 14px;
+  box-shadow: 0 2px 6px rgba(37, 99, 235, 0.25);
+}
+
+.creator-banner-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 16px;
+  align-items: center;
+}
+
+.creator-field-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 10px 14px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
+  transition: all .2s ease;
+}
+.creator-field-item:hover {
+  border-color: #cbd5e1;
+  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.04);
+}
+
+.field-icon-wrap {
+  width: 36px;
+  height: 36px;
+  border-radius: 8px;
+  display: grid;
+  place-items: center;
+  flex-shrink: 0;
+}
+.name-icon { background: #eff6ff; color: #2563eb; }
+.org-icon { background: #f1f5f9; color: #475569; }
+.phone-icon { background: #fef2f2; color: #dc2626; }
+.wechat-icon { background: #f0fdf4; color: #16a34a; }
+.time-icon { background: #fdf4ff; color: #9333ea; }
+
+.field-content {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  flex: 1;
+}
+
+.field-label {
+  font-size: 11px;
+  color: #64748b;
+  font-weight: 500;
+  margin-bottom: 2px;
+}
+
+.field-value-primary {
+  font-size: 14px;
+  font-weight: 700;
+  color: #0f172a;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.field-value-text {
+  font-size: 13px;
+  font-weight: 600;
+  color: #334155;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.field-value-highlight {
+  font-size: 14px;
+  font-weight: 700;
+  color: #dc2626;
+  font-family: ui-monospace, monospace;
+}
+
+.phone-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.copy-phone-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #2563eb;
+  background: #eff6ff;
+  border: 1px solid #bfdbfe;
+  padding: 3px 8px;
+  border-radius: 5px;
+  cursor: pointer;
+  transition: all .15s;
+}
+.copy-phone-btn:hover {
+  background: #2563eb;
+  color: #ffffff;
+}
+.copy-phone-btn.copied {
+  background: #16a34a;
+  color: #ffffff;
+  border-color: #16a34a;
+}
+
+.field-value-time {
+  font-size: 12px;
+  color: #475569;
+  font-weight: 500;
+}
+
+.creator-banner-bio {
+  margin-top: 14px;
+  padding: 10px 14px;
+  background: #ffffff;
+  border: 1px dashed #bfdbfe;
+  border-radius: 8px;
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  font-size: 12px;
+}
+.bio-label { color: #2563eb; font-weight: 600; flex-shrink: 0; }
+.bio-text { margin: 0; color: #334155; line-height: 1.5; }
+
+/* ==================== 制作积分消耗卡片 UI ==================== */
+
+.admin-points-card {
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 14px;
+  padding: 22px 26px;
+  margin: 20px 0;
+  box-shadow: 0 2px 10px rgba(0,0,0,.02);
+  display: grid;
+  grid-template-columns: minmax(0, 1.2fr) minmax(360px, 1fr);
+  gap: 28px;
+  align-items: center;
+}
+
+.points-card-left { display: flex; flex-direction: column; gap: 10px; }
+.points-card-header h3 { margin: 2px 0 0; font-size: 18px; font-weight: 700; color: #0f172a; }
+.points-formula-box {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: #f8fafc;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+  padding: 10px 16px;
+  width: fit-content;
+  flex-wrap: wrap;
+}
+.formula-item { display: flex; flex-direction: column; }
+.formula-label { font-size: 10px; color: #64748b; font-weight: 500; }
+.formula-item strong { font-size: 14px; color: #1e293b; font-weight: 700; }
+.formula-operator { font-size: 16px; color: #94a3b8; font-weight: 700; }
+.formula-highlight strong { color: var(--blue); font-size: 16px; }
+.points-card-note { font-size: 12px; color: #64748b; line-height: 1.5; margin: 0; }
+
+.points-card-form { display: flex; flex-direction: column; gap: 12px; }
+.points-form-fields { display: grid; grid-template-columns: 140px 1fr; gap: 12px; align-items: flex-end; }
+.points-input-field, .points-reason-field { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 600; color: #334155; }
+.input-suffix-wrap { position: relative; display: flex; align-items: center; }
+.points-number-input { width: 100%; padding-right: 42px !important; font-weight: 700; font-size: 14px; }
+.input-suffix { position: absolute; right: 10px; font-size: 12px; color: #94a3b8; font-weight: 500; pointer-events: none; }
+.points-update-btn { align-self: flex-end; display: inline-flex; align-items: center; gap: 6px; padding: 0 18px; min-height: 38px; }
+
+@media (max-width: 900px) {
+  .admin-points-card { grid-template-columns: 1fr; }
+  .points-form-fields { grid-template-columns: 1fr; }
+  .points-update-btn { width: 100%; justify-content: center; }
+}
+
+/* ==================== 制作交付成果与素材双栏工作台 ==================== */
+
+.admin-detail-grid { display:grid; grid-template-columns:minmax(0,1.75fr) minmax(320px,1.05fr); gap:24px; align-items:start; }
+.admin-panel { background:#fff; border:1px solid #e2e8f0; border-radius:14px; padding:26px; margin-bottom:24px; box-shadow:0 2px 8px rgba(0,0,0,.02); }
+.panel-desc { font-size:13px; color:#64748b; line-height:1.6; margin:-6px 0 20px; }
+
+.form-title-with-actions { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:18px; }
+.form-title-with-actions h3 { margin:2px 0 0; font-size:18px; font-weight:700; color:#0f172a; }
+.ep-expand-actions { display:flex; align-items:center; gap:8px; font-size:12px; }
+.divider-dot { color:#cbd5e1; }
+
+.deliverable-list-section { margin-bottom: 24px; }
+.deliverable-list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
+.deliverable-list-header h4 { margin: 0; font-size: 15px; font-weight: 700; color: #1e293b; }
+.admin-deliv-group-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
+.admin-deliv-group-card .group-header { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 10px; }
+.deliverable-items { display: flex; flex-direction: column; gap: 8px; }
+.deliv-item { display: flex; align-items: center; gap: 12px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; transition: .15s; }
+.deliv-item:hover { border-color: #cbd5e1; box-shadow: 0 2px 6px rgba(0,0,0,.03); }
+.deliv-icon-wrap { width: 34px; height: 34px; border-radius: 6px; background: #f0fdf4; display: grid; place-items: center; flex-shrink: 0; }
+.deliv-meta { flex: 1; min-width: 0; }
+.deliv-meta strong { display: block; font-size: 13px; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.deliv-meta small { color: #64748b; font-size: 11px; }
+.deliv-actions { display: flex; align-items: center; gap: 8px; }
+.empty-deliverable-placeholder { background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 10px; padding: 24px; text-align: center; color: #64748b; font-size: 13px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
+.placeholder-icon { color: #94a3b8; }
+
+.admin-upload-section { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; margin-top: 18px; }
+.upload-section-title { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #1e293b; margin-bottom: 12px; }
+.deliv-upload-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
+.target-ep-select-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #475569; font-weight: 500; }
+.target-ep-select-label select { padding: 6px 10px; border-radius: 6px; border: 1px solid #cbd5e1; font-size: 12px; background: #fff; }
+.pending-upload-stack { margin-top: 14px; padding: 14px; background: #eff6ff; border: 1px dashed #93c5fd; border-radius: 8px; font-size: 12px; }
+.pending-stack-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 8px; }
+.file-chips-wrap { display: flex; flex-wrap: wrap; gap: 6px; }
+.file-chip { background: #ffffff; border: 1px solid #bfdbfe; color: #1e40af; font-weight: 600; padding: 3px 8px; border-radius: 4px; font-size: 11px; }
+
+.delivery-notes-label { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; font-size: 13px; color: #1e293b; font-weight: 600; }
+.delivery-notes-label textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font: inherit; font-size: 13px; outline: none; box-sizing: border-box; }
+.delivery-notes-label textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
+.admin-delivery-footer { margin-top: 16px; }
+.deliver-confirm-btn { min-height: 44px; font-size: 15px; font-weight: 700; }
+
+/* ==================== 分集讲稿与素材面板 ==================== */
+
+.admin-episodes-list { display: flex; flex-direction: column; gap: 12px; }
+.admin-episode-card { border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,.02); transition: .15s; }
+.admin-episode-card:hover { border-color: #cbd5e1; }
+.admin-ep-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #f8fafc; cursor: pointer; user-select: none; }
+.admin-ep-header .header-left { display: flex; align-items: center; gap: 10px; }
+.ep-badge-pill { font-size: 11px; font-weight: 700; padding: 3px 8px; background: #eff6ff; color: var(--blue); border-radius: 6px; border: 1px solid #bfdbfe; }
+.ep-card-title { font-size: 14px; font-weight: 600; color: #0f172a; }
+.admin-ep-header .header-right { display: flex; align-items: center; gap: 8px; }
+.text-pill { font-size: 11px; color: #475569; background: #ffffff; padding: 3px 8px; border: 1px solid #e2e8f0; border-radius: 4px; }
+.admin-ep-body { padding: 16px; border-top: 1px solid #e2e8f0; background: #ffffff; }
+.notes-label-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
+.notes-label { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; color: #1e293b; }
+.copy-notes-btn { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: #2563eb; background: #eff6ff; border: 1px solid #bfdbfe; padding: 2px 7px; border-radius: 4px; cursor: pointer; transition: .15s; }
+.copy-notes-btn:hover { background: #2563eb; color: #fff; }
+.copy-notes-btn.copied { background: #16a34a; color: #fff; border-color: #16a34a; }
+.notes-pre-content { margin: 0; padding: 12px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; font-family: inherit; font-size: 13px; line-height: 1.7; color: #0f172a; white-space: pre-wrap; word-break: break-word; max-height: 240px; overflow-y: auto; }
+
+.admin-ep-assets-wrap { margin-top: 14px; }
+.ep-assets-label { font-size: 12px; font-weight: 600; color: #475569; display: block; margin-bottom: 8px; }
+.admin-ep-assets-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
+.admin-mini-asset-card { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; }
+.mini-asset-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+.mini-asset-info strong { font-size: 12px; font-weight: 600; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.mini-asset-info small { color: #64748b; font-size: 10px; }
+.download-mini-btn { padding: 3px 8px; font-size: 11px; }
+
+.admin-global-assets-section { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e2e8f0; }
+.sub-section-title { font-size: 14px; font-weight: 700; color: #1e293b; margin: 0 0 12px; }
+.asset-download-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
+.asset-download-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 14px; display: flex; align-items: center; gap: 12px; }
+.asset-download-info { flex: 1; min-width: 0; }
+.asset-download-info strong { display: block; font-size: 13px; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.asset-download-info small { color: #64748b; font-size: 11px; }
+
+/* ==================== 右侧侧边栏卡片 ==================== */
+
+.admin-sidebar-card { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 14px; padding: 22px; margin-bottom: 20px; box-shadow: 0 2px 6px rgba(0,0,0,.02); }
+.admin-sidebar-card .form-title { margin-bottom: 14px; }
+.admin-sidebar-card .form-title h4 { margin: 2px 0 0; font-size: 16px; font-weight: 700; color: #0f172a; }
+
+.creator-sidebar-card { border-top: 3px solid var(--blue); }
+.user-profile-summary { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; padding-bottom: 14px; border-bottom: 1px solid #f1f5f9; }
+.user-avatar-large { width: 48px; height: 48px; border-radius: 50%; background: #eff6ff; color: var(--blue); border: 1.5px solid #bfdbfe; display: grid; place-items: center; flex-shrink: 0; }
+.sidebar-user-name { display: block; font-size: 16px; font-weight: 700; color: #0f172a; }
+.sidebar-user-org { display: block; color: #475569; font-size: 12px; font-weight: 500; margin-top: 1px; }
+.sidebar-user-role { display: inline-block; font-size: 10px; font-weight: 600; color: #2563eb; background: #eff6ff; padding: 2px 6px; border-radius: 4px; margin-top: 3px; }
+
+.contact-list { display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
+.contact-item { display: flex; justify-content: space-between; align-items: center; color: #475569; }
+.contact-label { color: #64748b; font-size: 12px; }
+.contact-val-wrap { display: flex; align-items: center; gap: 6px; }
+.mini-copy-link { border: 0; background: transparent; color: var(--blue); font-size: 11px; font-weight: 600; cursor: pointer; text-decoration: underline; padding: 0; }
+.contact-item strong { color: #0f172a; font-weight: 600; }
+.contact-item.flex-col { flex-direction: column; align-items: flex-start; gap: 6px; margin-top: 6px; }
+.contact-item p { margin: 0; font-size: 12px; color: #334155; line-height: 1.5; background: #f8fafc; padding: 10px 12px; border-radius: 6px; border: 1px solid #e2e8f0; width: 100%; box-sizing: border-box; }
+
+.sidebar-episode-outline { display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow-y: auto; }
+.outline-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: #f8fafc; border-radius: 6px; border: 1px solid #f1f5f9; }
+.outline-badge { font-size: 10px; font-weight: 700; color: var(--blue); background: #eff6ff; padding: 2px 6px; border-radius: 4px; }
+.outline-info { flex: 1; min-width: 0; }
+.outline-info strong { display: block; font-size: 12px; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.outline-info small { color: #64748b; font-size: 11px; }
+
+.course-full-desc { font-size: 13px; color: #334155; line-height: 1.7; margin: 0 0 16px; white-space: pre-line; background: #f8fafc; padding: 12px; border-radius: 8px; border: 1px solid #e2e8f0; }
+.time-meta-box { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: #f8fafc; border-radius: 8px; font-size: 11px; color: #64748b; border: 1px solid #f1f5f9; }
+.time-meta-box div { display: flex; justify-content: space-between; }
+.time-meta-box span { font-weight: 600; color: #1e293b; }
+
+.instructor-sidebar-list { display: flex; flex-direction: column; gap: 14px; }
+.inst-card-detail { display: flex; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid #f1f5f9; }
+.inst-card-detail:last-child { border-bottom: 0; padding-bottom: 0; }
+.inst-photo { width: 56px; height: 68px; object-fit: cover; border-radius: 6px; flex-shrink: 0; border: 1px solid #e2e8f0; }
+.inst-info strong { display: block; font-size: 13px; color: #0f172a; font-weight: 700; }
+.inst-info small { color: #64748b; font-size: 11px; display: block; margin: 2px 0 6px; }
+.inst-info p { margin: 0; font-size: 11px; color: #475569; line-height: 1.5; }
 
 @media (max-width:960px) {
   .admin-detail-grid { grid-template-columns:1fr; }
@@ -777,6 +1196,169 @@ button { cursor:pointer; }
   background: #f8fafc;
 }
 
+/* ==================== 新建用户弹窗 ==================== */
+.modal-dialog.create-user-modal {
+  width: min(520px, calc(100vw - 40px));
+  max-height: min(720px, calc(100vh - 40px));
+  background: #ffffff;
+  border: 1px solid #e2e8f0;
+  border-radius: 16px;
+  box-shadow: 0 25px 60px rgba(15, 23, 42, 0.24);
+  overflow: hidden;
+}
+
+.create-user-modal form {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+}
+
+.create-user-body {
+  display: flex;
+  flex-direction: column;
+  gap: 18px;
+  padding: 24px;
+  overflow-y: auto;
+}
+
+.create-user-body .notice {
+  margin: 0;
+}
+
+.create-user-field {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 8px;
+  color: #334155;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.create-user-field.required > span::after {
+  content: " *";
+  color: #ef4444;
+}
+
+.create-user-field input {
+  width: 100%;
+  min-width: 0;
+  box-sizing: border-box;
+  border: 1px solid #cbd5e1;
+  border-radius: 9px;
+  background: #f8fafc;
+  padding: 11px 13px;
+  color: #0f172a;
+  font: inherit;
+  font-size: 14px;
+  font-weight: 400;
+  transition: border-color .15s ease, background .15s ease, box-shadow .15s ease;
+}
+
+.create-user-field input::placeholder {
+  color: #94a3b8;
+}
+
+.create-user-field input:focus {
+  outline: none;
+  border-color: #3b82f6;
+  background: #ffffff;
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, .12);
+}
+
+.create-user-field small,
+.create-user-hint {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1.55;
+}
+
+.create-user-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+}
+
+.password-generate-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 10px;
+}
+
+.password-generate-row .secondary-button {
+  white-space: nowrap;
+}
+
+.login-credentials {
+  overflow: hidden;
+  border: 1px solid #dbe4ef;
+  border-radius: 10px;
+  background: #f8fafc;
+}
+
+.login-credentials > div {
+  display: grid;
+  grid-template-columns: 88px minmax(0, 1fr);
+  align-items: center;
+  gap: 14px;
+  padding: 13px 16px;
+}
+
+.login-credentials > div + div {
+  border-top: 1px solid #e2e8f0;
+}
+
+.login-credentials span {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.login-credentials strong {
+  overflow-wrap: anywhere;
+  color: #0f172a;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 14px;
+}
+
+.copy-login-button {
+  align-self: stretch;
+  justify-content: center;
+}
+
+@media (max-width: 540px) {
+  .modal-backdrop {
+    align-items: end;
+    padding: 12px;
+  }
+
+  .modal-dialog.create-user-modal {
+    width: 100%;
+    max-height: calc(100vh - 24px);
+    border-radius: 14px;
+  }
+
+  .create-user-modal .modal-header,
+  .create-user-modal .modal-footer,
+  .create-user-body {
+    padding-left: 18px;
+    padding-right: 18px;
+  }
+
+  .create-user-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .password-generate-row {
+    grid-template-columns: 1fr;
+  }
+
+  .password-generate-row .secondary-button {
+    justify-content: center;
+  }
+}
+
 /* ==================== 讲师出镜照片上传与规范示例 ==================== */
 .instructor-image-field-wrapper {
   margin: 18px 0;
@@ -1540,6 +2122,8 @@ button { cursor:pointer; }
   grid-template-columns: minmax(0, 1fr) 340px;
   gap: 24px;
   align-items: start;
+  width: 100%;
+  margin-top: 20px;
 }
 
 .episodes-workspace-main {
@@ -1783,17 +2367,6 @@ button { cursor:pointer; }
   background: #ffffff;
 }
 
-.ep-summary-text {
-  margin: 0 0 16px 0;
-  padding: 8px 12px;
-  background: #f8fafc;
-  border-radius: 6px;
-  font-size: 12px;
-  color: #475569;
-  line-height: 1.5;
-  border-left: 3px solid #3b82f6;
-}
-
 .episode-content-heading {
   display: flex;
   align-items: center;
@@ -3268,6 +3841,13 @@ button { cursor:pointer; }
   background: #f1f5f9;
 }
 
+.help-search-status {
+  min-height: 20px;
+  margin-top: 10px;
+  color: var(--muted);
+  font-size: 13px;
+}
+
 /* 页面小节通用头部 */
 .help-section {
   margin-bottom: 56px;
@@ -3357,6 +3937,11 @@ button { cursor:pointer; }
   flex-direction: column;
 }
 
+.help-feature-grid.is-search-results,
+.help-steps-grid.is-search-results {
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
 .step-badge {
   font-size: 20px;
   font-weight: 700;
@@ -3514,6 +4099,15 @@ button { cursor:pointer; }
   margin: 0;
 }
 
+.help-search-empty {
+  max-width: 860px;
+  margin: 0 auto 56px;
+}
+
+.help-search-empty .secondary-button {
+  margin-top: 18px;
+}
+
 /* 底部联系卡片 Banner */
 .help-contact-banner {
   background: linear-gradient(135deg, #ffffff 0%, #edf4ff 100%);
@@ -3704,6 +4298,3 @@ button { cursor:pointer; }
     font-size: 11px;
   }
 }
-
-
-

+ 17 - 3
src/types.ts

@@ -14,6 +14,17 @@ export interface User {
   waitingCoursesCount?: number
   inProductionCoursesCount?: number
   completedCoursesCount?: number
+  pointsBalance: number
+}
+
+export interface CourseProgressEvent {
+  id: string
+  actorType: 'USER' | 'ADMIN' | 'SYSTEM'
+  eventType: string
+  title: string
+  description: string
+  metadata: Record<string, unknown>
+  createdAt: string
 }
 
 export interface CourseAsset {
@@ -33,6 +44,7 @@ export interface Instructor {
   organization: string
   introduction: string
   imageUrl: string | null
+  imageUrls: string[]
 }
 
 export interface CourseDeliverable {
@@ -64,10 +76,14 @@ export interface Course {
   userId?: string
   name: string
   category: string
-  audience: string
   description: string
   status: CourseStatus
   productionNotes?: string
+  estimatedMinutes: number
+  estimatedPoints: number
+  actualPoints: number | null
+  pointsCharged: boolean
+  progressEvents: CourseProgressEvent[]
   assets: CourseAsset[]
   episodes: Episode[]
   instructors: Instructor[]
@@ -105,7 +121,6 @@ export interface PresetAsset {
 export interface PresetEpisode {
   episodeNumber: number
   title: string
-  summary?: string
   lectureNotes?: string
   assets?: PresetAsset[]
 }
@@ -114,7 +129,6 @@ export interface CourseTemplate {
   id: string
   name: string
   category: string
-  audience?: string
   tags: string[]
   badge?: string
   summary: string

+ 0 - 1
tsconfig.app.tsbuildinfo

@@ -1 +0,0 @@
-{"root":["./src/app.tsx","./src/authcontext.tsx","./src/api.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/addinstructormodal.tsx","./src/components/brand.tsx","./src/components/contactmodal.tsx","./src/components/deletecoursemodal.tsx","./src/components/episodemodal.tsx","./src/components/footer.tsx","./src/components/instructorimagefield.tsx","./src/components/layout.tsx","./src/components/statusbadge.tsx","./src/data/coursetemplates.ts","./src/pages/authpage.tsx","./src/pages/courseworkspace.tsx","./src/pages/dashboard.tsx","./src/pages/helppage.tsx","./src/pages/newcourse.tsx","./src/pages/profile.tsx","./src/pages/admin/admincoursedetail.tsx","./src/pages/admin/admincourses.tsx","./src/pages/admin/admindashboard.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/adminloginpage.tsx","./src/pages/admin/adminusers.tsx"],"version":"5.7.3"}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä