2 Incheckningar 0fd737b845 ... ac4e6e36ff

Upphovsman SHA1 Meddelande Datum
  bob ac4e6e36ff feat: 完善课程制作进度、课程信息编辑与后台管理功能,优化UI交互 1 vecka sedan
  bob ec39a2e209 feat: 优化UI界面与样式,新增帮助中心、页脚及联系客服交流群功能 2 veckor sedan

+ 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",

BIN
public/contact-group-qrcode.jpg


+ 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 - 9
server/templates.ts

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

+ 12 - 2
src/App.tsx

@@ -5,6 +5,7 @@ import { NewCourse } from './pages/NewCourse'
 import { CourseWorkspace } from './pages/CourseWorkspace'
 import { AuthPage } from './pages/AuthPage'
 import { Profile } from './pages/Profile'
+import { HelpPage } from './pages/HelpPage'
 import { AdminLayout } from './pages/admin/AdminLayout'
 import { AdminDashboard } from './pages/admin/AdminDashboard'
 import { AdminCourses } from './pages/admin/AdminCourses'
@@ -32,17 +33,26 @@ 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 />} />
           <Route path="courses/:id" element={<CourseWorkspace />} />
           <Route path="profile" element={<Profile />} />
+          <Route path="help" element={<HelpPage />} />
         </Route>
       ) : 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',

BIN
src/assets/contact-group-qrcode.jpg


+ 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">

+ 36 - 4
src/components/Brand.tsx

@@ -1,6 +1,38 @@
 export function Brand({ compact = false }: { compact?: boolean }) {
-  return <div className={`brand ${compact ? 'brand--compact' : ''}`} aria-label="星痕智能课程工坊">
-    <span className="brand-mark" aria-hidden="true"><i /><b /></span>
-    <span><strong>星痕</strong>{!compact && <small>XINGHEN</small>}</span>
-  </div>
+  return (
+    <div className={`brand ${compact ? 'brand--compact' : ''}`} aria-label="星痕智能课程工坊">
+      <span className="brand-mark" aria-hidden="true">
+        <svg
+          className="brand-mark-svg"
+          viewBox="0 0 44 26"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <defs>
+            <linearGradient id="brandGoldDot" x1="0%" y1="0%" x2="100%" y2="100%">
+              <stop offset="0%" stopColor="#deb66c" />
+              <stop offset="100%" stopColor="#b58735" />
+            </linearGradient>
+            <linearGradient id="brandArcTrail" x1="0%" y1="0%" x2="100%" y2="0%">
+              <stop offset="0%" stopColor="#8292a8" />
+              <stop offset="60%" stopColor="#8fa0b5" />
+              <stop offset="100%" stopColor="#a5b5cb" />
+            </linearGradient>
+          </defs>
+          <circle cx="8" cy="18" r="3.6" fill="url(#brandGoldDot)" />
+          <path
+            d="M 14.2 14.2 C 20.2 7.2, 30.2 4.6, 41 10"
+            stroke="url(#brandArcTrail)"
+            strokeWidth="1.8"
+            strokeLinecap="round"
+          />
+        </svg>
+      </span>
+      <span className="brand-text">
+        <strong className="brand-title">星痕</strong>
+        {!compact && <small className="brand-sub">XINGHEN</small>}
+      </span>
+    </div>
+  )
 }
+

+ 98 - 0
src/components/ContactModal.tsx

@@ -0,0 +1,98 @@
+import { MessageSquare, QrCode, Sparkles, X } from 'lucide-react'
+import contactQrCode from '../assets/contact-group-qrcode.jpg'
+
+interface ContactModalProps {
+  onClose: () => void
+}
+
+export function ContactModal({ onClose }: ContactModalProps) {
+  return (
+    <div className="modal-backdrop" onClick={onClose}>
+      <div
+        className="modal-dialog contact-modal"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="modal-header">
+          <div>
+            <span className="modal-badge highlight-badge">CONTACT SUPPORT</span>
+            <h3>联系星痕 · 专属制作顾问</h3>
+          </div>
+          <button
+            type="button"
+            className="close-modal-btn"
+            onClick={onClose}
+            title="关闭"
+          >
+            <X size={18} />
+          </button>
+        </div>
+
+        <div className="modal-body contact-modal-body">
+          <div className="contact-modal-intro">
+            <p>
+              请扫描下方二维码添加微信,加入<strong>「星痕课程制作沟通群」</strong>。
+              制作团队将在群内为您同步排期制作进度,并提供一对一课件制作支持。
+            </p>
+          </div>
+
+          <div className="contact-modal-qr-section">
+            <div className="contact-modal-qr-card">
+              <div className="qr-image-wrapper">
+                <img
+                  src={contactQrCode}
+                  alt="微信群二维码"
+                  className="contact-modal-qr-img"
+                />
+              </div>
+              <div className="qr-badge-text">
+                <QrCode size={14} />
+                <span>微信扫一扫 · 加群联系</span>
+              </div>
+            </div>
+
+            <div className="contact-modal-steps">
+              <div className="contact-step-card">
+                <span className="step-badge-num">1</span>
+                <div className="step-content">
+                  <strong>扫码进群</strong>
+                  <p>微信扫描左侧二维码加入专属制作对接群</p>
+                </div>
+              </div>
+
+              <div className="contact-step-card">
+                <span className="step-badge-num">2</span>
+                <div className="step-content">
+                  <strong>核对课程信息</strong>
+                  <p>群内发送您提交的课程名称,以便顾问快速定位</p>
+                </div>
+              </div>
+
+              <div className="contact-step-card">
+                <span className="step-badge-num">3</span>
+                <div className="step-content">
+                  <strong>确认排期与交付</strong>
+                  <p>实时沟通制作方案与细节,跟进分集交付状态</p>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className="contact-modal-footer-tip">
+            <Sparkles size={16} />
+            <span>如二维码过期或无法进群,可联系平台客服为您手动拉群。</span>
+          </div>
+        </div>
+
+        <div className="modal-footer">
+          <button
+            type="button"
+            className="primary-button full-width"
+            onClick={onClose}
+          >
+            我知道了
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 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>
+  )
+}

