Преглед на файлове

feat: 后续功能完善(服务端调整、前端页面与组件、新增讲师语音播放器)

bob преди 1 месец
родител
ревизия
2ae4a747e0

+ 3 - 2
server/db.ts

@@ -35,8 +35,10 @@ export const mapInstructor = (r: any) => ({
   name: r.name,
   organization: r.organization || '',
   introduction: r.introduction || '',
+  note: r.note || '',
   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}`] : [])
+  imageUrls: r.image_urls?.length ? r.image_urls : (r.image_stored_name ? [`/uploads/${r.image_stored_name}`] : []),
+  audioUrls: r.audio_urls?.length ? r.audio_urls : (r.audio?.length ? r.audio.map((a: any) => a.url) : [])
 })
 
 export const mapDeliverable = (r: any) => ({
@@ -87,7 +89,6 @@ export const mapCourse = (
     organizationName: r.organization_name || null,
     userId: r.user_id,
     name: r.name,
-    category: r.category || '',
     description: r.description || '',
     status: r.status,
     productionNotes: r.production_notes || '',

+ 21 - 13
server/index.ts

@@ -181,13 +181,22 @@ const fullCourse = async (row: any, includeCreator = false) => {
   if (row.digital_instructor_id) {
     const digital = await pool.query('SELECT * FROM digital_instructors WHERE id=$1', [row.digital_instructor_id])
     if (digital.rows[0]) {
-      const imageRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' ORDER BY sort_order", [row.digital_instructor_id])
-      instructors = [{ ...digital.rows[0], image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`) }]
+      const imageRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='IMAGE' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
+      const audioRows = await pool.query("SELECT stored_name FROM digital_instructor_files WHERE instructor_id=$1 AND kind='AUDIO' AND is_active=TRUE ORDER BY sort_order", [row.digital_instructor_id])
+      instructors = [{
+        ...digital.rows[0],
+        image_urls: imageRows.rows.map(f => `/uploads/${f.stored_name}`),
+        audio_urls: audioRows.rows.map(f => `/uploads/${f.stored_name}`)
+      }]
     }
   }
   if (row.status !== 'DRAFT' && row.digital_instructor_snapshot) {
     const snapshot = row.digital_instructor_snapshot
-    instructors = [{ ...snapshot, image_urls: (snapshot.images || []).map((image: any) => image.url) }]
+    instructors = [{
+      ...snapshot,
+      image_urls: (snapshot.images || []).map((image: any) => image.url),
+      audio_urls: (snapshot.audio || []).map((audio: any) => audio.url)
+    }]
   }
   return mapCourse({ ...row, progress_events: progress.rows }, a.rows, instructors, d.rows, creatorUser, episodesRows)
 }
@@ -427,7 +436,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(''),
   description: z.string().max(500).default(''),
   digitalInstructorId: z.string().uuid().nullable().optional()
 })