+ 3 - 9
src/components/DeleteCourseModal.tsx

@@ -71,15 +71,9 @@ export function DeleteCourseModal({
       }}
     >
       <div className="modal-dialog delete-course-modal" role="dialog" aria-modal="true">
-        <div className="modal-header danger-header">
-          <div className="danger-header-title">
-            <div className="danger-icon-badge">
-              <AlertTriangle size={20} />
-            </div>
-            <div>
-              <span className="modal-badge danger-badge">DANGER ZONE</span>
-              <h3>删除课程</h3>
-            </div>
+        <div className="modal-header">
+          <div className="modal-header-simple-title">
+            <h3>删除课程</h3>
           </div>
           <button
             type="button"

+ 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>

+ 30 - 0
src/components/Footer.tsx

@@ -0,0 +1,30 @@
+import { Link } from 'react-router-dom'
+
+export function Footer() {
+  return (
+    <footer className="site-footer">
+      <div className="site-footer-inner">
+        <div className="site-footer-company">
+          <span>杭州纯粹星辰人工智能科技有限公司</span>
+          <span className="site-footer-dot">·</span>
+          <span>2026 版权所有</span>
+        </div>
+
+        <div className="site-footer-links">
+          <a
+            href="https://beian.miit.gov.cn/"
+            target="_blank"
+            rel="noreferrer"
+            className="site-footer-beian"
+          >
+            浙ICP备2026057502号
+          </a>
+          <span className="site-footer-sep">·</span>
+          <Link to="/help" className="site-footer-help-link">
+            帮助中心
+          </Link>
+        </div>
+      </div>
+    </footer>
+  )
+}

+ 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">

+ 6 - 3
src/components/Layout.tsx

@@ -1,6 +1,7 @@
 import { CircleHelp, LogOut, UserRound } from 'lucide-react'
 import { NavLink, Outlet } from 'react-router-dom'
 import { Brand } from './Brand'
+import { Footer } from './Footer'
 import { useAuth } from '../AuthContext'
 
 export function Layout() {
@@ -18,21 +19,23 @@ 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>
           </NavLink>
-          <button className="icon-button" title="帮助">
+          <NavLink to="/help" className="icon-button" title="使用帮助">
             <CircleHelp size={19} />
-          </button>
+          </NavLink>
           <button className="icon-button" title="退出登录" onClick={logout}>
             <LogOut size={18} />
           </button>
         </div>
       </header>
-      <main>
+      <main className="app-main">
         <Outlet />
       </main>
+      <Footer />
     </div>
   )
 }

+ 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集我们将迎来微分学的核心支柱——拉格朗日中值定理。它是联系局部导数与整体增量的桥梁……'
       }
     ],

+ 120 - 2
src/pages/AuthPage.tsx

@@ -1,2 +1,120 @@
-import{FormEvent,useState}from'react';import{Navigate}from'react-router-dom';import{ArrowRight,LockKeyhole,Phone}from'lucide-react';import{Brand}from'../components/Brand';import{useAuth}from'../AuthContext'
-export function AuthPage(){const{user,authenticate}=useAuth(),[mode,setMode]=useState<'login'|'register'>('login'),[busy,setBusy]=useState(false),[error,setError]=useState('');if(user)return <Navigate to="/" replace/>;async function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const f=new FormData(e.currentTarget);setBusy(true);setError('');try{await authenticate(mode,String(f.get('phone')),String(f.get('password')))}catch(x){setError(x instanceof Error?x.message:'操作失败')}finally{setBusy(false)}}return <div className="auth-page"><section className="auth-brand"><Brand/><div><p className="eyebrow">智能课程工坊</p><h1>让每一门好课,<br/><em>留下清晰的星痕。</em></h1><p>归集课程资产与讲师信息,让课程制作更简单。</p></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>手机号<div className="input-with-icon"><Phone/><input name="phone" inputMode="numeric" required pattern="1[0-9]{10}" placeholder="请输入 11 位手机号"/></div></label><label>密码<div className="input-with-icon"><LockKeyhole/><input name="password" type="password" required minLength={6} placeholder="至少 6 位密码"/></div></label>{error&&<div className="notice notice-error">{error}</div>}<button className="primary-button full-button" disabled={busy}>{busy?'请稍候…':mode==='login'?'登录':'注册'}<ArrowRight size={17}/></button><button type="button" className="auth-switch" onClick={()=>{setMode(mode==='login'?'register':'login');setError('')}}>{mode==='login'?'还没有账号?立即注册':'已有账号?返回登录'}</button></form></div>}
+import { FormEvent, useState } from 'react'
+import { Navigate } from 'react-router-dom'
+import { ArrowRight, LockKeyhole, Phone } from 'lucide-react'
+import { Brand } from '../components/Brand'
+import { Footer } from '../components/Footer'
+import { useAuth } from '../AuthContext'
+
+export function AuthPage() {
+  const { user, authenticate } = useAuth()
+  const [mode, setMode] = useState<'login' | 'register'>('login')
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+
+  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')), password)
+    } catch (x) {
+      setError(x instanceof Error ? x.message : '操作失败')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  return (
+    <div className="auth-wrapper">
+      <div className="auth-page">
+        <section className="auth-brand">
+          <Brand />
+          <div>
+            <p className="">智能课程工坊</p>
+            <h1>
+              让每一门好课,
+              <br />
+              <em>留下清晰的星痕。</em>
+            </h1>
+            <p>归集课程资产与讲师信息,让课程制作更简单。</p>
+          </div>
+        </section>
+        <form className="auth-card" onSubmit={submit}>
+          <h2>{mode === 'login' ? '登录星痕' : '创建账号'}</h2>
+          <p>{mode === 'login' ? '使用手机号和密码继续' : '注册后即可开始'}</p>
+          <label>
+            手机号
+            <div className="input-with-icon">
+              <Phone />
+              <input
+                name="phone"
+                inputMode="numeric"
+                required
+                pattern="1[0-9]{10}"
+                placeholder="请输入 11 位手机号"
+              />
+            </div>
+          </label>
+          <label>
+            密码
+            <div className="input-with-icon">
+              <LockKeyhole />
+              <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' ? '登录' : '注册'}
+            <ArrowRight size={17} />
+          </button>
+          <button
+            type="button"
+            className="auth-switch"
+            onClick={() => {
+              setMode(mode === 'login' ? 'register' : 'login')
+              setError('')
+            }}
+          >
+            {mode === 'login' ? '还没有账号?立即注册' : '已有账号?返回登录'}
+          </button>
+        </form>
+      </div>
+      <Footer />
+    </div>
+  )
+}

+ 236 - 57
src/pages/CourseWorkspace.tsx

@@ -11,11 +11,12 @@ import {
   FileText,
   Layers,
   LoaderCircle,
+  MessageCircle,
+  MessageSquare,
   PackageCheck,
   Plus,
   Save,
   Send,
-  Sparkles,
   Trash2,
   Upload,
   UserPlus,
@@ -28,6 +29,10 @@ import { StatusBadge } from '../components/StatusBadge'
 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'
 
 const size = (n: number) =>
@@ -42,11 +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)
 
   // 全局资产待上传
@@ -191,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] }))
@@ -211,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) || []
@@ -228,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>
@@ -242,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>
 
@@ -352,9 +407,11 @@ export function CourseWorkspace() {
             </span>
           </div>
 
-          <Link className="secondary-button" to="/">
-            返回课程列表
-          </Link>
+          <div className="success-panel-actions">
+            <Link className="secondary-button" to="/">
+              返回课程列表
+            </Link>
+          </div>
         </section>
       )}
 