@@ -471,8 +479,8 @@ app.post('/api/courses', { preHandler: auth }, async (req, reply) => {
   }
   if (d.digitalInstructorId && !await validDigitalInstructor(d.digitalInstructorId, organizationId, userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, organization_id, name, category, description,digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6,$7) RETURNING *',
-    [id, userId(req), organizationId, d.name, d.category, d.description,d.digitalInstructorId || null]
+    'INSERT INTO courses(id, user_id, organization_id, name, description, digital_instructor_id) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
+    [id, userId(req), organizationId, d.name, d.description, d.digitalInstructorId || null]
   )
 
   // 课程创建后默认创建第 1 集内容,确保分集不为空
@@ -507,8 +515,8 @@ app.post('/api/courses/template', { preHandler: auth }, async (req, reply) => {
   }
 
   const { rows } = await pool.query(
-    'INSERT INTO courses(id, user_id, organization_id, name, category, description) VALUES($1, $2, $3, $4, $5, $6) RETURNING *',
-    [courseId, userId(req), organizationId, template.name, template.category, template.description]
+    'INSERT INTO courses(id, user_id, organization_id, name, description) VALUES($1, $2, $3, $4, $5) RETURNING *',
+    [courseId, userId(req), organizationId, template.name, template.description]
   )
 
   await populateCourseFromTemplate(pool, courseId, template.id, uploadDir, sampleDir)
@@ -533,7 +541,7 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   const row = await ownedCourse(id, userId(req))
   if (!row) return reply.code(404).send({ message: '课程不存在' })
 
-  const p = courseInput.pick({ name: true, category: true, description: true, digitalInstructorId: true }).safeParse(req.body)
+  const p = courseInput.pick({ name: true, description: true, digitalInstructorId: true }).safeParse(req.body)
   if (!p.success) {
     return reply.code(400).send({ message: p.error.issues[0]?.message || '请检查课程信息' })
   }
@@ -541,11 +549,11 @@ app.patch('/api/courses/:id', { preHandler: auth }, async (req, reply) => {
   if (p.data.digitalInstructorId !== undefined && row.status !== 'DRAFT') return reply.code(409).send({ message: '只有草稿课程可以更换讲师' })
   if (p.data.digitalInstructorId && !await validDigitalInstructor(p.data.digitalInstructorId,row.organization_id,userId(req))) return reply.code(400).send({ message: '请选择本工作区的数字讲师' })
   const { rows } = await pool.query(
-    'UPDATE courses SET name = $1, category = $2, description = $3, digital_instructor_id=COALESCE($5,digital_instructor_id), updated_at = NOW() WHERE id = $4 RETURNING *',
-    [p.data.name, p.data.category, p.data.description, id,p.data.digitalInstructorId || null]
+    'UPDATE courses SET name = $1, description = $2, digital_instructor_id=COALESCE($4,digital_instructor_id), updated_at = NOW() WHERE id = $3 RETURNING *',
+    [p.data.name, p.data.description, id, p.data.digitalInstructorId || null]
   )
   await refreshCoursePoints(id)
-  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称、分类或需求说明已更新。')
+  await addProgressEvent(id, 'USER', 'CONTENT_UPDATED', '课程基本信息已修改', '课程名称或需求说明已更新。')
   return { course: await fullCourse(rows[0]) }
 })
 
@@ -887,7 +895,7 @@ app.get('/api/admin/courses', { preHandler: adminAuth }, async (req) => {
   if (query.keyword && query.keyword.trim()) {
     params.push(`%${query.keyword.trim()}%`)
     const idx = params.length
-    conditions.push(`(c.name ILIKE $${idx} OR c.category ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
+    conditions.push(`(c.name ILIKE $${idx} OR u.phone ILIKE $${idx} OR u.contact_name ILIKE $${idx})`)
   }
 
   const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''

+ 1 - 1
server/organizations.ts

@@ -79,7 +79,7 @@ export function registerOrganizations(app: FastifyInstance, { pool, auth, adminA
     const [members, courses] = await Promise.all([
       pool.query(`SELECT u.id, u.contact_name, m.role FROM organization_members m
         JOIN users u ON u.id=m.user_id WHERE m.organization_id=$1 ORDER BY m.joined_at`, [id]),
-      pool.query(`SELECT c.id,c.user_id,c.name,c.category,c.status,c.created_at,c.updated_at,c.submitted_at,c.completed_at,
+      pool.query(`SELECT c.id,c.user_id,c.name,c.status,c.created_at,c.updated_at,c.submitted_at,c.completed_at,
         u.contact_name AS creator_name FROM courses c JOIN users u ON u.id=c.user_id
         WHERE c.organization_id=$1 ORDER BY c.created_at DESC`, [id])
     ])

+ 0 - 2
server/templates.ts

@@ -7,7 +7,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'party-building-standard',
     name: '《坚持人民至上根本立场》党课视频',
-    category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
     summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
@@ -104,7 +103,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
-    category: '高校公开课',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',

+ 2 - 2
src/api.ts

@@ -138,14 +138,14 @@ export const api = {
 
   // 普通用户课程操作
   listCourses: () => request<{ courses: Course[] }>('/courses'),
-  createCourse: (body: Pick<Course, 'name' | 'category' | 'description'> & { digitalInstructorId?: string | null }) =>
+  createCourse: (body: Pick<Course, 'name' | 'description'> & { digitalInstructorId?: string | null }) =>
     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'> & { digitalInstructorId?: string | null }) =>
+  updateCourse: (id: string, body: Pick<Course, 'name' | 'description'> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>(`/courses/${id}`, {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },

+ 1 - 1
src/components/AddInstructorModal.tsx

@@ -16,7 +16,7 @@ export function AddInstructorModal({ isOpen, onClose, courseId, onSuccess }: { i
     if (!course || !selected) { setError('请选择一位数字讲师'); return }
     setBusy(true)
     try {
-      const result = await api.updateCourse(courseId, { name: course.name, category: course.category, description: course.description, digitalInstructorId: selected })
+      const result = await api.updateCourse(courseId, { name: course.name, description: course.description, digitalInstructorId: selected })
       onSuccess(result.course); onClose()
     } catch (e) { setError((e as Error).message) } finally { setBusy(false) }
   }

+ 1 - 22
src/components/EditCourseModal.tsx

@@ -2,7 +2,6 @@ import { FormEvent, useEffect, useState } from 'react'
 import { Save, X } from 'lucide-react'
 import { api } from '../api'
 import type { Course } from '../types'
-import { DigitalInstructorPicker } from './DigitalInstructorPicker'
 
 interface EditCourseModalProps {
   course: Course
@@ -13,18 +12,14 @@ interface EditCourseModalProps {
 
 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 [digitalInstructorId, setDigitalInstructorId] = useState<string | null>(course.digitalInstructorId || null)
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
 
   useEffect(() => {
     if (!isOpen) return
     setName(course.name)
-    setCategory(course.category)
     setDescription(course.description)
-    setDigitalInstructorId(course.digitalInstructorId || null)
     setError('')
   }, [isOpen, course])
 
@@ -49,9 +44,7 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
     try {
       const res = await api.updateCourse(course.id, {
         name: name.trim(),
-        category,
-        description: description.trim(),
-        ...(course.status === 'DRAFT' ? { digitalInstructorId } : {})
+        description: description.trim()
       })
       onSuccess(res.course)
       onClose()
@@ -81,20 +74,6 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
               <span>课程名称</span>
               <input autoFocus required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} disabled={busy} />
             </label>
-            {course.status === 'DRAFT' && <DigitalInstructorPicker value={digitalInstructorId} onChange={setDigitalInstructorId} />}
-            <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>

+ 84 - 0
src/components/InstructorVoicePlayer.tsx

@@ -0,0 +1,84 @@
+import { useEffect, useRef, useState } from 'react'
+import { Volume2, Loader2 } from 'lucide-react'
+import { privateImageUrl } from '../api'
+
+interface InstructorVoicePlayerProps {
+  src: string
+  title?: string
+}
+
+export function InstructorVoicePlayer({ src, title = '播放讲师语音' }: InstructorVoicePlayerProps) {
+  const [isPlaying, setIsPlaying] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const audioRef = useRef<HTMLAudioElement | null>(null)
+  const blobUrlRef = useRef<string | null>(null)
+
+  useEffect(() => {
+    return () => {
+      if (audioRef.current) {
+        audioRef.current.pause()
+        audioRef.current = null
+      }
+      if (blobUrlRef.current) {
+        URL.revokeObjectURL(blobUrlRef.current)
+        blobUrlRef.current = null
+      }
+    }
+  }, [src])
+
+  const togglePlay = async (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (loading) return
+
+    if (isPlaying && audioRef.current) {
+      audioRef.current.pause()
+      setIsPlaying(false)
+      return
+    }
+
+    try {
+      if (!audioRef.current) {
+        setLoading(true)
+        const objectUrl = await privateImageUrl(src)
+        blobUrlRef.current = objectUrl
+        const audio = new Audio(objectUrl)
+        audioRef.current = audio
+
+        audio.onended = () => setIsPlaying(false)
+        audio.onpause = () => setIsPlaying(false)
+        audio.onerror = () => {
+          setIsPlaying(false)
+          setLoading(false)
+        }
+      }
+
+      await audioRef.current.play()
+      setIsPlaying(true)
+    } catch (err) {
+      console.error('音频播放失败', err)
+      setIsPlaying(false)
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <button
+      type="button"
+      className={`instructor-voice-btn ${isPlaying ? 'playing' : ''}`}
+      onClick={togglePlay}
+      title={isPlaying ? '暂停播放语音' : title}
+      aria-label={isPlaying ? '暂停播放语音' : title}
+      disabled={loading}
+    >
+      {loading ? (
+        <Loader2 size={13} className="spin" />
+      ) : isPlaying ? (
+        <Volume2 size={13} className="voice-icon-active" />
+      ) : (
+        <Volume2 size={13} />
+      )}
+      <span className="voice-btn-text">{isPlaying ? '播放中' : '试听'}</span>
+    </button>
+  )
+}

+ 5 - 7
src/components/Layout.tsx

@@ -13,7 +13,7 @@ export function Layout() {
   const navigate = useNavigate()
   const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
   const workspaceMenuRef = useRef<HTMLDivElement>(null)
-  const personalWorkspaceName = user?.contactName || '个人工作区'
+  const personalWorkspaceName = '个人'
   const currentWorkspaceName = currentOrganization?.name || personalWorkspaceName
   const hasMultipleWorkspaces = organizations.length > 0
 
@@ -34,10 +34,8 @@ export function Layout() {
     navigate('/')
   }
 
-  const isOrgManager = Boolean(
-    currentOrganization?.role === 'MANAGER' || organizations.some((org) => org.role === 'MANAGER')
-  )
-  const managerBadgeTitle = currentOrganization?.role === 'MANAGER'
+  const isCurrentWorkspaceManager = Boolean(currentOrganization?.role === 'MANAGER')
+  const managerBadgeTitle = currentOrganization
     ? `组织管理员 · ${currentOrganization.name}`
     : '组织管理员'
 
@@ -87,13 +85,13 @@ export function Layout() {
         <div className="top-actions">
           {user?.role !== 'ADMIN' && (
             <NavLink to="/profile?tab=points" className="points-balance-badge" title="查看积分消费记录">
-              积分 {currentOrganization ? currentOrganization.pointsBalance : user?.pointsBalance ?? 0}
+              积分余额:{(currentOrganization ? currentOrganization.pointsBalance : (user?.pointsBalance ?? 0)).toLocaleString('zh-CN')}
             </NavLink>
           )}
           <NavLink to="/profile" className="user-link">
             <UserRound size={16} />
             <span>{user?.contactName || user?.phone}</span>
-            {isOrgManager && (
+            {isCurrentWorkspaceManager && (
               <span className="topbar-role-badge" title={managerBadgeTitle}>
                 <ShieldCheck size={12} />
                 <span>管理员</span>

+ 0 - 2
src/data/courseTemplates.ts

@@ -6,7 +6,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'party-building-standard',
     name: '《坚持人民至上根本立场》党课视频',
-    category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
     summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
@@ -103,7 +102,6 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
   {
     id: 'higher-edu-calculus',
     name: '《深入浅出微积分:极限与导数之美》公开课',
-    category: '高校公开课',
     tags: ['高校公开课', '数理基础', '公式板书', '生动启发'],
     badge: '高校示范',
     summary: '高校精品开放课程标准,聚焦概念启发与几何直观推导,支持讲师半身出镜与动态板书公式画中画同步。',

+ 73 - 24
src/pages/CourseWorkspace.tsx

@@ -20,12 +20,14 @@ import {
   Trash2,
   Upload,
   UserPlus,
-  X
+  X,
+  ZoomIn
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useNavigate, useParams } from 'react-router-dom'
 import { api, downloadPrivateFile } from '../api'
 import { ProtectedImage } from '../components/ProtectedImage'
+import { InstructorVoicePlayer } from '../components/InstructorVoicePlayer'
 import { StatusBadge } from '../components/StatusBadge'
 import { AddInstructorModal } from '../components/AddInstructorModal'
 import { EpisodeModal } from '../components/EpisodeModal'
@@ -59,6 +61,16 @@ export function CourseWorkspace() {
   const [showEditCourseModal, setShowEditCourseModal] = useState(false)
   const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
   const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
+  const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
+
+  useEffect(() => {
+    if (!previewImage) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setPreviewImage(null)
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [previewImage])
 
   // 全局资产待上传
   const [globalFiles, setGlobalFiles] = useState<File[]>([])
@@ -261,8 +273,6 @@ export function CourseWorkspace() {
             </button>
           </div>
           <div style={{ display: 'flex', alignItems: 'center', gap: '14px', flexWrap: 'wrap', color: '#64748b', fontSize: '0.85rem', marginTop: '6px' }}>
-            <span>{course.category || '综合分类'}</span>
-            <span>•</span>
             <span title={`创建时间:${formatDateTime(course.createdAt)}`}>创建时间:{formatDateTime(course.createdAt)}</span>
             <span>•</span>
             <span title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#334155', fontWeight: 500 }}>
@@ -581,7 +591,6 @@ export function CourseWorkspace() {
             <section className="paper-form episodes-header-panel">
               <div className="panel-heading-row">
                 <div>
-                  <span className="step-number">02</span>
                   <h2>课程分集与素材管理</h2>
                   <p className="panel-intro">
                     课程默认已包含第 1 集,可按需添加更多分集(如 12 集微积分),为每一集在线录入口播讲稿,并上传该集的专属课件与素材文件。
@@ -612,14 +621,6 @@ export function CourseWorkspace() {
                   <span className="metric-num">{filledNotesCount} / {episodes.length}</span>
                   <small>已录入讲稿</small>
                 </div>
-                <div className="metric-item">
-                  <span className="metric-num">{course.assets.length}</span>
-                  <small>课件与素材</small>
-                </div>
-                <div className="metric-item">
-                  <span className="metric-num">{course.instructors.length}</span>
-                  <small>主讲讲师</small>
-                </div>
               </div>
 
               {hasEpisodes && (
@@ -933,20 +934,44 @@ export function CourseWorkspace() {
             </div>
 
             <div className="instructor-list">
-              {course.instructors.map((t) => (
-                <div className="instructor-card" key={t.id}>
-                  {t.imageUrls?.length ? (
-                    <div className="instructor-photo-stack">
-                      {t.imageUrls.map((url, index) => <ProtectedImage src={url} alt={`${t.name} 出镜照 ${index + 1}`} key={url} />)}
+              {course.instructors.map((t) => {
+                const note = (t.note || t.introduction || '').trim()
+                const audioUrl = t.audioUrls?.[0] || course.digitalInstructorSnapshot?.audio?.[0]?.url
+                return (
+                  <div className="instructor-card" key={t.id}>
+                    {t.imageUrls?.length ? (
+                      <div className="instructor-photo-stack">
+                        {t.imageUrls.map((url, index) => (
+                          <button
+                            key={url}
+                            type="button"
+                            className="instructor-photo-thumb-btn"
+                            onClick={() => setPreviewImage({ url, name: `${t.name} 照片 ${index + 1}` })}
+                            title="点击放大查看照片"
+                            aria-label={`点击放大查看 ${t.name} 照片 ${index + 1}`}
+                          >
+                            <ProtectedImage src={url} alt={`${t.name} 出镜照 ${index + 1}`} />
+                            <span className="photo-zoom-hint">
+                              <ZoomIn size={12} />
+                            </span>
+                          </button>
+                        ))}
+                      </div>
+                    ) : (
+                      <span className="avatar">{t.name[0]}</span>
+                    )}
+                    <div className="instructor-card-info">
+                      <div className="instructor-name-row">
+                        <strong className="instructor-name">{t.name}</strong>
+                        {audioUrl && (
+                          <InstructorVoicePlayer src={audioUrl} title={`${t.name} 录音试听`} />
+                        )}
+                      </div>
+                      {note ? <p className="instructor-note">{note}</p> : null}
                     </div>
-                  ) : <span className="avatar">{t.name[0]}</span>}
-                  <div>
-                    <strong>{t.name}</strong>
-                    <small>{t.organization || '未填写单位'}</small>
-                    <p>{t.introduction || '暂无介绍'}</p>
                   </div>
-                </div>
-              ))}
+                )
+              })}
               {!course.instructors.length && (
                 <div className="empty-instructor-box">
                   <p>尚未选择数字讲师</p>
@@ -1073,6 +1098,30 @@ export function CourseWorkspace() {
         <ContactModal onClose={() => setShowContactModal(false)} />
       )}
 
+      {/* 照片放大预览弹窗 */}
+      {previewImage && (
+        <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreviewImage(null)}>
+          <div
+            className="digital-photo-lightbox-content"
+            role="dialog"
+            aria-modal="true"
+            aria-label={`查看照片:${previewImage.name}`}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <button
+              type="button"
+              className="digital-photo-lightbox-close"
+              onClick={() => setPreviewImage(null)}
+              aria-label="关闭照片预览"
+            >
+              ×
+            </button>
+            <ProtectedImage src={previewImage.url} alt={previewImage.name} />
+            <p>{previewImage.name}</p>
+          </div>
+        </div>
+      )}
+
       {error && <div className="notice notice-error">{error}</div>}
     </div>
   )

+ 0 - 1
src/pages/Dashboard.tsx

@@ -146,7 +146,6 @@ export function Dashboard() {
                   <h3>{course.name}</h3>
                   <p>{course.description || '一门正在准备中的课程。'}</p>
                   <div className="course-meta">
-                    <span>{course.category || '未分类'}</span>
                     <span>
                       {course.assets && course.assets.length > 0
                         ? `${course.assets.length} 份课件资产`

+ 2 - 6
src/pages/NewCourse.tsx

@@ -23,7 +23,6 @@ export function NewCourse() {
 
   // 表单状态
   const [name, setName] = useState('')
-  const [category, setCategory] = useState('党政专题')
   const [description, setDescription] = useState('')
 
   const [busy, setBusy] = useState(false)
@@ -48,7 +47,6 @@ export function NewCourse() {
   // 1. 一键填入表单
   function handleFillTemplate(tmpl: CourseTemplate = currentTemplate) {
     setName(tmpl.name)
-    setCategory(tmpl.category)
     setDescription(tmpl.description)
     setFilledNotice(`已填入示例《${tmpl.name}》内容,您可按需进行调整。`)
     setTimeout(() => setFilledNotice(''), 4500)
@@ -75,7 +73,6 @@ export function NewCourse() {
     try {
       const { course } = await api.createCourse({
         name: name.trim(),
-        category,
         description: description.trim(),
         digitalInstructorId
       })
@@ -121,8 +118,7 @@ export function NewCourse() {
                   className={`template-pill ${tmpl.id === currentTemplate.id ? 'active' : ''}`}
                   onClick={() => setSelectedTemplateId(tmpl.id)}
                 >
-                  {tmpl.badge && <span className="pill-badge">{tmpl.badge}</span>}
-                  {tmpl.category}
+                  {tmpl.badge || tmpl.name}
                 </button>
               ))}
             </div>
@@ -179,7 +175,7 @@ export function NewCourse() {
                 name="name"
                 required
                 maxLength={100}
-                placeholder="例如:坚持人民至上根本立场党课视频"
+                placeholder="例如:高校数字化教学实战与微课制作规范"
                 value={name}
                 onChange={(e) => setName(e.target.value)}
               />

+ 1 - 1
src/pages/OrganizationManage.tsx

@@ -127,7 +127,7 @@ export function OrganizationManage() {
       {!organization.isActive && <div className="notice notice-error">该组织已停用:可查看和处理历史课程,暂不能新增成员或课程。</div>}
       <div className="stats-grid"><div className="stat-card"><div className="stat-content"><small>组织成员</small><strong>{manage.members.length}</strong></div></div>
         <div className="stat-card"><div className="stat-content"><small>组织课程</small><strong>{content.courses.length}</strong></div></div>
-        <div className="stat-card"><div className="stat-content"><small>组织积分余额</small><strong>{content.organization.pointsBalance}</strong></div></div></div>
+        <div className="stat-card"><div className="stat-content"><small>组织积分余额</small><strong>{content.organization.pointsBalance.toLocaleString('zh-CN')}</strong></div></div></div>
       <section className="admin-section-block"><h2>现场创建老师</h2><form onSubmit={createUser} className="org-inline-form">
         <label>手机号<input name="phone" required pattern="1[0-9]{10}" inputMode="numeric" /></label>
         <label>姓名<input name="contactName" required maxLength={80} /></label>

+ 52 - 3
src/pages/Profile.tsx

@@ -9,14 +9,14 @@ import { useWorkspace } from '../WorkspaceContext'
 type ProfileTab = 'profile' | 'security' | 'points'
 
 const tabs: Array<{ id: ProfileTab; label: string; description: string; icon: typeof UserRound }> = [
-  { id: 'profile', label: '用户信息', description: '联系人资料', icon: UserRound },
+  { id: 'profile', label: '用户信息', description: '姓名与组织资料', icon: UserRound },
   { id: 'security', label: '账户安全', description: '修改登录密码', icon: KeyRound },
   { id: 'points', label: '积分账单', description: '余额与积分流水', icon: Coins }
 ]
 
 export function Profile() {
   const { user, setUser } = useAuth()
-  const { currentOrganization } = useWorkspace()
+  const { organizations, currentOrganization } = useWorkspace()
   const [searchParams, setSearchParams] = useSearchParams()
   const requestedTab = searchParams.get('tab')
   const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
@@ -110,7 +110,56 @@ export function Profile() {
             <form className="paper-form profile-panel" onSubmit={save}>
               <div className="form-title"><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
               <label>注册手机号<input value={user.phone} disabled /></label>
-              <label>联系人<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
+
+              <div className="profile-field-item">
+                <span className="profile-field-label">隶属组织</span>
+                {organizations.length > 0 ? (
+                  <div className="profile-org-list">
+                    {organizations.map((org) => (
+                      <div key={org.id} className="profile-org-card">
+                        <div className="profile-org-main">
+                          <div className="profile-org-icon-box">
+                            <Building2 size={16} />
+                          </div>
+                          <div className="profile-org-meta">
+                            <span className="profile-org-name">{org.name}</span>
+                            <span className={`profile-org-badge ${org.role === 'MANAGER' ? 'badge-manager' : 'badge-member'}`}>
+                              {org.role === 'MANAGER' ? '组织管理员' : '组织成员'}
+                            </span>
+                            {!org.isActive && <span className="profile-org-badge badge-inactive">已停用</span>}
+                          </div>
+                        </div>
+                        {org.role === 'MANAGER' && (
+                          <Link to={`/org/${org.slug}/manage`} className="profile-org-action">
+                            组织管理
+                          </Link>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                ) : user.organization ? (
+                  <div className="profile-org-list">
+                    <div className="profile-org-card">
+                      <div className="profile-org-main">
+                        <div className="profile-org-icon-box">
+                          <Building2 size={16} />
+                        </div>
+                        <div className="profile-org-meta">
+                          <span className="profile-org-name">{user.organization}</span>
+                          <span className="profile-org-badge badge-member">成员</span>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="profile-org-empty-card">
+                    <Building2 size={16} />
+                    <span>当前为个人账号,暂未加入任何企业或组织</span>
+                  </div>
+                )}
+              </div>
+
+              <label>姓名<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
               <label>联系方式(微信)<input name="wechat" defaultValue={user.wechat} placeholder="请输入微信号" /></label>
               <label>补充信息<textarea name="bio" rows={4} defaultValue={user.bio} placeholder="其他需要说明的信息" /></label>
               <button className="primary-button"><Save size={16} />保存信息</button>

+ 77 - 26
src/pages/admin/AdminCourseDetail.tsx

@@ -27,12 +27,14 @@ import {
   Trash2,
   Upload,
   User,
-  Users
+  Users,
+  ZoomIn
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
 import { api, downloadPrivateFile } from '../../api'
 import { ProtectedImage } from '../../components/ProtectedImage'
+import { InstructorVoicePlayer } from '../../components/InstructorVoicePlayer'
 import { StatusBadge } from '../../components/StatusBadge'
 import { CourseProductionProgress } from '../../components/CourseProductionProgress'
 import type { Course, CourseStatus } from '../../types'
@@ -43,6 +45,16 @@ const size = (n: number) =>
 export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: string }) {
   const { id = '' } = useParams()
   const [course, setCourse] = useState<Course | null>(null)
+  const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
+
+  useEffect(() => {
+    if (!previewImage) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setPreviewImage(null)
+    }
+    window.addEventListener('keydown', handleKeyDown)
+    return () => window.removeEventListener('keydown', handleKeyDown)
+  }, [previewImage])
   const [loading, setLoading] = useState(true)
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
@@ -218,10 +230,6 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
               <StatusBadge status={course.status} />
             </div>
             <div className="admin-meta-tags">
-              <span className="meta-tag">
-                <Tag size={13} />
-                分类:<strong>{course.category || '未分类'}</strong>
-              </span>
               <span className="meta-tag">
                 <Layers size={13} />
                 规模:<strong>共 {episodes.length} 集</strong>
@@ -960,29 +968,48 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
 
             {course.instructors && course.instructors.length > 0 ? (
               <div className="instructor-sidebar-list">
-                {course.instructors.map((inst) => (
-                  <div className="inst-card-detail" key={inst.id}>
-                    {inst.imageUrls?.length ? (
-                      <div className="admin-instructor-photos">
-                        {inst.imageUrls.map((url, index) => (
-                          <ProtectedImage
-                            src={url}
-                            alt={`${inst.name} 出镜照 ${index + 1}`}
-                            className="inst-photo"
-                            key={url}
-                          />
-                        ))}
+                {course.instructors.map((inst) => {
+                  const note = (inst.note || inst.introduction || '').trim()
+                  const audioUrl = inst.audioUrls?.[0] || course.digitalInstructorSnapshot?.audio?.[0]?.url
+                  return (
+                    <div className="inst-card-detail" key={inst.id}>
+                      {inst.imageUrls?.length ? (
+                        <div className="admin-instructor-photos">
+                          {inst.imageUrls.map((url, index) => (
+                            <button
+                              key={url}
+                              type="button"
+                              className="instructor-photo-thumb-btn"
+                              onClick={() => setPreviewImage({ url, name: `${inst.name} 照片 ${index + 1}` })}
+                              title="点击放大查看照片"
+                              aria-label={`点击放大查看 ${inst.name} 照片 ${index + 1}`}
+                            >
+                              <ProtectedImage
+                                src={url}
+                                alt={`${inst.name} 出镜照 ${index + 1}`}
+                                className="inst-photo"
+                              />
+                              <span className="photo-zoom-hint">
+                                <ZoomIn size={12} />
+                              </span>
+                            </button>
+                          ))}
+                        </div>
+                      ) : (
+                        <div className="avatar-placeholder">{inst.name[0]}</div>
+                      )}
+                      <div className="inst-info">
+                        <div className="instructor-name-row">
+                          <strong className="instructor-name">{inst.name}</strong>
+                          {audioUrl && (
+                            <InstructorVoicePlayer src={audioUrl} title={`${inst.name} 录音试听`} />
+                          )}
+                        </div>
+                        {note ? <p className="instructor-note">{note}</p> : null}
                       </div>
-                    ) : (
-                      <div className="avatar-placeholder">{inst.name[0]}</div>
-                    )}
-                    <div className="inst-info">
-                      <strong>{inst.name}</strong>
-                      <small>{inst.organization || '未提供单位'}</small>
-                      <p>{inst.introduction || '暂无详细介绍'}</p>
                     </div>
-                  </div>
-                ))}
+                  )
+                })}
               </div>
             ) : (
               <p className="empty-sub-tip">未提供讲师图文信息。</p>
@@ -990,6 +1017,30 @@ export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: stri
           </section>
         </div>
       </div>
+
+      {/* 照片放大预览弹窗 */}
+      {previewImage && (
+        <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreviewImage(null)}>
+          <div
+            className="digital-photo-lightbox-content"
+            role="dialog"
+            aria-modal="true"
+            aria-label={`查看照片:${previewImage.name}`}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <button
+              type="button"
+              className="digital-photo-lightbox-close"
+              onClick={() => setPreviewImage(null)}
+              aria-label="关闭照片预览"
+            >
+              ×
+            </button>
+            <ProtectedImage src={previewImage.url} alt={previewImage.name} />
+            <p>{previewImage.name}</p>
+          </div>
+        </div>
+      )}
     </div>
   )
 }

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

@@ -123,7 +123,6 @@ export function AdminCourses() {
             <thead>
               <tr>
                 <th>课程名称与大纲</th>
-                <th>课程分类</th>
                 <th style={{ minWidth: 200 }}>提交制作用户信息</th>
                 <th>素材 / 讲师 / 交付</th>
                 <th>当前状态</th>
@@ -147,9 +146,6 @@ export function AdminCourses() {
                       </div>
                     </div>
                   </td>
-                  <td>
-                    <span className="category-pill">{course.category || '未分类'}</span>
-                  </td>
                   <td>
                     {/* 醒目的提交用户信息单元格 */}
                     <div className="user-highlight-cell">

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

@@ -131,7 +131,6 @@ export function AdminDashboard() {
               <thead>
                 <tr>
                   <th>课程名称</th>
-                  <th>课程分类</th>
                   <th>提交用户</th>
                   <th>素材与讲师</th>
                   <th>状态</th>
@@ -148,9 +147,6 @@ export function AdminDashboard() {
                         {course.description && <small>{course.description}</small>}
                       </div>
                     </td>
-                    <td>
-                      <span>{course.category || '未分类'}</span>
-                    </td>
                     <td>
                       <strong>{course.creator?.contactName || '未填姓名'}</strong>
                       <small className="sub-text">{course.creator?.phone}</small>

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

@@ -68,7 +68,7 @@ export function AdminProductionQueue() {
         const notesCount = course.episodes.filter((episode) => episode.lectureNotes?.trim()).length
         return <article className="production-queue-card" key={course.id}>
           <div className="production-queue-card-head">
-            <div><span className="production-queue-index">等待制作 · {course.category || '未分类'}</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
+            <div><span className="production-queue-index">等待制作</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
           </div>
           <div className="production-queue-meta">
             <span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.creator?.phone || '无联系电话'} · {course.organizationName || '个人工作区'}</span>

+ 138 - 3
src/styles.css

@@ -280,6 +280,21 @@ button { cursor:pointer; }
 .profile-content { min-width:0; }
 .profile-panel { min-height:560px; }
 .profile-panel .form-title p { margin:7px 0 0; color:#929bad; font-size:12px; }
+.profile-field-item { display:flex; flex-direction:column; gap:9px; margin:0 0 22px; }
+.profile-field-label { font-size:12px; color:#59657b; font-weight:500; }
+.profile-org-list { display:flex; flex-direction:column; gap:10px; }
+.profile-org-card { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; background:#fbfcfe; border:1px solid #dfe4ec; border-radius:9px; transition:border-color .2s; }
+.profile-org-main { display:flex; align-items:center; gap:12px; }
+.profile-org-icon-box { width:32px; height:32px; border-radius:8px; background:#eaf7ff; color:#0284c7; display:grid; place-items:center; flex-shrink:0; }
+.profile-org-meta { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
+.profile-org-name { font-size:14px; font-weight:600; color:#1e293b; }
+.profile-org-badge { display:inline-flex; align-items:center; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:500; }
+.profile-org-badge.badge-manager { background:#e0f2fe; color:#0369a1; border:1px solid #bae6fd; }
+.profile-org-badge.badge-member { background:#f1f5f9; color:#475569; border:1px solid #e2e8f0; }
+.profile-org-badge.badge-inactive { background:#fef2f2; color:#b91c1c; border:1px solid #fecaca; }
+.profile-org-action { font-size:12px; color:#0284c7; text-decoration:none; font-weight:500; padding:4px 10px; border-radius:6px; background:#fff; border:1px solid #bae6fd; transition:all .2s; }
+.profile-org-action:hover { background:#f0f9ff; border-color:#7dd3fc; }
+.profile-org-empty-card { display:flex; align-items:center; gap:10px; padding:12px 16px; background:#f8fafc; border:1px dashed #cbd5e1; border-radius:9px; color:#94a3b8; font-size:13px; }
 .points-form-title { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; }
 .points-form-title h2 { margin:0; }
 .points-heading-wrap { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
@@ -354,6 +369,126 @@ button { cursor:pointer; }
 .instructor-card>div { display:flex; flex-direction:column; gap:4px; min-width:0; }
 .instructor-card small,.instructor-card p,.empty-tip,.submit-box p { font-size:11px; color:#8d98aa; }
 .instructor-card p { margin:3px 0; line-height:1.5; color:#4a5568; }
+
+/* 数字讲师卡片升级样式 */
+.instructor-photo-thumb-btn {
+  position: relative;
+  display: block;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+  border-radius: 7px;
+  overflow: hidden;
+  line-height: 0;
+  transition: transform 0.18s ease, box-shadow 0.18s ease;
+}
+.instructor-photo-thumb-btn:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(15, 23, 42, 0.16);
+}
+.instructor-photo-thumb-btn .photo-zoom-hint {
+  position: absolute;
+  right: 3px;
+  bottom: 3px;
+  width: 20px;
+  height: 20px;
+  border-radius: 4px;
+  background: rgba(15, 23, 42, 0.65);
+  backdrop-filter: blur(2px);
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  opacity: 0;
+  transform: scale(0.85);
+  transition: all 0.15s ease;
+  pointer-events: none;
+}
+.instructor-photo-thumb-btn:hover .photo-zoom-hint {
+  opacity: 1;
+  transform: scale(1);
+}
+
+.instructor-card-info {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+  flex: 1;
+  justify-content: center;
+}
+.instructor-name-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.instructor-name {
+  font-size: 15px;
+  font-weight: 600;
+  color: #172033;
+}
+
+.instructor-voice-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  height: 24px;
+  padding: 0 8px;
+  border-radius: 12px;
+  border: 1px solid #d0e2ff;
+  background: #f0f6ff;
+  color: #0066cc;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  flex-shrink: 0;
+}
+.instructor-voice-btn:hover {
+  background: #e0edff;
+  border-color: #b3d1ff;
+  color: #0052a3;
+  transform: translateY(-1px);
+}
+.instructor-voice-btn:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+.instructor-voice-btn.playing {
+  background: #0066cc;
+  border-color: #0066cc;
+  color: #ffffff;
+  box-shadow: 0 2px 8px rgba(0, 102, 204, 0.3);
+  animation: pulse-voice 1.8s infinite;
+}
+.voice-icon-active {
+  animation: bounce-voice 1s infinite alternate ease-in-out;
+}
+@keyframes bounce-voice {
+  0% { transform: scale(0.92); }
+  100% { transform: scale(1.12); }
+}
+@keyframes pulse-voice {
+  0% { box-shadow: 0 0 0 0 rgba(0, 102, 204, 0.4); }
+  70% { box-shadow: 0 0 0 6px rgba(0, 102, 204, 0); }
+  100% { box-shadow: 0 0 0 0 rgba(0, 102, 204, 0); }
+}
+.voice-btn-text {
+  font-size: 11px;
+  line-height: 1;
+}
+
+.instructor-note {
+  margin: 3px 0 0 0;
+  font-size: 12px;
+  line-height: 1.5;
+  color: #64748b;
+  word-break: break-word;
+  white-space: pre-wrap;
+}
+
 .submit-box { border-top:1px solid #edf0f4; margin-top:20px; padding-top:18px; }
 
 .production-progress-card { margin: 18px 0; }
@@ -2231,9 +2366,9 @@ button { cursor:pointer; }
 }
 
 .episodes-metrics-bar {
-  display: grid;
-  grid-template-columns: repeat(4, 1fr);
-  gap: 12px;
+  display: flex;
+  align-items: center;
+  gap: 48px;
   margin-top: 20px;
   padding: 14px 18px;
   background: #f8fafc;

+ 2 - 2
src/types.ts

@@ -61,8 +61,10 @@ export interface Instructor {
   name: string
   organization: string
   introduction: string
+  note?: string
   imageUrl: string | null
   imageUrls: string[]
+  audioUrls?: string[]
 }
 
 export interface DigitalInstructor {
@@ -115,7 +117,6 @@ export interface Course {
   organizationId?: string | null
   organizationName?: string | null
   name: string
-  category: string
   description: string
   status: CourseStatus
   productionNotes?: string
@@ -171,7 +172,6 @@ export interface PresetEpisode {
 export interface CourseTemplate {
   id: string
   name: string
-  category: string
   tags: string[]
   badge?: string
   summary: string