@@ -364,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} 个分集讲稿与课件素材` : '课件素材'},制作完成后将第一时间在此处按集交付成品。
@@ -387,17 +443,26 @@ export function CourseWorkspace() {
               <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
             </span>
           </div>
-          <Link className="secondary-button" to="/">
-            返回课程列表
-          </Link>
+          <div className="success-panel-actions">
+            <Link className="secondary-button" to="/">
+              返回课程列表
+            </Link>
+            <button
+              type="button"
+              className="secondary-button"
+              onClick={() => setShowContactModal(true)}
+            >
+              <MessageCircle size={15} />
+              联系星痕
+            </button>
+          </div>
         </section>
       )}
 
       {/* 等待制作面板 */}
-      {isWaiting && (
+      {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">
@@ -418,15 +483,96 @@ export function CourseWorkspace() {
               <strong>{course.submittedAt ? new Date(course.submittedAt).toLocaleString('zh-CN') : '—'}</strong>
             </span>
           </div>
-          <Link className="secondary-button" to="/">
-            返回课程列表
-          </Link>
+
+          {/* 联系星痕 · 进群引导与二维码卡片 */}
+          <div className="contact-support-card">
+            <div className="contact-card-header">
+              <div className="contact-icon-badge">
+                <MessageSquare size={18} />
+              </div>
+              <div className="contact-header-text">
+                <h4>联系星痕 · 添加微信进群跟进排期</h4>
+                <p>建议添加微信并加入官方制作沟通群,与专属制作顾问对接排期进度与个性化需求</p>
+              </div>
+            </div>
+
+            <div className="contact-card-body">
+              <div className="contact-qr-container">
+                <div className="contact-qr-box">
+                  <img
+                    src={contactQrCode}
+                    alt="联系星痕 - 微信群二维码"
+                    className="contact-qr-image"
+                  />
+                </div>
+                <span className="contact-qr-caption">扫描二维码加入群聊</span>
+              </div>
+
+              <div className="contact-card-steps">
+                <div className="contact-step-row">
+                  <span className="step-circle">1</span>
+                  <div className="step-desc">
+                    <strong>微信扫码进群</strong>
+                    <span>使用微信扫描二维码,加入「星痕课程制作沟通群」</span>
+                  </div>
+                </div>
+                <div className="contact-step-row">
+                  <span className="step-circle">2</span>
+                  <div className="step-desc">
+                    <strong>发送课程名称</strong>
+                    <span>进群后发送已提交课程的名称,以便专属顾问快速核对并建立制作档案</span>
+                  </div>
+                </div>
+                <div className="contact-step-row">
+                  <span className="step-circle">3</span>
+                  <div className="step-desc">
+                    <strong>确认排期与跟进交付</strong>
+                    <span>制作团队将与您一对一沟通细节,并在制作完成后第一时间在此处交付</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </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">
             {/* 顶部统计与操作卡 */}
@@ -434,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 集微积分),为每一集在线录入口播讲稿,并上传该集的专属课件与素材文件。
@@ -537,7 +682,7 @@ export function CourseWorkspace() {
                             讲稿 ({ep.lectureNotes.length}字)
                           </span>
                         ) : (
-                          <span className="tag-pill tag-pill-dim">待补充讲稿</span>
+                          <span className="tag-pill tag-pill-dim">未添加讲稿</span>
                         )}
 
                         <span className="tag-pill tag-pill-info">
@@ -572,15 +717,21 @@ 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>
+                            <span>口播稿与素材均为选填,可按实际制作方式添加</span>
+                          </div>
+                        </div>
 
                         <div className="ep-body-grid">
-                          {/* 左半:口播讲稿正文 */}
+                          {/* 口播稿(选填) */}
                           <div className="ep-notes-box">
                             <div className="box-title-row">
                               <div className="title-left">
                                 <FileText size={15} />
-                                <strong>本集口播讲稿正文</strong>
+                                <strong>口播稿</strong>
+                                <span className="optional-label">选填</span>
                               </div>
                               {isNotesDirty && <span className="dirty-badge">未保存</span>}
                             </div>
@@ -592,7 +743,7 @@ export function CourseWorkspace() {
                                 const val = e.target.value
                                 setNotesDraftMap((prev) => ({ ...prev, [ep.id]: val }))
                               }}
-                              placeholder="直接在此输入或粘贴本集的口播讲稿/字幕文稿(如第一讲引入、推导、例题等)…"
+                              placeholder="可直接输入或粘贴本集的口播稿、字幕文稿…"
                             />
                             <div className="notes-footer-row">
                               <small>共 {draftNotes.length} 字</small>
@@ -608,13 +759,28 @@ export function CourseWorkspace() {
                             </div>
                           </div>
 
-                          {/* 右半:本集专属资产上传与管理 */}
+                          {/* 本集素材(选填) */}
                           <div className="ep-assets-box">
                             <div className="box-title-row">
                               <div className="title-left">
                                 <FileSpreadsheet size={15} />
-                                <strong>本集专属素材 ({epAssets.length})</strong>
+                                <strong>上传素材</strong>
+                                <span className="optional-label">选填</span>
+                                {epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
                               </div>
+                              <label className="ep-file-label compact-upload-button">
+                                <Upload size={14} />
+                                <span>选择文件</span>
+                                <input
+                                  type="file"
+                                  multiple
+                                  hidden
+                                  onChange={(e) => {
+                                    const selected = Array.from(e.target.files || [])
+                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
+                                  }}
+                                />
+                              </label>
                             </div>
 
                             {/* 本集已有素材列表 */}
@@ -637,26 +803,12 @@ export function CourseWorkspace() {
                                 </div>
                               ))}
                               {!epAssets.length && (
-                                <p className="empty-sub-tip">本集暂无独立附件文件,可上传课件 PPT、Word 讲稿等。</p>
+                                <p className="empty-sub-tip">如有需要,可上传本集使用的 PPT、Word、图片等制作素材。</p>
                               )}
                             </div>
 
-                            {/* 本集文件选择区 */}
+                            {/* 待上传文件 */}
                             <div className="ep-upload-zone">
-                              <label className="ep-file-label">
-                                <Upload size={14} />
-                                <span>选择本集课件/讲稿附件</span>
-                                <input
-                                  type="file"
-                                  multiple
-                                  hidden
-                                  onChange={(e) => {
-                                    const selected = Array.from(e.target.files || [])
-                                    setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
-                                  }}
-                                />
-                              </label>
-
                               {pendingFiles.length > 0 && (
                                 <div className="ep-pending-files">
                                   <div className="pending-chips">
@@ -701,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 模板、讲师全局参考音频等不区分分集的通用资料。
@@ -767,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
@@ -783,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>
@@ -815,21 +969,29 @@ 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>
+        </>
       )}
 
-      {/* 课程详情最底部:危险操作区 / 删除课程 */}
+      {/* 课程详情最底部:低调的辅助操作区 / 删除课程 */}
       <section className="course-danger-section">
         <div className="danger-zone-card">
           <div className="danger-zone-info">
             <div className="danger-zone-title-row">
-              <Trash2 size={18} className="danger-icon" />
+              <Trash2 size={15} className="danger-icon" />
               <h4>删除课程</h4>
             </div>
             <p>
@@ -842,28 +1004,40 @@ export function CourseWorkspace() {
             {isDraft ? (
               <button
                 type="button"
-                className="danger-button"
+                className="danger-button-subtle"
                 onClick={() => setShowDeleteModal(true)}
               >
-                <Trash2 size={15} />
+                <Trash2 size={14} />
                 删除课程
               </button>
             ) : (
               <button
                 type="button"
-                className="danger-button disabled"
+                className="danger-button-subtle disabled"
                 disabled
                 title="已等待制作或制作完成的课程不能删除"
               >
-                <Trash2 size={15} />
+                <Trash2 size={14} />
                 不可删除
               </button>
             )}
           </div>
         </div>
       </section>
+        </div>
+      )}
 
       {/* 独立添加讲师信息弹窗 */}
+      <EditCourseModal
+        course={course}
+        isOpen={showEditCourseModal}
+        onClose={() => setShowEditCourseModal(false)}
+        onSuccess={(updatedCourse) => {
+          setCourse(updatedCourse)
+          notify('课程信息已更新')
+        }}
+      />
+
       <AddInstructorModal
         isOpen={showAddInstructorModal}
         onClose={() => setShowAddInstructorModal(false)}
@@ -898,6 +1072,11 @@ export function CourseWorkspace() {
         }}
       />
 
+      {/* 联系星痕沟通弹窗 */}
+      {showContactModal && (
+        <ContactModal onClose={() => setShowContactModal(false)} />
+      )}
+
       {error && <div className="notice notice-error">{error}</div>}
     </div>
   )

+ 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">

+ 366 - 0
src/pages/HelpPage.tsx

@@ -0,0 +1,366 @@
+import { Children, isValidElement, useEffect, useMemo, useState, type ReactNode } from 'react'
+import {
+  ArrowLeft,
+  ChevronDown,
+  ChevronUp,
+  FileQuestion,
+  Layers,
+  MessageCircle,
+  MessageSquare,
+  Search,
+  UploadCloud,
+  UserCheck,
+  Video,
+} from 'lucide-react'
+import { Link } from 'react-router-dom'
+import { ContactModal } from '../components/ContactModal'
+
+interface FAQItem {
+  id: string
+  category: 'getting-started' | 'materials' | 'production' | 'account'
+  question: string
+  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: '入门与创建' },
+  { key: 'materials', label: '课件与素材规范' },
+  { key: 'production', label: '制作与交付' },
+  { key: 'account', label: '服务与支持' },
+] as const
+
+const FAQ_LIST: FAQItem[] = [
+  {
+    id: 'what-is-xinghen',
+    category: 'getting-started',
+    question: '星痕·智能课程工坊是什么?它能为我做什么?',
+    answer:
+      '星痕是一个专为知识创作者、讲师和教研团队打造的数字化课程生产工坊。通过标准化的分集大纲编排、讲师形象资产归集与课件整理,星痕能将您的教案素材高效转化为高质量的音视频课件与数字化成品。',
+  },
+  {
+    id: 'how-to-start',
+    category: 'getting-started',
+    question: '制作一门新课程的标准流程是怎样的?',
+    answer: (
+      <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>:上传讲师出镜形象照片(坐姿/站姿)及配套 PPT 课件;</li>
+          <li><strong>提交制作</strong>:确认材料无误后点击“提交制作”,制作团队将跟进合成并交付最终视频。</li>
+        </ol>
+      </div>
+    ),
+  },
+  {
+    id: 'course-templates',
+    category: 'getting-started',
+    question: '新手可以如何快速体验?有预设模板吗?',
+    answer:
+      '在创建课程页面,我们提供了多种精选的官方课程模板(如企业培训、通识科普、专业讲座等)。点击“使用示例”即可一键预填完整的分集大纲、讲师配置与配套素材,方便您快速熟悉全套工作流。',
+  },
+  {
+    id: 'ppt-requirements',
+    category: 'materials',
+    question: '上传 PPT / 课件文件有什么格式和要求?',
+    answer:
+      '支持常见的 PPT、PPTX、PDF 以及压缩包文件。建议课件页面比例为 16:9(1920×1080 最佳),文字排版留有合适边距;若包含特殊字体或复杂排版动画,建议在提交前同步导出或上传一份 PDF 副本以便制作核对。',
+  },
+  {
+    id: 'instructor-photo-guidelines',
+    category: 'materials',
+    question: '讲师照片与出镜形象有什么拍摄或上传规范?',
+    answer: (
+      <div>
+        <p style={{ margin: '0 0 6px' }}>为保证最终合成的画质与真实感,推荐遵守以下要求:</p>
+        <ul style={{ margin: 0, paddingLeft: '18px', lineHeight: 1.7 }}>
+          <li><strong>背景要求</strong>:优先使用纯色背景(白底/绿幕/纯色墙面)或已抠图的透明背景 PNG;</li>
+          <li><strong>姿态清晰</strong>:支持上传站姿、坐姿等高清照片,面部与服饰光线充足均匀,避免逆光与重度阴影;</li>
+          <li><strong>格式大小</strong>:支持 JPG、PNG、WebP 等格式,单张建议在 5MB 以内。</li>
+        </ul>
+      </div>
+    ),
+  },
+  {
+    id: 'script-writing-tips',
+    category: 'materials',
+    question: '分集逐字稿(口播文案)怎么写更利于制作?',
+    answer:
+      '建议逐字稿使用通俗易懂的口语化表达,按讲解节奏合理分段并添加标点符号;对于生僻字、英文缩写或专业术语,可标注拼音或中文读音,以确保声音合成与讲解语调准确自然。',
+  },
+  {
+    id: 'production-timeline',
+    category: 'production',
+    question: '课程提交制作后,通常需要多长时间交付?',
+    answer:
+      '提交制作后,课程会进入“等待制作”队列。通常 1~3 集的轻量课程在 1~3 个工作日内完成初版合成与质检;多集复杂课程将根据总时长与排期协商交付时间。您可以在控制台随时关注最新进度。',
+  },
+  {
+    id: 'course-statuses-meaning',
+    category: 'production',
+    question: '课程的不同状态分别代表什么意思?',
+    answer: (
+      <div>
+        <p style={{ margin: '0 0 6px' }}>课程生命周期包含以下状态:</p>
+        <ul style={{ margin: 0, paddingLeft: '18px', lineHeight: 1.7 }}>
+          <li><span className="status status--draft">草稿 DRAFT</span>:正在编辑完善阶段,可随时增删分集与素材;</li>
+          <li><span className="status status--waiting_production">等待制作 WAITING</span>:已确认并提交,等待制作团队认领排期;</li>
+          <li><span className="status status--in_production">制作中 IN PRODUCTION</span>:团队正在进行音视频渲染、剪辑与合成;</li>
+          <li><span className="status status--completed">已完成 COMPLETED</span>:制作完成,所有分集交付物已上架供下载。</li>
+        </ul>
+      </div>
+    ),
+  },
+  {
+    id: 'edit-after-submit',
+    category: 'production',
+    question: '提交制作后如果发现材料有误,还能修改吗?',
+    answer:
+      '课程提交后工作区将进入锁定保护状态,以保证制作期间版本的一致性。如需紧急更新脚本或替换课件,可直接点击页面底部的“联系制作顾问”加入微信沟通群,告知客服进行素材补充或退回调整。',
+  },
+  {
+    id: 'deliverables-download',
+    category: 'production',
+    question: '制作完成后我如何查看和下载最终成品?',
+    answer:
+      '课程状态变更为“已完成”后,进入该课程工作区即可看到绿色的「已交付成果」面板。您可以直接在线播放预览成片,或一键下载 MP4 高清视频文件及配套课件成果。',
+  },
+  {
+    id: 'how-to-contact',
+    category: 'account',
+    question: '遇到问题如何联系官方制作顾问与技术支持?',
+    answer:
+      '您可以点击页面上的“联系顾问”按钮,扫描弹窗中的二维码加入「星痕课程制作沟通群」,将有专属制作顾问为您提供一对一咨询、进度追踪与课件定制支持。',
+  },
+  {
+    id: 'profile-and-security',
+    category: 'account',
+    question: '如何修改我的联系人姓名、单位或登录密码?',
+    answer:
+      '点击顶部导航栏右上角的个人账号名称(或前往「个人中心 /profile」),即可修改您的联系人姓名、单位名称、微信号,或进行登录密码的修改。',
+  },
+]
+
+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>('')
+  const [openId, setOpenId] = useState<string | null>('what-is-xinghen')
+  const [showContact, setShowContact] = useState<boolean>(false)
+
+  const toggleAccordion = (id: string) => {
+    setOpenId((prev) => (prev === id ? null : id))
+  }
+
+  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
+
+  useEffect(() => {
+    if (isSearching && filteredFaqs.length > 0) setOpenId(filteredFaqs[0].id)
+  }, [isSearching, searchKeyword])
+
+  return (
+    <div className="page help-page">
+      <Link to="/" className="back-link">
+        <ArrowLeft size={16} /> 返回我的课程
+      </Link>
+
+      {/* 顶部介绍区 */}
+      <section className="help-hero">
+        <div className="help-hero-content">
+          <h1>帮助与使用指南</h1>
+          <p className="help-hero-desc">
+            了解星痕智能课程工坊的运作模式、素材规范与制作交付流程,让好课制作更轻松。
+          </p>
+
+          <div className="help-search-bar">
+            <Search size={18} className="search-icon" />
+            <input
+              aria-label="搜索帮助页面内容"
+              type="text"
+              placeholder="搜索常见问题,例如:PPT要求、制作周期、讲师照片..."
+              value={searchKeyword}
+              onChange={(e) => setSearchKeyword(e.target.value)}
+            />
+            {searchKeyword && (
+              <button
+                type="button"
+                className="clear-search-btn"
+                onClick={() => setSearchKeyword('')}
+              >
+                清空
+              </button>
+            )}
+          </div>
+          <div className="help-search-status" aria-live="polite">
+            {isSearching && `找到 ${searchResultCount} 条与“${searchKeyword.trim()}”相关的内容`}
+          </div>
+        </div>
+      </section>
+
+      {/* 网站介绍与核心能力 */}
+      {(!isSearching || filteredFeatures.length > 0) && <section className="help-section">
+        <div className="help-section-header">
+          <h2>认识星痕课程工坊</h2>
+          <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>}
+
+      {/* 快速入门 4 步流程 */}
+      {(!isSearching || filteredWorkflow.length > 0) && <section className="help-section">
+        <div className="help-section-header">
+          <h2>课程制作四步法</h2>
+          <p>从创意构思到拿到成品成片,只需要清晰的四步</p>
+        </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>}
+
+      {/* 常见问题 FAQ */}
+      {(!isSearching || filteredFaqs.length > 0) && <section className="help-section">
+        <div className="help-section-header">
+          <h2>常见问题解答</h2>
+          <p>遇到疑问?这里整理了大家最常咨询的操作与制作问题</p>
+        </div>
+
+        {/* 分类过滤标签 */}
+        {!isSearching && <div className="faq-category-tabs">
+          {CATEGORIES.map((cat) => (
+            <button
+              key={cat.key}
+              type="button"
+              className={`faq-tab-btn ${activeCategory === cat.key ? 'active' : ''}`}
+              onClick={() => setActiveCategory(cat.key)}
+            >
+              {cat.label}
+            </button>
+          ))}
+        </div>}
+
+        {/* 问答列表 */}
+        <div className="faq-accordion-list">
+          {filteredFaqs.length === 0 ? (
+            <div className="faq-empty-state">
+              <FileQuestion size={36} />
+              <h4>未找到匹配的问题</h4>
+              <p>试着换个搜索关键词,或直接联系制作顾问获取支持。</p>
+            </div>
+          ) : (
+            filteredFaqs.map((faq) => {
+              const isOpen = openId === faq.id
+              return (
+                <div
+                  key={faq.id}
+                  className={`faq-accordion-item ${isOpen ? 'is-open' : ''}`}
+                >
+                  <button
+                    type="button"
+                    className="faq-question-btn"
+                    onClick={() => toggleAccordion(faq.id)}
+                    aria-expanded={isOpen}
+                  >
+                    <span className="faq-question-text">{faq.question}</span>
+                    <span className="faq-chevron-icon">
+                      {isOpen ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
+                    </span>
+                  </button>
+                  {isOpen && (
+                    <div className="faq-answer-content">
+                      <div className="faq-answer-inner">{faq.answer}</div>
+                    </div>
+                  )}
+                </div>
+              )
+            })
+          )}
+        </div>
+      </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">
+        <div className="contact-banner-content">
+          <div className="contact-banner-icon">
+            <MessageSquare size={28} />
+          </div>
+          <div>
+            <h3>还有其他疑问或需要定制支持?</h3>
+            <p>
+              加入星痕专属制作沟通群,制作顾问与技术团队将为您提供 1 对 1 咨询与进度同步。
+            </p>
+          </div>
+        </div>
+        <div className="contact-banner-action">
+          <button
+            type="button"
+            className="primary-button"
+            onClick={() => setShowContact(true)}
+          >
+            <MessageCircle size={16} /> 联系制作顾问
+          </button>
+        </div>
+      </section>
+
+      {/* 联系顾问弹窗 */}
+      {showContact && <ContactModal onClose={() => setShowContact(false)} />}
+    </div>
+  )
+}

+ 10 - 49
src/pages/NewCourse.tsx

@@ -5,13 +5,10 @@ import {
   Check,
   CheckCircle2,
   FileCheck,
-  FileSpreadsheet,
-  FileText,
   Flame,
   Info,
   Layers,
   Sparkles,
-  UserCheck,
   Users,
   Video,
   Volume2,
@@ -23,6 +20,9 @@ import { Link, useNavigate } from 'react-router-dom'
 import { api } from '../api'
 import { COURSE_TEMPLATES, type CourseTemplate } from '../data/courseTemplates'
 
+const DEFAULT_COURSE_DESCRIPTION = `【课程简介】本课程围绕核心主题展开,通过清晰的知识讲解与案例说明,帮助学习者理解并掌握课程重点。
+【制作要求】采用“讲师讲解 + PPT课件”的形式,画面简洁清晰,内容层次分明;讲解自然流畅,重点内容配合字幕或图示呈现;视频建议使用16:9横屏、1080P高清规格。`
+
 export function NewCourse() {
   const navigate = useNavigate()
   const [templates, setTemplates] = useState<CourseTemplate[]>(COURSE_TEMPLATES)
@@ -32,7 +32,7 @@ export function NewCourse() {
   // 表单状态
   const [name, setName] = useState('')
   const [category, setCategory] = useState('党政专题')
-  const [description, setDescription] = useState('')
+  const [description, setDescription] = useState(DEFAULT_COURSE_DESCRIPTION)
 
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
@@ -85,7 +85,6 @@ export function NewCourse() {
       const { course } = await api.createCourse({
         name: name.trim(),
         category,
-        audience: '',
         description: description.trim()
       })
       navigate(`/courses/${course.id}`)
@@ -113,7 +112,7 @@ export function NewCourse() {
             这是一门什么课。
           </h1>
           <p className="aside-subtext">
-            填写基本信息后,可添加课程资产和讲师;您也可以直接选择下方官方示范任务书一键初始化
+            填写基本信息后,可继续添加课程资产和讲师;也可以参考下方制作示例快速开始
           </p>
 
           {/* 示范模版精选卡片 */}
@@ -123,7 +122,6 @@ export function NewCourse() {
                 <Flame size={16} className="flame-icon" />
                 <strong>制作示例</strong>
               </div>
-              <span className="sample-count-tag">{templates.length} 个模版可直接使用</span>
             </div>
 
             {/* 模板切换标签(支持未来扩展) */}
@@ -144,13 +142,6 @@ export function NewCourse() {
             {/* 当前选中模版内容卡片 */}
             <div className="selected-template-card">
               <div className="card-top">
-                <div className="card-tags">
-                  {currentTemplate.tags.map((tag, idx) => (
-                    <span key={idx} className="sample-tag">
-                      {tag}
-                    </span>
-                  ))}
-                </div>
                 <h3 className="sample-title">{currentTemplate.name}</h3>
                 <p className="sample-desc">{currentTemplate.summary}</p>
               </div>
@@ -178,28 +169,6 @@ export function NewCourse() {
                     <span>{currentTemplate.keySpecs.speechSpeed}</span>
                   </div>
                 </div>
-                <div className="spec-item">
-                  <UserCheck size={13} />
-                  <div>
-                    <small>预置示范讲师</small>
-                    <span>{currentTemplate.instructors.map((i) => i.name).join('、')}</span>
-                  </div>
-                </div>
-              </div>
-
-              {/* 预置素材与任务书一览 */}
-              <div className="preset-assets-strip">
-                <div className="strip-title">
-                  <FileSpreadsheet size={13} />
-                  <span>包含课件与制作资料 ({currentTemplate.assets.length} 份)</span>
-                </div>
-                <div className="preset-pill-list">
-                  {currentTemplate.assets.map((ast, i) => (
-                    <span key={i} className="asset-minitag" title={ast.description}>
-                      <FileText size={11} /> {ast.originalName}
-                    </span>
-                  ))}
-                </div>
               </div>
 
               {/* 模版操作按钮组 */}
@@ -251,30 +220,22 @@ export function NewCourse() {
                 <span>COURSE PROFILE</span>
                 <h2>课程基本信息</h2>
               </div>
-              <button
-                type="button"
-                className="quick-sample-pill-btn"
-                onClick={() => handleFillTemplate(currentTemplate)}
-                title="快速载入精选示范内容"
-              >
-                <Wand2 size={13} /> 载入精选示例
-              </button>
             </div>
 
             <label>
-              课程名称 <b>*</b>
+              <span>课程名称 <b>*</b></span>
               <input
                 name="name"
                 required
                 maxLength={100}
-                placeholder="例如:20260715《坚持人民至上根本立场党课视频"
+                placeholder="例如:坚持人民至上根本立场党课视频"
                 value={name}
                 onChange={(e) => setName(e.target.value)}
               />
             </label>
 
             <label>
-              课程分类
+              <span>课程分类</span>
               <select
                 name="category"
                 value={category}
@@ -289,7 +250,7 @@ export function NewCourse() {
             </label>
 
             <label>
-              课程简介与制作要求
+              <span>课程简介与制作要求</span>
               <textarea
                 name="description"
                 rows={7}
@@ -402,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>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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>

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

@@ -1,8 +1,5 @@
 import {
   BookOpen,
-  ChevronRight,
-  ExternalLink,
-  Layers,
   LayoutDashboard,
   LogOut,
   ShieldCheck,
@@ -10,6 +7,7 @@ import {
 } from 'lucide-react'
 import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'
 import { Brand } from '../../components/Brand'
+import { Footer } from '../../components/Footer'
 import { useAuth } from '../../AuthContext'
 
 export function AdminLayout() {
@@ -57,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>
@@ -74,6 +68,7 @@ export function AdminLayout() {
       <main className="admin-main">
         <Outlet />
       </main>
+      <Footer />
     </div>
   )
 }

+ 1 - 7
src/pages/admin/AdminLoginPage.tsx

@@ -1,4 +1,4 @@
-import { ArrowRight, LockKeyhole, Phone, ShieldCheck, Sparkles } from 'lucide-react'
+import { ArrowRight, LockKeyhole, Phone, ShieldCheck } from 'lucide-react'
 import { FormEvent, useState } from 'react'
 import { Link, Navigate, useNavigate } from 'react-router-dom'
 import { Brand } from '../../components/Brand'
@@ -56,7 +56,6 @@ export function AdminLoginPage() {
                 required
                 pattern="1[0-9]{10}"
                 placeholder="请输入管理员手机号"
-                defaultValue="18888888888"
               />
             </div>
           </label>
@@ -71,7 +70,6 @@ export function AdminLoginPage() {
                 required
                 minLength={6}
                 placeholder="请输入密码"
-                defaultValue="admin123456"
               />
             </div>
           </label>
@@ -85,10 +83,6 @@ export function AdminLoginPage() {
         </form>
 
         <div className="admin-login-footer">
-          <div className="admin-tip-box">
-            <Sparkles size={14} />
-            <span>默认管理员:18888888888 / admin123456</span>
-          </div>
           <Link to="/login" className="back-to-client-link">
             ← 返回普通创作者登录
           </Link>

+ 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>
   )
 }

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 740 - 143
src/styles.css


+ 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/deletecoursemodal.tsx","./src/components/episodemodal.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/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"}

Vissa filer visades inte eftersom för många filer har ändrats