Procházet zdrojové kódy

feat(web): 组织/数字讲师/生产队列 页面与组件,及课程页改造

bob před 1 měsícem
rodič
revize
910067e5c9

+ 19 - 135
src/components/AddInstructorModal.tsx

@@ -1,145 +1,29 @@
-import { FormEvent, useState, useEffect } from 'react'
-import { X, UserPlus, Sparkles } from 'lucide-react'
-import { InstructorImageField } from './InstructorImageField'
+import { useEffect, useState } from 'react'
 import { api } from '../api'
 import type { Course } from '../types'
+import { DigitalInstructorPicker } from './DigitalInstructorPicker'
 
-interface AddInstructorModalProps {
-  isOpen: boolean
-  onClose: () => void
-  courseId: string
-  onSuccess: (course: Course) => void
-}
-
-export function AddInstructorModal({
-  isOpen,
-  onClose,
-  courseId,
-  onSuccess
-}: AddInstructorModalProps) {
-  const [busy, setBusy] = useState(false)
+export function AddInstructorModal({ isOpen, onClose, courseId, onSuccess }: { isOpen: boolean; onClose: () => void; courseId: string; onSuccess: (course: Course) => void }) {
+  const [course, setCourse] = useState<Course | null>(null)
+  const [selected, setSelected] = useState<string | null>(null)
   const [error, setError] = useState('')
-  const [images, setImages] = useState<File[]>([])
-
+  const [busy, setBusy] = useState(false)
   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) api.getCourse(courseId).then(r => { setCourse(r.course); setSelected(r.course.digitalInstructorId || null) }).catch(e => setError(e.message))
+  }, [isOpen, courseId])
   if (!isOpen) return null
-
-  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
-    e.preventDefault()
-    const form = e.currentTarget
-    const f = new FormData(form)
-
-    const name = String(f.get('name') || '').trim()
-    if (!name) {
-      setError('请填写讲师姓名')
-      return
-    }
-
+  async function save() {
+    if (!course || !selected) { setError('请选择一位数字讲师'); return }
     setBusy(true)
-    setError('')
     try {
-      const res = await api.addInstructor(courseId, {
-        name,
-        organization: String(f.get('organization') || '').trim(),
-        introduction: String(f.get('introduction') || '').trim(),
-        images
-      })
-      onSuccess(res.course)
-      onClose()
-    } catch (err) {
-      setError((err as Error).message || '保存讲师信息失败,请重试')
-    } finally {
-      setBusy(false)
-    }
+      const result = await api.updateCourse(courseId, { name: course.name, category: course.category, description: course.description, digitalInstructorId: selected })
+      onSuccess(result.course); onClose()
+    } catch (e) { setError((e as Error).message) } finally { setBusy(false) }
   }
-
-  return (
-    <div className="modal-backdrop" onClick={() => !busy && onClose()}>
-      <div
-        className="modal-dialog instructor-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 instructor-modal-body">
-            {error && <div className="notice notice-error">{error}</div>}
-
-            <div className="form-group-row">
-              <label className="form-label required">
-                <span>讲师姓名</span>
-                <input
-                  name="name"
-                  required
-                  placeholder="如:张教授 / 李主讲"
-                  disabled={busy}
-                  autoFocus
-                />
-              </label>
-              <label className="form-label">
-                <span>所属单位 / 机构</span>
-                <input
-                  name="organization"
-                  placeholder="如:中共党史与理论研究中心"
-                  disabled={busy}
-                />
-              </label>
-            </div>
-
-            <label className="form-label">
-              <span>讲师介绍</span>
-              <textarea
-                name="introduction"
-                rows={3}
-                placeholder="简要描述讲师专业背景、主讲方向与授课风格"
-                disabled={busy}
-              />
-            </label>
-
-            <InstructorImageField disabled={busy} onFileChange={setImages} />
-          </div>
-
-          <div className="modal-footer">
-            <button
-              type="button"
-              className="secondary-button"
-              onClick={onClose}
-              disabled={busy}
-            >
-              取消
-            </button>
-            <button
-              type="submit"
-              className="primary-button"
-              disabled={busy}
-            >
-              {busy ? '正在保存…' : '保存讲师'}
-            </button>
-          </div>
-        </form>
-      </div>
-    </div>
-  )
+  return <div className="modal-backdrop" onClick={onClose}><div className="modal-dialog instructor-modal" onClick={e => e.stopPropagation()}>
+    <div className="modal-header"><h3>选择数字讲师</h3><button type="button" onClick={onClose}>关闭</button></div>
+    {error && <div className="notice notice-error">{error}</div>}
+    <div className="modal-body"><DigitalInstructorPicker value={selected} onChange={setSelected} /></div>
+    <div className="modal-footer"><button className="primary-button" type="button" disabled={busy || !selected} onClick={save}>{busy ? '保存中…' : '保存选择'}</button></div>
+  </div></div>
 }

+ 239 - 0
src/components/DigitalInstructorCreateForm.tsx

@@ -0,0 +1,239 @@
+import { useEffect, useRef, useState } from 'react'
+import { api } from '../api'
+import { ProtectedImage } from './ProtectedImage'
+import { ProtectedAudio } from './ProtectedAudio'
+import type { DigitalInstructor } from '../types'
+
+type Props = {
+  workspaceName: string
+  organizationNames: string[] | null
+  onCreated: () => void
+  onCancel: () => void
+  existing?: DigitalInstructor
+  workspaceId?: string
+  ownerOptions?: { id: string; contact_name: string }[]
+  canManageOwner?: boolean
+}
+
+const imageTypes = ['image/jpeg', 'image/png', 'image/webp']
+const audioTypes = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/webm', 'audio/ogg']
+const maxImages = 19
+const readingScript = '大家好,欢迎来到今天的课程。接下来,我会用清晰、自然的语速介绍本节内容。学习时可以先了解整体思路,再结合具体例子逐步练习。遇到不明白的地方,请暂停思考,之后继续学习。希望这段讲解能帮助大家掌握新的知识。'
+
+export function DigitalInstructorCreateForm({ workspaceName, organizationNames, onCreated, onCancel, existing, workspaceId, ownerOptions, canManageOwner }: Props) {
+  const [name, setName] = useState(existing?.name || '')
+  const [note, setNote] = useState(existing?.note || '')
+  const [noteOpen, setNoteOpen] = useState(!!existing?.note)
+  const [images, setImages] = useState<File[]>([])
+  const [audio, setAudio] = useState<File | null>(null)
+  const [removedImageIds, setRemovedImageIds] = useState<string[]>([])
+  const [coverChoice, setCoverChoice] = useState(existing?.images[0] ? `existing:${existing.images[0].id}` : '')
+  const [ownerId, setOwnerId] = useState(existing?.ownerId || '')
+  const [imageUrls, setImageUrls] = useState<string[]>([])
+  const [audioUrl, setAudioUrl] = useState('')
+  const [preview, setPreview] = useState<{ src: string; alt: string; protected: boolean } | null>(null)
+  const [error, setError] = useState('')
+  const [busy, setBusy] = useState(false)
+  const [recordingStatus, setRecordingStatus] = useState<'idle' | 'requesting' | 'countdown' | 'recording' | 'processing'>('idle')
+  const [recordingSeconds, setRecordingSeconds] = useState(0)
+  const [countdown, setCountdown] = useState(3)
+  const [photoGuideOpen, setPhotoGuideOpen] = useState(false)
+  const recorderRef = useRef<MediaRecorder | null>(null)
+  const streamRef = useRef<MediaStream | null>(null)
+  const recordingStartedAtRef = useRef(0)
+  const discardRecordingRef = useRef(false)
+  const mountedRef = useRef(true)
+
+  useEffect(() => {
+    mountedRef.current = true
+    return () => {
+      mountedRef.current = false
+      discardRecordingRef.current = true
+      if (recorderRef.current?.state === 'recording') recorderRef.current.stop()
+      streamRef.current?.getTracks().forEach(track => track.stop())
+    }
+  }, [])
+  useEffect(() => {
+    if (recordingStatus !== 'recording') return
+    const timer = window.setInterval(() => setRecordingSeconds(Math.floor((Date.now() - recordingStartedAtRef.current) / 1000)), 250)
+    return () => window.clearInterval(timer)
+  }, [recordingStatus])
+  useEffect(() => {
+    if (recordingStatus !== 'countdown') return
+    if (countdown === 0) {
+      try {
+        if (recorderRef.current?.state === 'inactive') recorderRef.current.start(1000)
+        recordingStartedAtRef.current = Date.now()
+        setRecordingSeconds(0)
+        setRecordingStatus('recording')
+      } catch {
+        streamRef.current?.getTracks().forEach(track => track.stop())
+        streamRef.current = null
+        recorderRef.current = null
+        setRecordingStatus('idle')
+        setError('录音无法开始,请重试或上传录音文件。')
+      }
+      return
+    }
+    const timer = window.setTimeout(() => setCountdown(value => value - 1), 1000)
+    return () => window.clearTimeout(timer)
+  }, [recordingStatus, countdown])
+
+  useEffect(() => {
+    const urls = images.map(file => URL.createObjectURL(file))
+    setImageUrls(urls)
+    return () => urls.forEach(url => URL.revokeObjectURL(url))
+  }, [images])
+  useEffect(() => {
+    if (!audio) { setAudioUrl(''); return }
+    const url = URL.createObjectURL(audio)
+    setAudioUrl(url)
+    return () => URL.revokeObjectURL(url)
+  }, [audio])
+  useEffect(() => {
+    if (!preview && !photoGuideOpen) return
+    const close = (event: KeyboardEvent) => { if (event.key === 'Escape') { setPreview(null); setPhotoGuideOpen(false) } }
+    window.addEventListener('keydown', close)
+    return () => window.removeEventListener('keydown', close)
+  }, [preview, photoGuideOpen])
+
+  const duplicate = !!organizationNames?.some(otherName => otherName.trim().toLocaleLowerCase() === name.trim().toLocaleLowerCase())
+  const retainedImages = existing?.images.filter(image => !removedImageIds.includes(image.id)) || []
+
+  function chooseImages(files: FileList | null) {
+    if (!files?.length) return
+    const picked = Array.from(files)
+    if (picked.some(file => !imageTypes.includes(file.type) || file.size === 0 || file.size > 20 * 1024 * 1024)) {
+      setError('照片仅支持 JPG、PNG、WebP;每张不超过 20 MB,且不能为空。')
+      return
+    }
+    const next = [...images]
+    for (const file of picked) {
+      if (!next.some(existing => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified)) next.push(file)
+    }
+    if (next.length + retainedImages.length > maxImages) { setError(`最多保留 ${maxImages} 张照片。`); return }
+    setError('')
+    setImages(next)
+  }
+  function chooseAudio(file?: File) {
+    if (!file) return
+    if (!audioTypes.includes(file.type) || file.size === 0 || file.size > 50 * 1024 * 1024) {
+      setError('录音仅支持 WAV、MP3、M4A、WebM、Ogg;文件不超过 50 MB,且不能为空。')
+      return
+    }
+    setError('')
+    setAudio(file)
+  }
+  async function startRecording() {
+    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
+      setError('当前浏览器无法直接录音,请使用上传录音文件。')
+      return
+    }
+    setError('')
+    setRecordingStatus('requesting')
+    try {
+      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+      if (!mountedRef.current) { stream.getTracks().forEach(track => track.stop()); return }
+      const mimeType = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
+        .find(type => MediaRecorder.isTypeSupported(type))
+      const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
+      const chunks: BlobPart[] = []
+      streamRef.current = stream
+      recorderRef.current = recorder
+      discardRecordingRef.current = false
+      recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data) }
+      recorder.onerror = () => {
+        discardRecordingRef.current = true
+        stream.getTracks().forEach(track => track.stop())
+        if (mountedRef.current) { setRecordingStatus('idle'); setError('录音中断,请重试或上传录音文件。') }
+      }
+      recorder.onstop = () => {
+        stream.getTracks().forEach(track => track.stop())
+        streamRef.current = null
+        recorderRef.current = null
+        if (!mountedRef.current) return
+        setRecordingStatus('idle')
+        if (discardRecordingRef.current) return
+        const type = recorder.mimeType.split(';')[0] || 'audio/webm'
+        const extension = type === 'audio/mp4' ? 'm4a' : type === 'audio/ogg' ? 'ogg' : 'webm'
+        const blob = new Blob(chunks, { type })
+        chooseAudio(new File([blob], `讲师录音-${Date.now()}.${extension}`, { type }))
+      }
+      setCountdown(3)
+      setRecordingStatus('countdown')
+    } catch {
+      streamRef.current?.getTracks().forEach(track => track.stop())
+      streamRef.current = null
+      if (mountedRef.current) { setRecordingStatus('idle'); setError('麦克风未启用或无法访问。请允许麦克风权限,或上传已有录音文件。') }
+    }
+  }
+  function stopRecording(discard = false) {
+    discardRecordingRef.current = discard
+    if (recordingStatus === 'countdown') {
+      streamRef.current?.getTracks().forEach(track => track.stop())
+      streamRef.current = null
+      recorderRef.current = null
+      setRecordingStatus('idle')
+      return
+    }
+    if (recorderRef.current?.state === 'recording') {
+      setRecordingStatus('processing')
+      recorderRef.current.stop()
+    }
+  }
+  async function save() {
+    if (!name.trim()) { setError('请先填写讲师姓名。'); return }
+    if (duplicate) { setError('这个组织已有同名讲师,请换一个名字。'); return }
+    if (recordingStatus !== 'idle') { setError('请先结束录音,再保存讲师。'); return }
+    setBusy(true); setError('')
+    try {
+      if (existing) {
+        await api.digitalInstructors.manage(existing.id, {
+          name: name.trim(), note: note.trim(), ownerId: canManageOwner ? (ownerId || undefined) : undefined, images, audio, removeImageIds: removedImageIds,
+          coverImageId: coverChoice.startsWith('existing:') ? coverChoice.slice(9) : undefined,
+          coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined
+        }, workspaceId)
+      } else await api.digitalInstructors.create({ name: name.trim(), note: note.trim(), images, audio, coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined })
+      onCreated()
+    } catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+
+  return <section className="digital-create-panel" aria-label={existing ? '编辑数字讲师' : '创建数字讲师'}>
+    <div className="digital-create-intro"><div><span className="digital-create-eyebrow">{existing ? '编辑讲师' : '新建讲师'} · {workspaceName}</span><h2>{existing ? '编辑数字讲师' : '创建数字讲师'}</h2><p>按顺序填写资料、准备照片和录音。素材可以稍后补齐,提交课程制作前需要两项齐备。</p></div></div>
+    {error && <div className="notice notice-error" role="alert">{error}</div>}
+    <div className="digital-create-steps">
+      <section className="digital-step"><div className="digital-step-title"><span>01</span><div><h3>讲师资料</h3><p>用一个容易辨认的名称管理这位讲师。</p></div></div>
+        <div className="digital-step-body"><label className="digital-field">讲师姓名 <strong>必填</strong><input autoFocus value={name} onChange={event => setName(event.target.value)} maxLength={80} placeholder="例如:张老师" aria-invalid={duplicate} /></label>
+          {organizationNames && <p className={duplicate ? 'digital-field-error' : 'digital-field-hint'}>{duplicate ? '这个组织已有同名讲师,请换一个名字。' : '同一组织中的讲师不能重名。'}</p>}
+          <button type="button" className="digital-note-toggle" aria-expanded={noteOpen} onClick={() => setNoteOpen(open => !open)}>{noteOpen ? '▾ 收起备注' : `+ 添加备注${note ? ' · 已填写' : '(选填)'}`}</button>
+          {noteOpen && <label className="digital-field">备注 <span>选填,仅用于内部记录</span><textarea value={note} onChange={event => setNote(event.target.value)} maxLength={1000} placeholder="例如:适合讲解哪类课程、后续需要补充什么素材" rows={3} /></label>}
+          {existing && (existing.organizationId || workspaceId !== 'personal') && (
+            canManageOwner && ownerOptions && ownerOptions.length > 0 ? (
+              <label className="digital-field">所有者<select value={ownerId} onChange={event => setOwnerId(event.target.value)}>{ownerOptions.map(member => <option key={member.id} value={member.id}>{member.contact_name || '未填写姓名'}</option>)}</select><span className="digital-field-hint">管理员可为该讲师分配新的所有者</span></label>
+            ) : (
+              <label className="digital-field">所有者<input type="text" value={existing.ownerName === '未知用户' ? '历史导入' : (existing.ownerName || '未知用户')} disabled readOnly /><span className="digital-field-hint">默认所有者为创建者本人,仅管理员可修改</span></label>
+            )
+          )}</div>
+      </section>
+      <section className="digital-step"><div className="digital-step-title"><span>02</span><div><h3>上传参考照片</h3><p>建议使用清晰的正面半身照,便于生成稳定的数字人形象。</p></div></div>
+        <div className="digital-step-body"><div className="digital-guidance"><strong>拍摄时留意</strong><ul><li>纯色、整洁的背景;正面均匀光线,避免逆光和强阴影。</li><li>肩部以上居中,面部无遮挡;建议手机后置摄像头、1080P 以上,关闭美颜。</li></ul><button type="button" className="digital-guide-button" onClick={() => setPhotoGuideOpen(true)}>查看照片参考样例 →</button></div>
+          <div className="digital-upload-actions"><label className="digital-upload-zone"><span className="digital-upload-icon">▧</span><strong>{images.length ? '继续添加照片' : '从相册或电脑选择'}</strong><small>可多次添加,最多 {maxImages} 张;JPG、PNG、WebP,每张不超过 20 MB</small><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => { chooseImages(event.target.files); event.target.value = '' }} /></label>
+            <label className="digital-upload-zone digital-camera-zone"><span className="digital-upload-icon">◎</span><strong>用手机拍照</strong><small>拍完可以继续拍下一张</small><input type="file" accept="image/jpeg,image/png,image/webp" capture="environment" onChange={event => { chooseImages(event.target.files); event.target.value = '' }} /></label></div>
+          {(retainedImages.length > 0 || images.length > 0) && <div className="digital-upload-preview"><div className="digital-preview-heading"><strong>照片 {retainedImages.length + images.length} 张</strong><span>点击照片放大,选择一张作为卡片封面</span></div><div className="digital-photo-list">{retainedImages.map(image => <div key={image.id}><button type="button" className="digital-photo-open" onClick={() => setPreview({ src: image.url, alt: image.name, protected: true })} aria-label={`放大查看 ${image.name}`}><ProtectedImage src={image.url} alt={image.name} /></button><span>{image.name}</span><button type="button" onClick={() => setCoverChoice(`existing:${image.id}`)}>{coverChoice === `existing:${image.id}` ? '✓ 封面' : '设为封面'}</button><button type="button" onClick={() => { setRemovedImageIds(ids => [...ids,image.id]); if (coverChoice === `existing:${image.id}`) setCoverChoice('') }} aria-label={`移除照片 ${image.name}`}>移除</button></div>)}{images.map((file, index) => <div key={`${file.name}-${index}`}><button type="button" className="digital-photo-open" onClick={() => setPreview({ src: imageUrls[index], alt: file.name, protected: false })} aria-label={`放大查看 ${file.name}`}><img src={imageUrls[index]} alt={`新参考照片 ${index + 1}`} /></button><span>{file.name}</span><button type="button" onClick={() => setCoverChoice(`new:${index}`)}>{coverChoice === `new:${index}` ? '✓ 封面' : '设为封面'}</button><button type="button" onClick={() => { setImages(current => current.filter((_, photoIndex) => photoIndex !== index)); if (coverChoice === `new:${index}`) setCoverChoice(''); else if (coverChoice.startsWith('new:') && Number(coverChoice.slice(4)) > index) setCoverChoice(`new:${Number(coverChoice.slice(4)) - 1}`) }} aria-label={`移除照片 ${file.name}`}>移除</button></div>)}</div></div>}</div>
+      </section>
+      <section className="digital-step"><div className="digital-step-title"><span>03</span><div><h3>上传声音录音</h3><p>请上传讲师本人连续、自然的人声录音。</p></div></div>
+        <div className="digital-step-body"><div className="digital-guidance"><strong>录音时留意</strong><ul><li>在安静环境录制,关好门窗,避开空调、风扇和明显回声。</li><li>距麦克风约 15–20 厘米,不喷麦、不捂麦;照着下面的文字自然朗读。录满 10 秒会提示时长达标,更长的连续录音通常更有利于后续声音处理。</li></ul></div>
+          <div className="digital-reading-script"><div><strong>录音朗读稿</strong><span>开始录音后照着读,读完可继续自然讲述</span></div><p>{readingScript}</p></div>
+          <div className="digital-upload-actions"><label className="digital-upload-zone"><span className="digital-upload-icon">♫</span><strong>{audio ? '更换录音文件' : '上传录音文件'}</strong><small>WAV、MP3、M4A、WebM、Ogg · 不超过 50 MB</small><input type="file" accept="audio/wav,audio/x-wav,audio/mpeg,audio/mp4,audio/x-m4a,audio/webm,audio/ogg" onChange={event => { chooseAudio(event.target.files?.[0]); event.target.value = '' }} /></label>
+            <div className="digital-record-zone"><span className="digital-upload-icon">●</span><strong>直接录音</strong><small>使用手机或电脑麦克风</small>{recordingStatus === 'countdown' ? <div className="digital-record-actions"><span className="digital-record-countdown" role="status" aria-live="assertive">{countdown}</span><span>准备开始朗读…</span><button type="button" onClick={() => stopRecording(true)}>取消</button></div> : recordingStatus === 'recording' ? <div className="digital-record-actions"><span className={recordingSeconds >= 10 ? 'digital-record-time-ready' : ''} role="status">录制中 {Math.floor(recordingSeconds / 60).toString().padStart(2, '0')}:{(recordingSeconds % 60).toString().padStart(2, '0')} {recordingSeconds >= 10 ? '✓ 时长达标' : '· 满 10 秒变绿'}</span><button type="button" onClick={() => stopRecording()}>结束并使用</button><button type="button" onClick={() => stopRecording(true)}>取消</button></div> : <button type="button" onClick={startRecording} disabled={recordingStatus !== 'idle'}>{recordingStatus === 'requesting' ? '正在请求麦克风…' : recordingStatus === 'processing' ? '正在处理录音…' : audio ? '重新录音' : '开始录音'}</button>}</div></div>
+          {audio && <div className="digital-audio-preview"><div><strong>{audio.name} · 待保存</strong><button type="button" onClick={() => setAudio(null)}>移除</button></div>{audioUrl && <audio controls src={audioUrl} aria-label="试听已选录音" />}</div>}
+          {!audio && existing?.audio[0] && <div className="digital-audio-preview"><strong>当前录音:{existing.audio[0].name}</strong><ProtectedAudio src={existing.audio[0].url} /></div>}
+          </div>
+      </section>
+    </div>
+    <div className="digital-create-footer"><p>目前只检查文件格式和是否上传,不会自动判断照片或声音质量。{!(retainedImages.length || images.length) || !(audio || existing?.audio.length) ? '现在可以先保存;提交课程制作前须补齐照片与录音。' : '照片与录音已齐备,可在课程中选择。'}</p><div><button type="button" className="secondary-button" onClick={onCancel} disabled={busy}>取消</button><button type="button" className="primary-button" onClick={save} disabled={busy || duplicate || recordingStatus !== 'idle'}>{busy ? '保存中…' : existing ? '保存修改' : '保存数字讲师'}</button></div></div>
+    {photoGuideOpen && <div className="digital-photo-lightbox" role="presentation" onClick={() => setPhotoGuideOpen(false)}><div className="digital-photo-guide-dialog" role="dialog" aria-modal="true" aria-label="照片参考样例" onClick={event => event.stopPropagation()}><button type="button" className="digital-photo-lightbox-close" onClick={() => setPhotoGuideOpen(false)} aria-label="关闭照片参考样例">×</button><h3>照片参考样例</h3><p>上传前对照检查:正面、光线均匀、背景整洁,人物完整清晰。</p><div className="digital-guide-examples"><div><div className="digital-guide-example-image digital-guide-example-good" role="img" aria-label="合适的正面半身照示例" /><strong>✓ 适合作为参考</strong><span>正面拍摄,背景干净,光线均匀</span></div><div><div className="digital-guide-example-image digital-guide-example-bad" role="img" aria-label="逆光且背景杂乱的反例" /><strong>✕ 建议重新拍摄</strong><span>逆光、背对镜头,背景杂乱</span></div></div><details><summary>查看完整拍摄说明</summary><img src="/guides/digital-instructor-photo-reference.png" alt="照片与录音素材准备参考材料" /></details></div></div>}
+    {preview && <div className="digital-photo-lightbox" role="presentation" onClick={() => setPreview(null)}><div className="digital-photo-lightbox-content" role="dialog" aria-modal="true" aria-label={`查看照片:${preview.alt}`} onClick={event => event.stopPropagation()}><button type="button" className="digital-photo-lightbox-close" onClick={() => setPreview(null)} aria-label="关闭照片预览">×</button>{preview.protected ? <ProtectedImage src={preview.src} alt={preview.alt} /> : <img src={preview.src} alt={preview.alt} />}<p>{preview.alt}</p></div></div>}
+  </section>
+}

+ 28 - 0
src/components/DigitalInstructorPicker.tsx

@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api'
+import { ProtectedImage } from './ProtectedImage'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorPicker({ value, onChange }: { value: string | null; onChange: (id: string | null) => void }) {
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [error, setError] = useState('')
+  const [search, setSearch] = useState('')
+  useEffect(() => { api.digitalInstructors.list().then(r => setItems(r.instructors)).catch(e => setError(e.message)) }, [])
+  const matches = items.filter(item => `${item.name} ${item.creatorName}`.toLowerCase().includes(search.trim().toLowerCase()))
+  return <div className="digital-picker">
+    <div className="digital-picker-head"><strong>选择数字讲师</strong></div>
+    {error && <p className="form-error">{error}</p>}
+    {items.length > 0 && <input className="digital-search" aria-label="搜索数字讲师" placeholder="搜索讲师姓名或创建者" value={search} onChange={e => setSearch(e.target.value)} />}
+    <div className="digital-picker-options">
+      {matches.map(item => <button type="button" key={item.id} className={`digital-picker-item ${value === item.id ? 'selected' : ''}`} onClick={() => onChange(item.id)}>
+        {item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span className="avatar">{item.name[0]}</span>}
+        <span><strong>{item.name}</strong><small>创建者 {item.creatorName} · {new Date(item.createdAt).toLocaleDateString('zh-CN')}</small></span>
+        <span>{value === item.id ? '已选中' : item.ready ? '选择' : '待补齐素材'}</span>
+      </button>)}
+      {!items.length && !error && <p>当前工作区还没有数字讲师。</p>}
+      {items.length > 0 && !matches.length && <p>没有找到符合条件的讲师。</p>}
+    </div>
+    {!items.length && <p className="digital-picker-help">请先到 <Link to="/digital-instructors">数字讲师资产库</Link> 创建,再返回课程选择。</p>}
+  </div>
+}

+ 6 - 1
src/components/EditCourseModal.tsx

@@ -2,6 +2,7 @@ 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
@@ -14,6 +15,7 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
   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('')
 
@@ -22,6 +24,7 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
     setName(course.name)
     setCategory(course.category)
     setDescription(course.description)
+    setDigitalInstructorId(course.digitalInstructorId || null)
     setError('')
   }, [isOpen, course])
 
@@ -47,7 +50,8 @@ export function EditCourseModal({ course, isOpen, onClose, onSuccess }: EditCour
       const res = await api.updateCourse(course.id, {
         name: name.trim(),
         category,
-        description: description.trim()
+        description: description.trim(),
+        ...(course.status === 'DRAFT' ? { digitalInstructorId } : {})
       })
       onSuccess(res.course)
       onClose()
@@ -77,6 +81,7 @@ 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}>

+ 13 - 0
src/components/ProtectedAudio.tsx

@@ -0,0 +1,13 @@
+import { useEffect, useState } from 'react'
+import { privateImageUrl } from '../api'
+
+export function ProtectedAudio({ src }: { src: string }) {
+  const [url, setUrl] = useState('')
+  useEffect(() => {
+    let alive = true
+    let created = ''
+    privateImageUrl(src).then(value => { created = value; if (alive) setUrl(value); else URL.revokeObjectURL(value) }).catch(() => setUrl(''))
+    return () => { alive = false; if (created) URL.revokeObjectURL(created) }
+  }, [src])
+  return url ? <audio controls preload="none" src={url}>您的浏览器不支持音频播放</audio> : null
+}

+ 17 - 0
src/components/ProtectedImage.tsx

@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react'
+import { privateImageUrl } from '../api'
+
+export function ProtectedImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
+  const [objectUrl, setObjectUrl] = useState('')
+  useEffect(() => {
+    let alive = true
+    let createdUrl = ''
+    privateImageUrl(src).then((url) => {
+      createdUrl = url
+      if (alive) setObjectUrl(url)
+      else URL.revokeObjectURL(url)
+    }).catch(() => setObjectUrl(''))
+    return () => { alive = false; if (createdUrl) URL.revokeObjectURL(createdUrl) }
+  }, [src])
+  return objectUrl ? <img src={objectUrl} alt={alt} className={className} /> : null
+}

+ 1 - 1
src/data/courseTemplates.ts

@@ -9,7 +9,7 @@ export const COURSE_TEMPLATES: CourseTemplate[] = [
     category: '党政专题',
     tags: ['精品党课', 'AI数字人', '1080P高清', 'PPT同步论证'],
     badge: '官方推荐示范',
-    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字人讲师近景与全景运镜、精准语速停顿控制。',
+    summary: '严格依据党课制作任务书规范,包含审定讲稿、全屏课件切换逻辑、数字讲师近景与全景运镜、精准语速停顿控制。',
     description: `【制作目标】PPT配合讲稿,完成一支可用于网络党课学习的横版课程视频。
 【形式结构】(课程包装片头—片名)—讲师讲述—PPT论证—讲师回场—章节转场—继续讲述—总结—片尾。
 【运镜与节奏】全片约 30% 为讲师中近景/全景,约 70% 为全屏课件;单页 PPT 驻留 10—45 秒;讲师近景口型眼神对齐,现代演播室虚化背景。

+ 11 - 3
src/pages/AuthPage.tsx

@@ -1,5 +1,5 @@
 import { FormEvent, useState } from 'react'
-import { Navigate, useSearchParams } from 'react-router-dom'
+import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
 import { ArrowRight, LockKeyhole, Phone } from 'lucide-react'
 import { Brand } from '../components/Brand'
 import { Footer } from '../components/Footer'
@@ -7,13 +7,14 @@ import { useAuth } from '../AuthContext'
 
 export function AuthPage() {
   const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
   const { user, authenticate } = useAuth()
   const [mode, setMode] = useState<'login' | 'register'>(() => searchParams.get('mode') === 'register' ? 'register' : 'login')
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
 
   if (user) {
-    return <Navigate to={user.role === 'ADMIN' ? '/admin' : '/'} replace />
+    return <Navigate to={user.role === 'ADMIN' ? '/admin' : searchParams.get('invite') ? `/join/${searchParams.get('invite')}` : searchParams.get('returnTo') || '/'} replace />
   }
 
   async function submit(e: FormEvent<HTMLFormElement>) {
@@ -29,7 +30,8 @@ export function AuthPage() {
     setBusy(true)
     setError('')
     try {
-      await authenticate(mode, String(f.get('phone')), password)
+      await authenticate(mode, String(f.get('phone')), password, mode === 'register' ? String(f.get('contactName')) : undefined, mode === 'register' ? searchParams.get('invite') || undefined : undefined)
+      if (mode === 'login' && searchParams.get('invite')) navigate(`/join/${searchParams.get('invite')}`)
     } catch (x) {
       setError(x instanceof Error ? x.message : '操作失败')
     } finally {
@@ -68,6 +70,12 @@ export function AuthPage() {
               />
             </div>
           </label>
+          {mode === 'register' && (
+            <label>
+              姓名
+              <input name="contactName" required maxLength={80} autoComplete="name" placeholder="请输入您的姓名" />
+            </label>
+          )}
           <label>
             密码
             <div className="input-with-icon">

+ 24 - 28
src/pages/CourseWorkspace.tsx

@@ -24,7 +24,8 @@ import {
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useNavigate, useParams } from 'react-router-dom'
-import { api } from '../api'
+import { api, downloadPrivateFile } from '../api'
+import { ProtectedImage } from '../components/ProtectedImage'
 import { StatusBadge } from '../components/StatusBadge'
 import { AddInstructorModal } from '../components/AddInstructorModal'
 import { EpisodeModal } from '../components/EpisodeModal'
@@ -33,6 +34,7 @@ import { ContactModal } from '../components/ContactModal'
 import { EditCourseModal } from '../components/EditCourseModal'
 import { CourseProductionProgress } from '../components/CourseProductionProgress'
 import contactQrCode from '../assets/contact-group-qrcode.jpg'
+import { formatDateTime } from '../utils/date'
 import type { Course, Episode } from '../types'
 
 const size = (n: number) =>
@@ -258,9 +260,15 @@ export function CourseWorkspace() {
               <Edit3 size={14} /> 修改
             </button>
           </div>
-          <p>
-            {course.category || '综合分类'}
-          </p>
+          <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 }}>
+              最后更新:{formatDateTime(course.updatedAt || course.createdAt)}
+            </span>
+          </div>
         </div>
       </div>
 
@@ -341,15 +349,12 @@ export function CourseWorkspace() {
                               <strong>{deliv.originalName}</strong>
                               <span>{size(deliv.size)}</span>
                             </div>
-                            <a
-                              href={deliv.url}
-                              download={deliv.originalName}
+                            <button
+                              onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                               className="primary-button small-button"
-                              target="_blank"
-                              rel="noreferrer"
                             >
                               <Download size={14} /> 下载
-                            </a>
+                            </button>
                           </div>
                         ))}
                       </div>
@@ -371,15 +376,12 @@ export function CourseWorkspace() {
                               <strong>{deliv.originalName}</strong>
                               <span>{size(deliv.size)}</span>
                             </div>
-                            <a
-                              href={deliv.url}
-                              download={deliv.originalName}
+                            <button
+                              onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                               className="primary-button small-button"
-                              target="_blank"
-                              rel="noreferrer"
                             >
                               <Download size={14} /> 下载
-                            </a>
+                            </button>
                           </div>
                         ))}
                       </div>
@@ -428,7 +430,7 @@ export function CourseWorkspace() {
           <div className="receipt">
             <span>
               <small>课程状态</small>
-              <strong style={{ color: '#245bd6' }}>制作中</strong>
+              <strong style={{ color: '#0875bd' }}>制作中</strong>
             </span>
             <span>
               <small>课程分集</small>
@@ -918,7 +920,7 @@ export function CourseWorkspace() {
           <aside className="instructor-panel workspace-sidebar">
             <div className="aside-title">
               <div>
-                <h3>讲师信息 ({course.instructors.length})</h3>
+                <h3>数字讲师</h3>
               </div>
               <button
                 type="button"
@@ -926,7 +928,7 @@ export function CourseWorkspace() {
                 onClick={() => setShowAddInstructorModal(true)}
               >
                 <UserPlus size={16} />
-                添加
+                {course.digitalInstructorId ? '更换' : '选择'}
               </button>
             </div>
 
@@ -935,7 +937,7 @@ export function CourseWorkspace() {
                 <div className="instructor-card" key={t.id}>
                   {t.imageUrls?.length ? (
                     <div className="instructor-photo-stack">
-                      {t.imageUrls.map((url, index) => <img src={url} alt={`${t.name} 出镜照 ${index + 1}`} key={url} />)}
+                      {t.imageUrls.map((url, index) => <ProtectedImage src={url} alt={`${t.name} 出镜照 ${index + 1}`} key={url} />)}
                     </div>
                   ) : <span className="avatar">{t.name[0]}</span>}
                   <div>
@@ -943,23 +945,17 @@ export function CourseWorkspace() {
                     <small>{t.organization || '未填写单位'}</small>
                     <p>{t.introduction || '暂无介绍'}</p>
                   </div>
-                  <button
-                    title="删除讲师"
-                    onClick={async () => setCourse((await api.deleteInstructor(id, t.id)).course)}
-                  >
-                    <Trash2 size={14} />
-                  </button>
                 </div>
               ))}
               {!course.instructors.length && (
                 <div className="empty-instructor-box">
-                  <p>还没有讲师信息,可按需添加</p>
+                  <p>尚未选择数字讲师</p>
                   <button
                     type="button"
                     className="add-instructor-trigger-btn"
                     onClick={() => setShowAddInstructorModal(true)}
                   >
-                    <UserPlus size={14} /> 点击添加讲师
+                    <UserPlus size={14} /> 选择讲师
                   </button>
                 </div>
               )}

+ 66 - 23
src/pages/Dashboard.tsx

@@ -1,14 +1,20 @@
 import { ArrowRight, BookOpen, CheckCircle2, Clock3, Loader2, Plus, Sparkles, Stars } from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { api } from '../api'
 import { StatusBadge } from '../components/StatusBadge'
+import { formatDate, formatDateTime } from '../utils/date'
 import type { Course } from '../types'
+import { useWorkspace } from '../WorkspaceContext'
+
+type DashboardSortMode = 'updated_desc' | 'updated_asc' | 'created_desc' | 'created_asc'
 
 export function Dashboard() {
+  const { workspaceId, currentOrganization } = useWorkspace()
   const [courses, setCourses] = useState<Course[]>([])
   const [loading, setLoading] = useState(true)
   const [error, setError] = useState('')
+  const [sortMode, setSortMode] = useState<DashboardSortMode>('updated_desc')
 
   useEffect(() => {
     api
@@ -16,12 +22,25 @@ export function Dashboard() {
       .then((r) => setCourses(r.courses))
       .catch((e) => setError(e.message))
       .finally(() => setLoading(false))
-  }, [])
+  }, [workspaceId])
 
   const waitingCount = courses.filter((c) => c.status === 'WAITING_PRODUCTION').length
   const inProductionCount = courses.filter((c) => c.status === 'IN_PRODUCTION').length
   const completedCount = courses.filter((c) => c.status === 'COMPLETED').length
 
+  const sortedCourses = useMemo(() => {
+    return [...courses].sort((a, b) => {
+      const updatedA = new Date(a.updatedAt || a.createdAt).getTime() || 0
+      const updatedB = new Date(b.updatedAt || b.createdAt).getTime() || 0
+      const createdA = new Date(a.createdAt).getTime() || 0
+      const createdB = new Date(b.createdAt).getTime() || 0
+      if (sortMode === 'updated_desc') return updatedB - updatedA
+      if (sortMode === 'updated_asc') return updatedA - updatedB
+      if (sortMode === 'created_desc') return createdB - createdA
+      return createdA - createdB
+    })
+  }, [courses, sortMode])
+
   return (
     <div className="page dashboard">
       <section className="hero">
@@ -39,7 +58,7 @@ export function Dashboard() {
           </h1>
           <p>归集课程素材,建立制作任务。简单几步,让创意从这里启程。</p>
           <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
-            <Link className="primary-button" to="/courses/new">
+            <Link className="primary-button" to="/courses/new" aria-disabled={currentOrganization?.isActive === false} onClick={(e) => { if (currentOrganization?.isActive === false) e.preventDefault() }}>
               <Plus size={18} /> 创建新课程
             </Link>
           </div>
@@ -51,28 +70,46 @@ export function Dashboard() {
       </section>
 
       <section className="content-section">
-        <div className="section-heading">
+        <div className="section-heading" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '12px' }}>
           <div>
-            <h2>我的课程</h2>
+            <h2>{currentOrganization ? `${currentOrganization.name} · 我的课程` : '个人课程'}</h2>
           </div>
-          <div className="metrics">
-            <span>
-              <BookOpen size={16} /> {courses.length} 门课程
-            </span>
-            {waitingCount > 0 && (
+          <div style={{ display: 'flex', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
+            <div className="metrics">
               <span>
-                <Clock3 size={16} /> {waitingCount} 等待制作
-              </span>
-            )}
-            {inProductionCount > 0 && (
-              <span style={{ color: '#2d62ce' }}>
-                <Loader2 size={16} className="spin" /> {inProductionCount} 制作中
-              </span>
-            )}
-            {completedCount > 0 && (
-              <span style={{ color: '#2e7d32' }}>
-                <CheckCircle2 size={16} /> {completedCount} 已完成
+                <BookOpen size={16} /> {courses.length} 门课程
               </span>
+              {waitingCount > 0 && (
+                <span>
+                  <Clock3 size={16} /> {waitingCount} 等待制作
+                </span>
+              )}
+              {inProductionCount > 0 && (
+                <span style={{ color: '#0875bd' }}>
+                  <Loader2 size={16} className="spin" /> {inProductionCount} 制作中
+                </span>
+              )}
+              {completedCount > 0 && (
+                <span style={{ color: '#2e7d32' }}>
+                  <CheckCircle2 size={16} /> {completedCount} 已完成
+                </span>
+              )}
+            </div>
+
+            {courses.length > 1 && (
+              <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+                <span style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</span>
+                <select
+                  style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+                  value={sortMode}
+                  onChange={(e) => setSortMode(e.target.value as DashboardSortMode)}
+                >
+                  <option value="updated_desc">最后更新(最新优先)</option>
+                  <option value="updated_asc">最后更新(最早优先)</option>
+                  <option value="created_desc">创建时间(最新优先)</option>
+                  <option value="created_asc">创建时间(最早优先)</option>
+                </select>
+              </div>
             )}
           </div>
         </div>
@@ -92,14 +129,14 @@ export function Dashboard() {
             <h3>还没有课程</h3>
             <p>创建第一门课程。</p>
             <div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
-              <Link className="primary-button small-button" to="/courses/new">
+              <Link className="primary-button small-button" to="/courses/new" aria-disabled={currentOrganization?.isActive === false} onClick={(e) => { if (currentOrganization?.isActive === false) e.preventDefault() }}>
                 <Plus size={15} /> 创建课程或使用示例
               </Link>
             </div>
           </div>
         ) : (
           <div className="course-grid">
-            {courses.map((course, index) => (
+            {sortedCourses.map((course, index) => (
               <Link to={`/courses/${course.id}`} className="course-card" key={course.id}>
                 <div className={`course-number tone-${index % 3}`}>
                   {String(index + 1).padStart(2, '0')}
@@ -120,6 +157,12 @@ export function Dashboard() {
                         <Sparkles size={12} style={{ display: 'inline', verticalAlign: '-1px' }} /> 成果可查收
                       </span>
                     )}
+                    <span title={`创建时间:${formatDateTime(course.createdAt)}`}>
+                      创建于 {formatDate(course.createdAt)}
+                    </span>
+                    <span title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#0f172a', fontWeight: 500 }}>
+                      更新于 {formatDateTime(course.updatedAt || course.createdAt)}
+                    </span>
                   </div>
                 </div>
                 <ArrowRight className="card-arrow" size={19} />

+ 62 - 0
src/pages/DigitalInstructorEditorPage.tsx

@@ -0,0 +1,62 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
+import { api } from '../api'
+import { useWorkspace } from '../WorkspaceContext'
+import { useAuth } from '../AuthContext'
+import { DigitalInstructorCreateForm } from '../components/DigitalInstructorCreateForm'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorEditorPage({ admin = false }: { admin?: boolean }) {
+  const { id } = useParams()
+  const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const { workspaceId, currentOrganization } = useWorkspace()
+  const { user } = useAuth()
+  const adminOrganizationId = admin ? searchParams.get('organizationId') || '' : ''
+  const activeWorkspace = admin ? adminOrganizationId : workspaceId
+  const backTo = admin ? `/admin/digital-instructors?organizationId=${encodeURIComponent(adminOrganizationId)}` : '/digital-instructors'
+  const [item, setItem] = useState<DigitalInstructor | null>(null)
+  const [names, setNames] = useState<string[] | null>(null)
+  const [ownerOptions, setOwnerOptions] = useState<{ id: string; contact_name: string }[]>()
+  const [workspaceName, setWorkspaceName] = useState(currentOrganization?.name || '个人工作区')
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+
+  const isManager = Boolean(admin || user?.role === 'ADMIN' || currentOrganization?.role === 'MANAGER')
+
+  useEffect(() => {
+    let cancelled = false
+    setLoading(true); setError('')
+    if (admin && !adminOrganizationId) { setError('请先选择组织'); setLoading(false); return }
+    const orgIdToLoad = admin ? adminOrganizationId : (currentOrganization?.id || '')
+    const fetchManagement = !isManager || !orgIdToLoad ? Promise.resolve(null)
+      : (admin || user?.role === 'ADMIN')
+        ? api.admin.organization(orgIdToLoad).catch(() => currentOrganization?.role === 'MANAGER' ? api.organizations.manage(orgIdToLoad) : null)
+        : api.organizations.manage(orgIdToLoad)
+    Promise.all([
+      api.digitalInstructors.list(activeWorkspace),
+      id ? api.digitalInstructors.get(id, activeWorkspace) : Promise.resolve(null),
+      fetchManagement
+    ]).then(([list, detail, management]) => {
+      if (cancelled) return
+      const selected = detail?.instructor || null
+      if (selected && !admin && selected.ownerId !== user?.id && currentOrganization?.role !== 'MANAGER' && user?.role !== 'ADMIN') {
+        setError('您没有编辑这位数字讲师的权限'); setLoading(false); return
+      }
+      setItem(selected)
+      setNames(activeWorkspace === 'personal' ? null : list.instructors.filter(candidate => candidate.id !== selected?.id).map(candidate => candidate.name))
+      if (management && 'members' in management) setOwnerOptions(management.members || [])
+      else setOwnerOptions(undefined)
+      if (admin && management && 'organization' in management) setWorkspaceName(management.organization.name)
+      else setWorkspaceName(currentOrganization?.name || '个人工作区')
+      setLoading(false)
+    }).catch(cause => { if (!cancelled) { setError((cause as Error).message); setLoading(false) } })
+    return () => { cancelled = true }
+  }, [id,activeWorkspace,admin,adminOrganizationId,currentOrganization?.id,currentOrganization?.role,currentOrganization?.name,user?.id,user?.role,isManager])
+
+  return <div className="page digital-editor-page">
+    <Link className="digital-editor-back" to={backTo}>← 返回数字讲师</Link>
+    {loading ? <div className="center-loading">正在加载数字讲师…</div> : error ? <div className="notice notice-error">{error}</div> :
+      <DigitalInstructorCreateForm key={`${activeWorkspace}-${id || 'new'}`} existing={item || undefined} workspaceId={activeWorkspace} workspaceName={workspaceName} organizationNames={names} ownerOptions={ownerOptions} canManageOwner={isManager} onCancel={() => navigate(backTo)} onCreated={() => navigate(backTo, { state: { notice: id ? '数字讲师已更新' : '数字讲师已创建' } })} />}
+  </div>
+}

+ 91 - 0
src/pages/DigitalInstructorsPage.tsx

@@ -0,0 +1,91 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useLocation } from 'react-router-dom'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { ProtectedImage } from '../components/ProtectedImage'
+import type { DigitalInstructor } from '../types'
+
+export function DigitalInstructorsPage() {
+  const { user } = useAuth()
+  const { workspaceId, currentOrganization, organizations } = useWorkspace()
+  const location = useLocation()
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [notice, setNotice] = useState((location.state as { notice?: string } | null)?.notice || '')
+  const [search, setSearch] = useState('')
+  const [filter, setFilter] = useState<'all' | 'ready' | 'incomplete'>('all')
+  const [searchOpen, setSearchOpen] = useState(false)
+  const searchInputRef = useRef<HTMLInputElement>(null)
+  const [deleteTarget, setDeleteTarget] = useState<DigitalInstructor | null>(null)
+  const [usageId, setUsageId] = useState<string | null>(null)
+  const [usedCourses, setUsedCourses] = useState<{ id: string; name: string; status: string }[]>([])
+  const [targetOrg, setTargetOrg] = useState('')
+  const [busy, setBusy] = useState(false)
+
+  async function load() {
+    setLoading(true); setError('')
+    try { setItems((await api.digitalInstructors.list()).instructors) }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setLoading(false) }
+  }
+  useEffect(() => { load(); setUsageId(null); setTargetOrg('') }, [workspaceId])
+  useEffect(() => { if (searchOpen) searchInputRef.current?.focus() }, [searchOpen])
+
+  async function duplicate(item: DigitalInstructor) {
+    setBusy(true); setError('')
+    try { const result = await api.digitalInstructors.duplicate(item.id); setNotice(`已复制为「${result.instructor.name}」`); await load() }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function copyToOrganization(item: DigitalInstructor) {
+    if (!targetOrg) { setError('请先选择目标组织'); return }
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.copy(item.id,targetOrg); setNotice('已复制到组织资产库'); setTargetOrg('') }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function showUsage(item: DigitalInstructor) {
+    if (usageId === item.id) { setUsageId(null); return }
+    setError('')
+    try { setUsedCourses((await api.digitalInstructors.usage(item.id)).courses); setUsageId(item.id) }
+    catch (cause) { setError((cause as Error).message) }
+  }
+  async function remove() {
+    if (!deleteTarget) return
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.delete(deleteTarget.id); setNotice(`已删除「${deleteTarget.name}」`); setDeleteTarget(null); await load() }
+    catch (cause) { setError((cause as Error).message); setDeleteTarget(null) }
+    finally { setBusy(false) }
+  }
+  const visibleItems = items.filter(item => {
+    if (filter === 'ready' && !item.ready || filter === 'incomplete' && item.ready) return false
+    return `${item.name} ${item.creatorName} ${item.ownerName} ${item.note}`.toLowerCase().includes(search.trim().toLowerCase())
+  })
+  return <div className="page digital-library-page">
+    <div className="digital-library-heading"><div><h1>数字讲师</h1><p>{currentOrganization?.name || '个人工作区'} · {items.length} 位讲师</p></div><div className="digital-library-heading-actions"><button type="button" className="secondary-button digital-search-toggle" aria-expanded={searchOpen} aria-controls="digital-instructor-search" onClick={() => setSearchOpen(open => !open)}>⌕ 搜索{search || filter !== 'all' ? ' · 已筛选' : ''}</button><Link className="primary-button" to="/digital-instructors/new">+ 创建数字讲师</Link></div></div>
+    {notice && <div className="notice notice-success">{notice}</div>}{error && <div className="notice notice-error" role="alert">{error}</div>}
+    {searchOpen && <div id="digital-instructor-search" className="digital-library-toolbar" role="search"><input ref={searchInputRef} aria-label="搜索数字讲师" placeholder="搜索姓名、备注、创建者或所有者" value={search} onChange={event => setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Escape') setSearchOpen(false) }} /><select aria-label="按素材状态筛选" value={filter} onChange={event => setFilter(event.target.value as typeof filter)}><option value="all">全部状态</option><option value="ready">素材齐备</option><option value="incomplete">待补齐</option></select>{(search || filter !== 'all') && <button type="button" className="digital-search-clear" onClick={() => { setSearch(''); setFilter('all'); searchInputRef.current?.focus() }}>清除筛选</button>}</div>}
+    {loading ? <div className="center-loading">正在加载数字讲师…</div> : !items.length ? <div className="digital-library-empty"><h2>还没有数字讲师</h2><p>创建后即可在课程中选择。</p><Link className="primary-button" to="/digital-instructors/new">创建第一位讲师</Link></div> : !visibleItems.length ? <div className="digital-library-empty">没有找到符合条件的讲师。</div> :
+      <div className="digital-library-grid">{visibleItems.map(item => {
+        const canEdit = item.ownerId === user?.id || currentOrganization?.role === 'MANAGER'
+        const canDelete = workspaceId === 'personal' || currentOrganization?.role === 'MANAGER'
+        return <article key={item.id} className="digital-library-card digital-asset-card">
+          <div className="digital-asset-photo">{item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span>暂无照片</span>}</div>
+          <div className="digital-asset-body"><div className="digital-asset-title"><h2>{item.name}</h2><span className={item.ready ? 'digital-state-ready' : 'digital-state-incomplete'}>{item.ready ? '素材齐备' : '待补齐'}</span></div>
+            <p className="digital-asset-note">{item.note || '暂无备注'}</p>
+            <p className="digital-asset-meta">所有者:{item.ownerName === '未知用户' ? '历史导入' : item.ownerName} · 创建者:{item.creatorName === '未知用户' ? '历史导入' : item.creatorName}</p>
+            <p className="digital-asset-meta">创建于 {new Date(item.createdAt).toLocaleString('zh-CN')}</p>
+            {item.usageCount > 0 && <button type="button" className="digital-usage-link" onClick={() => showUsage(item)}>用于 {item.usageCount} 门课程 · 查看</button>}
+            {usageId === item.id && <div className="digital-usage-list"><strong>正在使用这位讲师的课程</strong>{usedCourses.map(course => <Link key={course.id} to={`/courses/${course.id}`}>{course.name}</Link>)}</div>}
+            <div className="digital-asset-actions">{canEdit && <Link className="secondary-button" to={`/digital-instructors/${item.id}/edit`}>编辑</Link>}
+              <details className="digital-more-menu"><summary className="secondary-button">更多操作</summary><div><button type="button" onClick={() => duplicate(item)} disabled={busy}>复制讲师</button>
+                {workspaceId === 'personal' && organizations.some(org => org.isActive) && <div className="digital-copy-org"><label>加入组织资产库<select value={targetOrg} onChange={event => setTargetOrg(event.target.value)}><option value="">选择组织</option>{organizations.filter(org => org.isActive).map(org => <option key={org.id} value={org.id}>{org.name}</option>)}</select></label><button type="button" onClick={() => copyToOrganization(item)} disabled={busy || !targetOrg}>复制到组织</button></div>}
+                {canDelete && <button type="button" className="digital-danger-action" disabled={busy || item.usageCount > 0} title={item.usageCount > 0 ? '已有课程使用,不能删除' : undefined} onClick={() => setDeleteTarget(item)}>{item.usageCount > 0 ? '已有课程使用,不能删除' : '删除讲师'}</button>}</div></details></div>
+          </div>
+        </article>
+      })}</div>}
+    {deleteTarget && <div className="digital-confirm-backdrop" role="presentation"><div className="digital-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="digital-delete-title"><h2 id="digital-delete-title">删除数字讲师?</h2><p>确定删除「{deleteTarget.name}」吗?删除后将从资产库中隐藏。</p><div><button type="button" className="secondary-button" onClick={() => setDeleteTarget(null)} disabled={busy}>取消</button><button type="button" className="primary-button digital-danger-button" onClick={remove} disabled={busy}>{busy ? '删除中…' : '确认删除'}</button></div></div></div>}
+  </div>
+}

+ 6 - 6
src/pages/HelpPage.tsx

@@ -27,7 +27,7 @@ interface FAQItem {
 
 const FEATURE_LIST = [
   { icon: Layers, title: '结构化大纲编排', description: '支持按集管理课程知识点、逐字稿讲义与预估时长,告别混乱的文档传输与版本断层。' },
-  { icon: UserCheck, title: '讲师出镜形象配置', description: '支持为每门课程建立专属讲师库,录入站姿与坐姿立绘素材,保障课件出镜形象专业统一。' },
+  { icon: UserCheck, title: '数字讲师资产库', description: '个人和组织工作区分别管理数字讲师。课程可选择本工作区的一位讲师。' },
   { icon: UploadCloud, title: '全格式课件归集', description: 'PPT、PDF、教学讲义集中云端归档,自动匹配分集内容,让每一次制作都有迹可循。' },
   { icon: Video, title: '专业制作与成果交付', description: '一键提交进入流水线制作,在线实时跟踪制作状态,成片交付支持在线即时预览与下载。' },
 ]
@@ -35,7 +35,7 @@ const FEATURE_LIST = [
 const WORKFLOW_LIST = [
   { step: '01', title: '创建课程', description: '录入课程名称、分类和简介,或直接选择系统提供的官方范例模板一键快速生成。' },
   { step: '02', title: '编排大纲与台词', description: '添加分集,填写每集知识主题与口播讲义,设定课程节奏。' },
-  { step: '03', title: '上传讲师与课件', description: '上传讲师高清照片(坐姿/站姿)及配套 PPT 课件等制作素材。' },
+  { step: '03', title: '选择讲师与上传课件', description: '从工作区选择数字讲师,上传参考照片、录音和配套课件;提交制作前补齐讲师照片和录音。' },
   { step: '04', title: '提交制作与交付', description: '确认无误后提交,顾问团队排期制作,完成后在工作区一键获取成品。' },
 ]
 
@@ -65,7 +65,7 @@ const FAQ_LIST: FAQItem[] = [
         <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>:从工作区资产库选择一位数字讲师,上传照片、录音及配套 PPT 课件;照片和录音可稍后补齐,但提交制作前必须齐备;</li>
           <li><strong>提交制作</strong>:确认材料无误后点击“提交制作”,制作团队将跟进合成并交付最终视频。</li>
         </ol>
       </div>
@@ -76,7 +76,7 @@ const FAQ_LIST: FAQItem[] = [
     category: 'getting-started',
     question: '新手可以如何快速体验?有预设模板吗?',
     answer:
-      '在创建课程页面,我们提供了多种精选的官方课程模板(如企业培训、通识科普、专业讲座等)。点击“使用示例”即可一键预填完整的分集大纲、讲师配置与配套素材,方便您快速熟悉全套工作流。',
+      '在创建课程页面,我们提供了多种精选的官方课程模板(如企业培训、通识科普、专业讲座等)。使用示例后,可在课程页选择工作区数字讲师;模板不会自动生成可用于制作的讲师。',
   },
   {
     id: 'ppt-requirements',
@@ -154,9 +154,9 @@ const FAQ_LIST: FAQItem[] = [
   {
     id: 'profile-and-security',
     category: 'account',
-    question: '如何修改我的联系人姓名、单位或登录密码?',
+    question: '如何修改我的联系人姓名或登录密码?',
     answer:
-      '点击顶部导航栏右上角的个人账号名称(或前往「个人中心 /profile」),即可修改您的联系人姓名、单位名称、微信号,或进行登录密码的修改。',
+      '点击顶部导航栏右上角的个人账号名称(或前往「个人中心 /profile」),即可修改联系人姓名、微信号或登录密码。所属组织由组织管理员或平台管理员管理。',
   },
 ]
 

+ 14 - 223
src/pages/NewCourse.tsx

@@ -1,30 +1,22 @@
 import {
   ArrowLeft,
   ArrowRight,
-  BookOpen,
-  Check,
   CheckCircle2,
-  FileCheck,
   Flame,
-  Info,
-  Layers,
   Sparkles,
-  Users,
-  Video,
-  Volume2,
-  Wand2,
-  X
+  Wand2
 } from 'lucide-react'
 import { FormEvent, useEffect, useState } from 'react'
 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高清规格。`
+import { DigitalInstructorPicker } from '../components/DigitalInstructorPicker'
+import { useWorkspace } from '../WorkspaceContext'
 
 export function NewCourse() {
   const navigate = useNavigate()
+  const { workspaceId } = useWorkspace()
+  const [digitalInstructorId, setDigitalInstructorId] = useState<string | null>(null)
   const [templates, setTemplates] = useState<CourseTemplate[]>(COURSE_TEMPLATES)
   const [selectedTemplateId, setSelectedTemplateId] = useState<string>(COURSE_TEMPLATES[0].id)
   const [activeTab, setActiveTab] = useState<'form' | 'templates'>('form')
@@ -32,12 +24,11 @@ export function NewCourse() {
   // 表单状态
   const [name, setName] = useState('')
   const [category, setCategory] = useState('党政专题')
-  const [description, setDescription] = useState(DEFAULT_COURSE_DESCRIPTION)
+  const [description, setDescription] = useState('')
 
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
   const [filledNotice, setFilledNotice] = useState('')
-  const [detailModalOpen, setDetailModalOpen] = useState(false)
 
   const currentTemplate = templates.find((t) => t.id === selectedTemplateId) || templates[0]
 
@@ -85,7 +76,8 @@ export function NewCourse() {
       const { course } = await api.createCourse({
         name: name.trim(),
         category,
-        description: description.trim()
+        description: description.trim(),
+        digitalInstructorId
       })
       navigate(`/courses/${course.id}`)
     } catch (x) {
@@ -103,16 +95,12 @@ export function NewCourse() {
       <div className="form-layout new-course-layout">
         {/* 左侧说明与精选示例区域 */}
         <aside className="new-course-aside">
-          <span className="step-number">01</span>
-          <p className="eyebrow">
-            <Sparkles size={13} /> 创建课程
-          </p>
           <h1>
             先告诉我们,<br />
             这是一门什么课。
           </h1>
           <p className="aside-subtext">
-            填写基本信息后,可继续添加课程资产和讲师;也可以参考下方制作示例快速开始。
+            填写基本信息并选择工作区数字讲师;照片和录音可稍后补齐,提交制作前必须齐备。
           </p>
 
           {/* 示范模版精选卡片 */}
@@ -145,32 +133,6 @@ export function NewCourse() {
                 <h3 className="sample-title">{currentTemplate.name}</h3>
                 <p className="sample-desc">{currentTemplate.summary}</p>
               </div>
-
-              {/* 关键规格参数列表 */}
-              <div className="specs-grid">
-                <div className="spec-item">
-                  <Video size={13} />
-                  <div>
-                    <small>分辨率与画面</small>
-                    <span>{currentTemplate.keySpecs.resolution}</span>
-                  </div>
-                </div>
-                <div className="spec-item">
-                  <Layers size={13} />
-                  <div>
-                    <small>讲师与课件比例</small>
-                    <span>{currentTemplate.keySpecs.presenterRatio}</span>
-                  </div>
-                </div>
-                <div className="spec-item">
-                  <Volume2 size={13} />
-                  <div>
-                    <small>语速与节奏控制</small>
-                    <span>{currentTemplate.keySpecs.speechSpeed}</span>
-                  </div>
-                </div>
-              </div>
-
               {/* 模版操作按钮组 */}
               <div className="template-actions">
                 <button
@@ -186,21 +148,11 @@ export function NewCourse() {
                   className="primary-button small-button action-btn highlight-sample-btn"
                   disabled={busy}
                   onClick={() => handleCreateFromTemplate(currentTemplate)}
-                  title="直接创建完整示范课程,并自动导入讲师与课件素材"
+                  title="创建示范课程并导入课件素材,讲师需在课程页选择"
                 >
                   <Sparkles size={14} /> 一键使用此示例
                 </button>
               </div>
-
-              <div className="template-card-footer">
-                <button
-                  type="button"
-                  className="detail-link-btn"
-                  onClick={() => setDetailModalOpen(true)}
-                >
-                  <Info size={13} /> 查看完整任务书制作规范与验收标准 &raquo;
-                </button>
-              </div>
             </div>
           </div>
         </aside>
@@ -217,13 +169,12 @@ export function NewCourse() {
           <form className="paper-form" onSubmit={submit}>
             <div className="form-title">
               <div className="title-left">
-                <span>COURSE PROFILE</span>
-                <h2>课程基本信息</h2>
+                <h2>视频课程基本信息</h2>
               </div>
             </div>
 
             <label>
-              <span>课程名称 <b>*</b></span>
+              <span>视频课程名称 <b>*</b></span>
               <input
                 name="name"
                 required
@@ -234,37 +185,11 @@ export function NewCourse() {
               />
             </label>
 
-            <label>
-              <span>课程分类</span>
-              <select
-                name="category"
-                value={category}
-                onChange={(e) => setCategory(e.target.value)}
-              >
-                <option value="党政专题">党政专题</option>
-                <option value="高校公开课">高校公开课</option>
-                <option value="企业培训">企业培训</option>
-                <option value="职业教育">职业教育</option>
-                <option value="其他">其他</option>
-              </select>
-            </label>
-
-            <label>
-              <span>课程简介与制作要求</span>
-              <textarea
-                name="description"
-                rows={7}
-                maxLength={1000}
-                placeholder="填写课程概述、目标人群、运镜风格(如30%出镜/70%课件)、语速要求(如210-225字/分)或技术规格要求等..."
-                value={description}
-                onChange={(e) => setDescription(e.target.value)}
-              />
-            </label>
-
             {error && <p className="form-error">{error}</p>}
+            <DigitalInstructorPicker key={workspaceId} value={digitalInstructorId} onChange={setDigitalInstructorId} />
 
             <div className="form-footer">
-              <span>下一步:添加课程资产与讲师</span>
+              <span>下一步:添加课程资产并完善讲师素材</span>
               <button className="primary-button" disabled={busy || !name.trim()}>
                 {busy ? '正在创建…' : '继续'}
                 <ArrowRight size={17} />
@@ -273,140 +198,6 @@ export function NewCourse() {
           </form>
         </div>
       </div>
-
-      {/* 完整制作任务书与技术标准弹窗 */}
-      {detailModalOpen && (
-        <div className="modal-backdrop" onClick={() => setDetailModalOpen(false)}>
-          <div className="modal-dialog brief-modal" onClick={(e) => e.stopPropagation()}>
-            <div className="modal-header">
-              <div>
-                <span className="modal-badge">{currentTemplate.badge || '制作示范'}</span>
-                <h3>{currentTemplate.name} · 制作任务书</h3>
-              </div>
-              <button
-                type="button"
-                className="close-modal-btn"
-                onClick={() => setDetailModalOpen(false)}
-              >
-                <X size={18} />
-              </button>
-            </div>
-
-            <div className="modal-body brief-modal-content">
-              {/* 核心重点 */}
-              <div className="brief-section focus-card">
-                <h4>
-                  <Flame size={15} /> 核心重点要求
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.focalPoints.map((pt, i) => (
-                    <li key={i}>{pt}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 项目目标 */}
-              <div className="brief-section">
-                <h4>
-                  <FileCheck size={15} /> 项目目标与规格
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.goals.map((g, i) => (
-                    <li key={i}>{g}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 画面与运镜节奏 */}
-              <div className="brief-section">
-                <h4>
-                  <Video size={15} /> 画面运镜与语速控制
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.cameraAndPacing.map((c, i) => (
-                    <li key={i}>{c}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 验收标准 */}
-              <div className="brief-section">
-                <h4>
-                  <CheckCircle2 size={15} /> 交付与验收标准
-                </h4>
-                <ul>
-                  {currentTemplate.taskBrief.acceptanceCriteria.map((a, i) => (
-                    <li key={i}>{a}</li>
-                  ))}
-                </ul>
-              </div>
-
-              {/* 预置素材与讲师概览 */}
-              <div className="brief-section">
-                <h4>
-                  <Users size={15} /> 示范配置一览
-                </h4>
-                <div className="brief-preset-grid">
-                  <div className="brief-subcard">
-                    <strong>示范讲师</strong>
-                    {currentTemplate.instructors.map((inst, i) => (
-                      <div key={i} className="inst-subrow">
-                        <span>{inst.name}</span>
-                        <small>{inst.organization}</small>
-                        <p>{inst.introduction}</p>
-                      </div>
-                    ))}
-                  </div>
-                  {currentTemplate.episodes && currentTemplate.episodes.length > 0 && (
-                    <div className="brief-subcard">
-                      <strong>示范课程分集 ({currentTemplate.episodes.length} 讲/集)</strong>
-                      {currentTemplate.episodes.map((ep, i) => (
-                        <div key={i} className="ep-subrow">
-                          <span>{ep.title}</span>
-                          <small>包含完整口播讲稿与课件</small>
-                        </div>
-                      ))}
-                    </div>
-                  )}
-                  <div className="brief-subcard">
-                    <strong>示范课件与讲稿</strong>
-                    {currentTemplate.assets.map((ast, i) => (
-                      <div key={i} className="asset-subrow">
-                        <span>{ast.originalName}</span>
-                        <small>{ast.sizeText} · {ast.description}</small>
-                      </div>
-                    ))}
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="modal-footer">
-              <button
-                type="button"
-                className="secondary-button"
-                onClick={() => {
-                  handleFillTemplate(currentTemplate)
-                  setDetailModalOpen(false)
-                }}
-              >
-                <Wand2 size={15} /> 填入当前表单
-              </button>
-              <button
-                type="button"
-                className="primary-button highlight-sample-btn"
-                disabled={busy}
-                onClick={() => {
-                  setDetailModalOpen(false)
-                  handleCreateFromTemplate(currentTemplate)
-                }}
-              >
-                <Sparkles size={15} /> 一键使用此示例创建课程
-              </button>
-            </div>
-          </div>
-        </div>
-      )}
     </div>
   )
 }

+ 279 - 0
src/pages/OrganizationManage.tsx

@@ -0,0 +1,279 @@
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
+import { Link, Navigate, useParams } from 'react-router-dom'
+import QRCode from 'qrcode'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { StatusBadge } from '../components/StatusBadge'
+import { AdminCourseDetail } from './admin/AdminCourseDetail'
+import { formatDateTime } from '../utils/date'
+import type { CourseStatus } from '../types'
+
+type CourseSortField = 'updated_at' | 'created_at'
+type SortOrder = 'desc' | 'asc'
+
+export function OrganizationManage() {
+  const { slug = '', id: courseId } = useParams()
+  const { user } = useAuth()
+  const { organizations, selectWorkspace } = useWorkspace()
+  const organization = organizations.find((item) => item.slug === slug)
+  const [content, setContent] = useState<any>(null)
+  const [manage, setManage] = useState<any>(null)
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  const [invite, setInvite] = useState('')
+  const [inviteQrCode, setInviteQrCode] = useState('')
+  const [inviteCopied, setInviteCopied] = useState(false)
+  const [createdLogin, setCreatedLogin] = useState<{ phone: string; password: string } | null>(null)
+  const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
+  const [editingMemberName, setEditingMemberName] = useState('')
+  const [savingMemberName, setSavingMemberName] = useState(false)
+  const [showAudit, setShowAudit] = useState(false)
+  const [courseSortField, setCourseSortField] = useState<CourseSortField>('updated_at')
+  const [courseSortOrder, setCourseSortOrder] = useState<SortOrder>('desc')
+
+  async function load() {
+    if (!organization) return
+    try {
+      const [c, m] = await Promise.all([api.organizations.content(organization.id), api.organizations.manage(organization.id)])
+      setContent(c); setManage(m)
+    } catch (e) { setError((e as Error).message) }
+  }
+  useEffect(() => { load() }, [organization?.id])
+  const activeInvite = invite || (manage?.inviteToken ? `${window.location.origin}/join/${manage.inviteToken}` : '')
+  useEffect(() => {
+    if (!activeInvite) { setInviteQrCode(''); return }
+    QRCode.toDataURL(activeInvite, { width: 176, margin: 1, errorCorrectionLevel: 'M' })
+      .then(setInviteQrCode)
+      .catch(() => setInviteQrCode(''))
+  }, [activeInvite])
+  const sortedCourses = useMemo(() => {
+    if (!content?.courses) return []
+    return [...content.courses].sort((a: any, b: any) => {
+      const valA = new Date(courseSortField === 'updated_at' ? (a.updated_at || a.created_at) : a.created_at).getTime() || 0
+      const valB = new Date(courseSortField === 'updated_at' ? (b.updated_at || b.created_at) : b.created_at).getTime() || 0
+      return courseSortOrder === 'desc' ? valB - valA : valA - valB
+    })
+  }, [content?.courses, courseSortField, courseSortOrder])
+
+  if (!user) return <Navigate to="/" replace />
+  if (!organization || organization.role !== 'MANAGER') return <div className="page">没有组织管理权限</div>
+  if (courseId) return <div className="org-manage-shell"><header className="admin-topbar"><Link to={`/organizations/${slug}/manage`}>← {organization.name} · 组织管理</Link></header>
+    <AdminCourseDetail backTo={`/organizations/${slug}/manage`} /></div>
+
+  async function createUser(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const form = new FormData(e.currentTarget)
+    try {
+      const result = await api.organizations.createUser(organization!.id, String(form.get('phone')), String(form.get('contactName')))
+      setMessage(`已创建 ${result.user.contactName}:手机号 ${result.user.phone},初始密码 ${result.initialPassword}`)
+      setCreatedLogin({ phone: result.user.phone, password: result.initialPassword })
+      ;(e.currentTarget as HTMLFormElement).reset()
+      await load()
+    } catch (x) { setError((x as Error).message) }
+  }
+  async function review(id: string, approve: boolean) {
+    try { await api.organizations.review(organization!.id, id, approve); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function rotateInvite() {
+    if (manage?.inviteActive && !confirm('生成新链接会立即停用旧链接,继续吗?')) return
+    try {
+      const result = await api.organizations.rotateInvite(organization!.id)
+      setInvite(`${window.location.origin}/join/${result.token}`)
+      await load()
+    } catch (x) { setError((x as Error).message) }
+  }
+  async function copyInvite() {
+    try {
+      await navigator.clipboard.writeText(activeInvite)
+      setInviteCopied(true)
+      window.setTimeout(() => setInviteCopied(false), 1800)
+    } catch { setError('复制失败,请手动复制链接') }
+  }
+  async function saveMemberName(memberId: string) {
+    const contactName = editingMemberName.trim()
+    if (!contactName) { setError('请填写姓名'); return }
+    setSavingMemberName(true)
+    setError('')
+    try {
+      await api.organizations.updateMemberName(organization!.id, memberId, contactName)
+      setEditingMemberId(null)
+      setMessage('姓名已更新')
+      await load()
+    } catch (x) { setError((x as Error).message) } finally { setSavingMemberName(false) }
+  }
+
+  function toggleCourseSort(field: CourseSortField) {
+    if (courseSortField === field) {
+      setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
+    } else {
+      setCourseSortField(field)
+      setCourseSortOrder('desc')
+    }
+  }
+
+  return <div className="org-manage-shell">
+    <header className="admin-topbar org-manage-topbar">
+      <Link className="org-manage-back" to={`/organizations/${slug}`} aria-label="返回机构主页">← 返回机构主页</Link>
+    </header>
+    <main className="page admin-page">
+      <div className="admin-header-row"><div><h1>{organization.name} · 机构设置</h1><p>成员、课程与组织账单</p></div></div>
+    {error && <div className="notice notice-error">{error}</div>}
+    {message && <div className="notice notice-success">{message}</div>}
+    {createdLogin && <div className="org-created-login"><span>请将登录信息交给新老师,并提醒首次登录修改密码。</span>
+      <button className="secondary-button small-button" onClick={() => navigator.clipboard.writeText(`手机号:${createdLogin.phone}\n初始密码:${createdLogin.password}`)}>复制登录信息</button></div>}
+    {!content || !manage ? <div className="center-loading">正在加载组织数据…</div> : <>
+      {!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>
+      <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>
+        <button className="primary-button" disabled={!organization.isActive}>创建账号(初始密码 888888)</button>
+      </form></section>
+      <section className="admin-section-block"><h2>免审批注册链接</h2><p>持有链接的人注册或登录后,可直接加入本组织。</p>
+        <div className="org-invite-actions"><button className="secondary-button" onClick={rotateInvite} disabled={!organization.isActive}>{manage.inviteActive ? '重新生成并停用旧链接' : '生成注册链接'}</button>
+        {manage.inviteActive && <button className="secondary-button" onClick={async () => {
+          if (!confirm('停用后,已发出的注册链接将无法再加入组织。')) return
+          await api.organizations.disableInvite(organization.id); setInvite(''); await load()
+        }}>停用当前链接</button>}</div>
+        {activeInvite && <div className="org-invite-display">
+          <div className="org-invite-link"><label htmlFor="organization-invite-link">当前注册链接</label><div><input id="organization-invite-link" readOnly value={activeInvite} /><button className="secondary-button small-button" onClick={copyInvite}>{inviteCopied ? '已复制' : '复制链接'}</button></div></div>
+          {inviteQrCode && <div className="org-invite-qr"><img src={inviteQrCode} alt="注册链接二维码" /><span>扫码打开注册链接</span></div>}
+        </div>}
+        {manage.inviteActive && !activeInvite && <p className="org-invite-legacy-note">当前有效链接生成于此前版本,出于安全原因无法还原。请重新生成链接后,即可展示并复制二维码。</p>}
+      </section>
+      <section className="admin-section-block"><h2>待审批申请</h2>
+        {manage.applications.filter((a: any) => a.status === 'PENDING').length === 0 ? <p>暂无待审批申请</p> :
+          manage.applications.filter((a: any) => a.status === 'PENDING').map((a: any) => <div className="org-list-row" key={a.id}>
+            <span>{a.contact_name || a.phone} · {a.phone}</span><div><button className="primary-button small-button" onClick={() => review(a.id, true)}>同意</button>
+              <button className="secondary-button small-button" onClick={() => review(a.id, false)}>拒绝</button></div></div>)}
+      </section>
+      <section className="admin-section-block">
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>课程管理</h2>
+          <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+            <label style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</label>
+            <select
+              style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+              value={`${courseSortField}-${courseSortOrder}`}
+              onChange={(e) => {
+                const [f, o] = e.target.value.split('-') as [CourseSortField, SortOrder]
+                setCourseSortField(f)
+                setCourseSortOrder(o)
+              }}
+            >
+              <option value="updated_at-desc">最后更新时间(最新优先)</option>
+              <option value="updated_at-asc">最后更新时间(最早优先)</option>
+              <option value="created_at-desc">创建时间(最新优先)</option>
+              <option value="created_at-asc">创建时间(最早优先)</option>
+            </select>
+          </div>
+        </div>
+        <div className="admin-table-wrapper">
+          <table className="admin-table">
+            <thead>
+              <tr>
+                <th>课程</th>
+                <th>老师</th>
+                <th>状态</th>
+                <th>
+                  <button
+                    type="button"
+                    style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                    onClick={() => toggleCourseSort('updated_at')}
+                    title="点击切换按最后更新时间排序"
+                  >
+                    最后更新时间 {courseSortField === 'updated_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                  </button>
+                </th>
+                <th>
+                  <button
+                    type="button"
+                    style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                    onClick={() => toggleCourseSort('created_at')}
+                    title="点击切换按创建时间排序"
+                  >
+                    创建时间 {courseSortField === 'created_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                  </button>
+                </th>
+                <th>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {sortedCourses.length === 0 ? (
+                <tr>
+                  <td colSpan={6} style={{ textAlign: 'center', color: '#64748b', padding: '24px' }}>
+                    暂无组织课程
+                  </td>
+                </tr>
+              ) : (
+                sortedCourses.map((c: any) => (
+                  <tr key={c.id}>
+                    <td><strong>{c.name}</strong></td>
+                    <td>{c.creator_name || '未填写'}</td>
+                    <td><StatusBadge status={c.status as CourseStatus} /></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#334155' }}>{formatDateTime(c.updated_at || c.created_at)}</span></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#64748b' }}>{formatDateTime(c.created_at)}</span></td>
+                    <td>
+                      <div className="action-button-group">
+                        <Link className="secondary-button small-button" to={`/organizations/${slug}/manage/courses/${c.id}`}>制作与积分</Link>
+                        <Link className="secondary-button small-button" to={`/courses/${c.id}`} onClick={() => selectWorkspace(organization.id)}>代修改内容</Link>
+                        <button className="secondary-button small-button" onClick={async () => {
+                          if (!confirm(`删除课程「${c.name}」及其素材?已结算积分不会自动退回,账单和操作记录会保留。`)) return
+                          selectWorkspace(organization.id)
+                          try { await api.deleteCourse(c.id); await load() } catch (x) { setError((x as Error).message) }
+                        }}>删除</button>
+                      </div>
+                    </td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </section>
+      <section className="admin-section-block"><h2>人员名单</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>姓名</th><th>手机号</th><th>组织身份</th><th>操作</th></tr></thead><tbody>
+        {manage.members.map((m: any) => <tr key={m.id}><td>{editingMemberId === m.id ? <input aria-label={`${m.phone} 的姓名`} value={editingMemberName} maxLength={80} autoFocus onChange={(e) => setEditingMemberName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveMemberName(m.id); if (e.key === 'Escape') setEditingMemberId(null) }} /> : (m.contact_name || '未填写')}</td><td>{m.phone}</td><td>{m.role === 'MANAGER' ? '组织管理员' : '成员'}</td><td>{editingMemberId === m.id ? <div className="action-button-group"><button className="primary-button small-button" disabled={savingMemberName} onClick={() => saveMemberName(m.id)}>{savingMemberName ? '保存中…' : '保存'}</button><button className="secondary-button small-button" disabled={savingMemberName} onClick={() => setEditingMemberId(null)}>取消</button></div> : <button className="secondary-button small-button" onClick={() => { setEditingMemberId(m.id); setEditingMemberName(m.contact_name || '') }}>修改姓名</button>}</td></tr>)}
+      </tbody></table></div></section>
+      <section className="admin-section-block"><h2>组织积分流水</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>时间</th><th>事项</th><th>老师</th><th>积分变化</th></tr></thead><tbody>
+        {manage.ledger.map((l: any) => <tr key={l.id}><td>{new Date(l.created_at).toLocaleString('zh-CN')}</td><td>{l.reason}</td><td>{l.beneficiary_name || '组织账户'}</td><td>{l.amount}</td></tr>)}
+      </tbody></table></div></section>
+      <section className="admin-section-block">
+        <div
+          style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
+          onClick={() => setShowAudit((prev) => !prev)}
+        >
+          <h2 style={{ margin: 0 }}>操作记录</h2>
+          <button
+            type="button"
+            className="secondary-button small-button"
+            onClick={(e) => {
+              e.stopPropagation()
+              setShowAudit((prev) => !prev)
+            }}
+          >
+            {showAudit ? '收起记录' : '展开记录'}
+          </button>
+        </div>
+        {showAudit && (
+          <div style={{ marginTop: '16px' }}>
+            {manage.audit?.length === 0 ? (
+              <p style={{ color: '#64748b', marginTop: '12px' }}>暂无操作记录</p>
+            ) : (
+              manage.audit.map((a: any) => (
+                <details className="org-audit-event" key={a.id}>
+                  <summary>{a.actor_name || '系统'} · {a.action} · {new Date(a.created_at).toLocaleString('zh-CN')}</summary>
+                  <pre>{JSON.stringify(a.details, null, 2)}</pre>
+                </details>
+              ))
+            )}
+          </div>
+        )}
+      </section>
+    </>}
+    </main>
+  </div>
+}

+ 263 - 0
src/pages/OrganizationPage.tsx

@@ -0,0 +1,263 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'
+import { api } from '../api'
+import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { StatusBadge } from '../components/StatusBadge'
+import { formatDateTime } from '../utils/date'
+import type { CourseStatus } from '../types'
+
+export function OrganizationPage() {
+  const { slug = '' } = useParams()
+  const { user } = useAuth()
+  const { organizations } = useWorkspace()
+  const [content, setContent] = useState<any>(null)
+  const [orgInfo, setOrgInfo] = useState<{ id: string; name: string; isActive: boolean } | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [applying, setApplying] = useState(false)
+  const [message, setMessage] = useState('')
+  const [error, setError] = useState('')
+  const [courseSortField, setCourseSortField] = useState<'updated_at' | 'created_at'>('updated_at')
+  const [courseSortOrder, setCourseSortOrder] = useState<'desc' | 'asc'>('desc')
+
+  useEffect(() => {
+    if (!user || !slug) return
+    let cancelled = false
+    setLoading(true)
+    setError('')
+    api.organizations.publicInfo(slug)
+      .then(async ({ organization }) => {
+        if (cancelled) return
+        if (!organization) {
+          setError('组织不存在')
+          setLoading(false)
+          return
+        }
+        setOrgInfo(organization)
+        const isMember = organizations.some((item) => item.id === organization.id)
+        if (isMember) {
+          try {
+            const data = await api.organizations.content(organization.id)
+            if (!cancelled) setContent(data)
+          } catch (e) {
+            if (!cancelled) setError((e as Error).message)
+          }
+        }
+        if (!cancelled) setLoading(false)
+      })
+      .catch((e) => {
+        if (!cancelled) {
+          setError(e.message)
+          setLoading(false)
+        }
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [slug, user, organizations])
+
+  const sortedCourses = useMemo(() => {
+    if (!content?.courses) return []
+    return [...content.courses].sort((a: any, b: any) => {
+      const valA = new Date(courseSortField === 'updated_at' ? (a.updated_at || a.created_at) : a.created_at).getTime() || 0
+      const valB = new Date(courseSortField === 'updated_at' ? (b.updated_at || b.created_at) : b.created_at).getTime() || 0
+      return courseSortOrder === 'desc' ? valB - valA : valA - valB
+    })
+  }, [content?.courses, courseSortField, courseSortOrder])
+
+  if (!user) {
+    return <Navigate to="/" replace />
+  }
+
+  async function apply() {
+    if (!orgInfo) return
+    setApplying(true)
+    setError('')
+    try {
+      await api.organizations.apply(slug)
+      setMessage('申请已提交,等待组织管理员审批')
+    } catch (e) {
+      setError((e as Error).message)
+    } finally {
+      setApplying(false)
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="page org-page">
+        <div className="center-loading">正在加载组织信息…</div>
+      </div>
+    )
+  }
+
+  if (error && !orgInfo) {
+    return (
+      <div className="page org-page">
+        <div className="notice notice-error">{error}</div>
+      </div>
+    )
+  }
+
+  // 已登录但不属于组织:仅展示组织名称和申请加入
+  if (!content) {
+    return (
+      <div className="page org-page org-guest-page">
+        <div className="org-apply-card">
+          <div className="org-apply-header">
+            <h1 className="org-title">{orgInfo?.name || '组织'}</h1>
+          </div>
+          {error && <div className="notice notice-error">{error}</div>}
+          {message && <div className="notice notice-success">{message}</div>}
+          <div className="org-apply-actions">
+            {orgInfo?.isActive ? (
+              <button
+                type="button"
+                className="primary-button"
+                onClick={apply}
+                disabled={applying || !!message}
+              >
+                {applying ? '正在提交申请…' : message ? '申请已提交' : '申请加入'}
+              </button>
+            ) : (
+              <p className="org-inactive-text">该组织已停用</p>
+            )}
+          </div>
+        </div>
+      </div>
+    )
+  }
+
+  function toggleCourseSort(field: 'updated_at' | 'created_at') {
+    if (courseSortField === field) {
+      setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
+    } else {
+      setCourseSortField(field)
+      setCourseSortOrder('desc')
+    }
+  }
+
+  // 已属于组织:展示完整组织页面
+  return (
+    <div className="page org-page">
+      <div className="admin-header-row">
+        <div>
+          <h1>{content.organization.name}</h1>
+          <p>组织课程与成员</p>
+        </div>
+        <div className="action-button-group">
+          <Link className="secondary-button" to="/">
+            返回工作台
+          </Link>
+          {content.organization.role === 'MANAGER' && (
+            <Link className="primary-button" to={`/organizations/${slug}/manage`}>
+              机构设置
+            </Link>
+          )}
+        </div>
+      </div>
+
+      <div className="admin-section-block">
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>课程进度</h2>
+          {content.courses.length > 1 && (
+            <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
+              <label style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</label>
+              <select
+                style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
+                value={`${courseSortField}-${courseSortOrder}`}
+                onChange={(e) => {
+                  const [f, o] = e.target.value.split('-') as ['updated_at' | 'created_at', 'desc' | 'asc']
+                  setCourseSortField(f)
+                  setCourseSortOrder(o)
+                }}
+              >
+                <option value="updated_at-desc">最后更新时间(最新优先)</option>
+                <option value="updated_at-asc">最后更新时间(最早优先)</option>
+                <option value="created_at-desc">创建时间(最新优先)</option>
+                <option value="created_at-asc">创建时间(最早优先)</option>
+              </select>
+            </div>
+          )}
+        </div>
+        {sortedCourses.length === 0 ? (
+          <p>暂无课程</p>
+        ) : (
+          <div className="admin-table-wrapper">
+            <table className="admin-table">
+              <thead>
+                <tr>
+                  <th>课程</th>
+                  <th>创建老师</th>
+                  <th>进度</th>
+                  <th>
+                    <button
+                      type="button"
+                      style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                      onClick={() => toggleCourseSort('updated_at')}
+                      title="点击切换按最后更新时间排序"
+                    >
+                      最后更新时间 {courseSortField === 'updated_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                    </button>
+                  </th>
+                  <th>
+                    <button
+                      type="button"
+                      style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
+                      onClick={() => toggleCourseSort('created_at')}
+                      title="点击切换按创建时间排序"
+                    >
+                      创建时间 {courseSortField === 'created_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
+                    </button>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                {sortedCourses.map((course: any) => (
+                  <tr key={course.id}>
+                    <td><strong>{course.name}</strong></td>
+                    <td>{course.creator_name || '未填写'}</td>
+                    <td>
+                      <StatusBadge status={course.status as CourseStatus} />
+                    </td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#334155' }}>{formatDateTime(course.updated_at || course.created_at)}</span></td>
+                    <td><span style={{ fontSize: '0.85rem', color: '#64748b' }}>{formatDateTime(course.created_at)}</span></td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+
+      <div className="admin-section-block">
+        <h2>人员名单</h2>
+        <div className="org-member-grid">
+          {content.members.map((member: any) => (
+            <span key={member.id}>
+              {member.contact_name || '未填写姓名'}
+              {member.role === 'MANAGER' ? ' · 管理员' : ''}
+            </span>
+          ))}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function InviteJoinPage() {
+  const { token = '' } = useParams()
+  const { user } = useAuth()
+  const { refreshOrganizations } = useWorkspace()
+  const navigate = useNavigate()
+  const [error, setError] = useState('')
+  useEffect(() => {
+    if (!user) return
+    api.organizations.join(token).then(async () => { await refreshOrganizations(); navigate('/') }).catch((e) => setError(e.message))
+  }, [token, user?.id])
+  if (!user) return <div className="page org-page"><h1>加入组织</h1><p>登录或注册后,将通过此链接直接加入组织。</p>
+    <Link className="primary-button" to={`/login?mode=register&invite=${encodeURIComponent(token)}`}>注册并加入</Link>
+    <Link className="secondary-button" to={`/login?invite=${encodeURIComponent(token)}`}>已有账号,登录加入</Link></div>
+  return <div className="page org-page">{error ? <div className="notice notice-error">{error}</div> : '正在加入组织…'}</div>
+}

+ 45 - 24
src/pages/Profile.tsx

@@ -1,43 +1,46 @@
 import { FormEvent, useEffect, useState } from 'react'
-import { ArrowLeft, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
+import { ArrowLeft, Building2, ChevronRight, Coins, KeyRound, LoaderCircle, Save, UserRound } from 'lucide-react'
 import { Link, useSearchParams } from 'react-router-dom'
 import { api } from '../api'
 import { useAuth } from '../AuthContext'
 import type { PointsTransaction } from '../types'
+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 }
+  { id: 'points', label: '积分账单', description: '余额与积分流水', icon: Coins }
 ]
 
 export function Profile() {
   const { user, setUser } = useAuth()
+  const { currentOrganization } = useWorkspace()
   const [searchParams, setSearchParams] = useSearchParams()
   const requestedTab = searchParams.get('tab')
-  const activeTab: ProfileTab = tabs.some((tab) => tab.id === requestedTab)
+  const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
     ? (requestedTab as ProfileTab)
     : 'profile'
   const [message, setMessage] = useState('')
   const [error, setError] = useState('')
   const [transactions, setTransactions] = useState<PointsTransaction[]>([])
   const [pointsLoading, setPointsLoading] = useState(false)
+  const [displayBalance, setDisplayBalance] = useState<number | null>(null)
 
   useEffect(() => {
     if (activeTab !== 'points') return
     setPointsLoading(true)
-    setError('')
-    api
-      .getPointsTransactions()
+    setError('');
+    (currentOrganization ? api.organizations.points(currentOrganization.id) : api.getPointsTransactions())
       .then((result) => {
         setTransactions(result.transactions)
-        if (user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
+        setDisplayBalance(result.balance)
+        if (!currentOrganization && user && result.balance !== user.pointsBalance) setUser({ ...user, pointsBalance: result.balance })
       })
       .catch((reason) => setError((reason as Error).message))
       .finally(() => setPointsLoading(false))
-  }, [activeTab])
+  }, [activeTab, currentOrganization?.id])
 
   if (!user) return null
 
@@ -54,7 +57,6 @@ export function Profile() {
     setMessage('')
     try {
       const result = await api.updateMe({
-        organization: String(form.get('organization')),
         wechat: String(form.get('wechat')),
         contactName: String(form.get('contactName')),
         bio: String(form.get('bio'))
@@ -75,6 +77,7 @@ export function Profile() {
     if (form.get('newPassword') !== form.get('confirm')) return setError('两次输入的新密码不一致')
     try {
       await api.changePassword(String(form.get('currentPassword')), String(form.get('newPassword')))
+      setUser({ ...user!, mustChangePassword: false })
       formElement.reset()
       setMessage('密码修改成功')
     } catch (reason) {
@@ -92,7 +95,7 @@ export function Profile() {
             <div><strong>{user.contactName || '我的账户'}</strong><span>{user.phone}</span></div>
           </div>
           <nav aria-label="个人中心菜单">
-            {tabs.map(({ id, label, description, icon: Icon }) => (
+            {tabs.filter((tab) => !user.mustChangePassword || tab.id === 'security').map(({ id, label, description, icon: Icon }) => (
               <button key={id} className={activeTab === id ? 'active' : ''} onClick={() => changeTab(id)}>
                 <Icon size={18} />
                 <span><strong>{label}</strong><small>{description}</small></span>
@@ -105,12 +108,9 @@ export function Profile() {
         <main className="profile-content">
           {activeTab === 'profile' && (
             <form className="paper-form profile-panel" onSubmit={save}>
-              <div className="form-title"><span>USER PROFILE</span><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
+              <div className="form-title"><h2>用户信息</h2><p>管理您的基本资料和联系方式</p></div>
               <label>注册手机号<input value={user.phone} disabled /></label>
-              <div className="two-columns">
-                <label>联系人<input name="contactName" defaultValue={user.contactName} placeholder="怎么称呼您" /></label>
-                <label>单位名称<input name="organization" defaultValue={user.organization} placeholder="请输入单位名称" /></label>
-              </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>
@@ -119,7 +119,8 @@ export function Profile() {
 
           {activeTab === 'security' && (
             <form className="paper-form profile-panel profile-security" onSubmit={password}>
-              <div className="form-title"><span>SECURITY</span><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
+              <div className="form-title"><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
+              {user.mustChangePassword && <div className="notice notice-error">这是组织管理员创建的初始密码。请先设置自己的密码,再进入工作区。</div>}
               <label>当前密码<input name="currentPassword" type="password" required /></label>
               <label>新密码<input name="newPassword" type="password" minLength={6} required placeholder="至少 6 位" /></label>
               <label>确认新密码<input name="confirm" type="password" minLength={6} required /></label>
@@ -129,26 +130,46 @@ export function Profile() {
 
           {activeTab === 'points' && (
             <section className="paper-form profile-panel points-history-panel">
-              <div className="form-title"><span>POINTS</span><h2>积分记录</h2><p>查看当前积分余额与历史消费明细</p></div>
+              <div className="form-title points-form-title">
+                <div>
+                  <div className="points-heading-wrap">
+                    <h2>{currentOrganization ? `${currentOrganization.name} · 积分账单` : '个人积分账单'}</h2>
+                    {currentOrganization && (
+                      <span className="points-org-pill">
+                        <Building2 size={13} />
+                        {currentOrganization.role === 'MANAGER' ? '组织管理员' : '组织成员'}
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <p>{currentOrganization?.role === 'MANAGER' ? '组织完整积分流水' : currentOrganization ? '组织余额与本人课程消耗' : '个人积分余额与流水'}</p>
+              </div>
               <div className="points-wallet">
                 <div className="points-wallet-icon"><Coins size={25} /></div>
-                <div><span>当前可用积分</span><strong>{user.pointsBalance.toLocaleString('zh-CN')}</strong><small>积分</small></div>
+                <div>
+                  <span>{currentOrganization ? `${currentOrganization.name} · 组织可用积分` : '个人可用积分'}</span>
+                  <strong>{(displayBalance ?? currentOrganization?.pointsBalance ?? user.pointsBalance).toLocaleString('zh-CN')}</strong>
+                  <small>积分</small>
+                </div>
+              </div>
+              <div className="points-history-heading">
+                <h3>{currentOrganization ? `${currentOrganization.name} · 积分流水` : '积分流水'}</h3>
+                <span>共 {transactions.length} 笔</span>
               </div>
-              <div className="points-history-heading"><h3>消费记录</h3><span>共 {transactions.length} 笔</span></div>
               {pointsLoading ? (
                 <div className="points-empty"><LoaderCircle className="spin" size={22} />正在加载消费记录…</div>
               ) : transactions.length ? (
                 <div className="points-transaction-list">
                   {transactions.map((transaction) => (
-                    <Link to={`/courses/${transaction.courseId}`} className="points-transaction" key={transaction.id}>
+                    <div className="points-transaction" key={transaction.id}>
                       <div className="transaction-icon"><Coins size={17} /></div>
-                      <div className="transaction-main"><strong>{transaction.courseName}</strong><span>课程制作完成扣费</span></div>
+                      <div className="transaction-main"><strong>{transaction.courseName}</strong><span>{transaction.points < 0 ? currentOrganization ? '组织账户支付课程费用' : '个人账户支付课程费用' : '积分入账'}</span></div>
                       <div className="transaction-value"><strong>{transaction.points.toLocaleString('zh-CN')}</strong><time>{new Date(transaction.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</time></div>
-                    </Link>
+                    </div>
                   ))}
                 </div>
               ) : (
-                <div className="points-empty"><Coins size={24} /><strong>暂无积分消费记录</strong><span>课程制作完成并交付后,消费明细会显示在这里</span></div>
+                <div className="points-empty"><Coins size={24} /><strong>暂无积分流水</strong><span>课程制作完成或积分入账后,记录会显示在这里</span></div>
               )}
             </section>
           )}

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

@@ -31,7 +31,8 @@ import {
 } from 'lucide-react'
 import { FormEvent, useEffect, useRef, useState } from 'react'
 import { Link, useParams } from 'react-router-dom'
-import { api } from '../../api'
+import { api, downloadPrivateFile } from '../../api'
+import { ProtectedImage } from '../../components/ProtectedImage'
 import { StatusBadge } from '../../components/StatusBadge'
 import { CourseProductionProgress } from '../../components/CourseProductionProgress'
 import type { Course, CourseStatus } from '../../types'
@@ -39,7 +40,7 @@ import type { Course, CourseStatus } from '../../types'
 const size = (n: number) =>
   n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
 
-export function AdminCourseDetail() {
+export function AdminCourseDetail({ backTo = '/admin/courses' }: { backTo?: string }) {
   const { id = '' } = useParams()
   const [course, setCourse] = useState<Course | null>(null)
   const [loading, setLoading] = useState(true)
@@ -202,7 +203,7 @@ export function AdminCourseDetail() {
   return (
     <div className="page admin-page">
       <div className="admin-breadcrumb-bar">
-        <Link to="/admin/courses" className="back-link">
+        <Link to={backTo} className="back-link">
           <ArrowLeft size={16} /> 返回课程制作列表
         </Link>
         <span className="breadcrumb-id">ID: {course.id}</span>
@@ -311,9 +312,9 @@ export function AdminCourseDetail() {
                 <Building2 size={18} />
               </div>
               <div className="field-content">
-                <span className="field-label">单位 / 企业机构</span>
-                <strong className="field-value-text" title={creator?.organization || '个人创作者'}>
-                  {creator?.organization || '个人创作者'}
+                <span className="field-label">课程所属工作区</span>
+                <strong className="field-value-text" title={course.organizationName || '个人工作区'}>
+                  {course.organizationName || '个人工作区'}
                 </strong>
               </div>
             </div>
@@ -421,7 +422,7 @@ export function AdminCourseDetail() {
             </div>
           </div>
           <p className="points-card-note">
-            制作完成后系统将自动从用户账户中扣除【实际消耗积分】。管理员可根据实际制作工作量及复杂度调整最终扣除额度。
+            制作完成后系统将自动从课程所属工作区账户中扣除【实际消耗积分】。管理员可根据实际制作工作量及复杂度调整最终扣除额度。
           </p>
         </div>
 
@@ -515,15 +516,12 @@ export function AdminCourseDetail() {
                                 <small>{size(deliv.size)} · 成品文件</small>
                               </div>
                               <div className="deliv-actions">
-                                <a
-                                  href={deliv.url}
-                                  download={deliv.originalName}
+                                <button
+                                  onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                                   className="secondary-button small-button"
-                                  target="_blank"
-                                  rel="noreferrer"
                                 >
                                   <Download size={13} /> 下载
-                                </a>
+                                </button>
                                 <button
                                   type="button"
                                   className="icon-button delete-button"
@@ -557,15 +555,12 @@ export function AdminCourseDetail() {
                               <small>{size(deliv.size)} · 全套通用交付物</small>
                             </div>
                             <div className="deliv-actions">
-                              <a
-                                href={deliv.url}
-                                download={deliv.originalName}
+                              <button
+                                onClick={() => downloadPrivateFile(deliv.url, deliv.originalName)}
                                 className="secondary-button small-button"
-                                target="_blank"
-                                rel="noreferrer"
                               >
                                 <Download size={13} /> 下载
-                              </a>
+                              </button>
                               <button
                                 type="button"
                                 className="icon-button delete-button"
@@ -795,15 +790,12 @@ export function AdminCourseDetail() {
                                       <strong title={asset.originalName}>{asset.originalName}</strong>
                                       <small>{size(asset.size)}</small>
                                     </div>
-                                    <a
-                                      href={asset.url}
-                                      download={asset.originalName}
+                                    <button
+                                      onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
                                       className="secondary-button small-button download-mini-btn"
-                                      target="_blank"
-                                      rel="noreferrer"
                                     >
                                       <Download size={12} /> 下载
-                                    </a>
+                                    </button>
                                   </div>
                                 ))}
                               </div>
@@ -829,15 +821,12 @@ export function AdminCourseDetail() {
                         <strong title={asset.originalName}>{asset.originalName}</strong>
                         <small>{size(asset.size)} · 全套通用素材</small>
                       </div>
-                      <a
-                        href={asset.url}
-                        download={asset.originalName}
+                      <button
+                        onClick={() => downloadPrivateFile(asset.url, asset.originalName)}
                         className="primary-button small-button"
-                        target="_blank"
-                        rel="noreferrer"
                       >
                         <Download size={14} /> 下载素材
-                      </a>
+                      </button>
                     </div>
                   ))}
                 </div>
@@ -867,7 +856,7 @@ export function AdminCourseDetail() {
                   {creator?.contactName || '未填写联系人'}
                 </strong>
                 <span className="sidebar-user-org">
-                  {creator?.organization || '个人创作者'}
+                  {course.organizationName || '个人工作区'}
                 </span>
                 <span className="sidebar-user-role">
                   {creator?.role === 'ADMIN' ? '平台管理员' : '普通学员 / 创作者'}
@@ -896,8 +885,8 @@ export function AdminCourseDetail() {
                 <strong>{creator?.wechat || '未填写'}</strong>
               </div>
               <div className="contact-item">
-                <span className="contact-label">所属单位:</span>
-                <strong>{creator?.organization || '未填写'}</strong>
+                <span className="contact-label">课程工作区:</span>
+                <strong>{course.organizationName || '个人工作区'}</strong>
               </div>
               {creator?.bio && (
                 <div className="contact-item flex-col">
@@ -976,7 +965,7 @@ export function AdminCourseDetail() {
                     {inst.imageUrls?.length ? (
                       <div className="admin-instructor-photos">
                         {inst.imageUrls.map((url, index) => (
-                          <img
+                          <ProtectedImage
                             src={url}
                             alt={`${inst.name} 出镜照 ${index + 1}`}
                             className="inst-photo"

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

@@ -1,5 +1,4 @@
 import {
-  ArrowRight,
   Building2,
   Clock3,
   FileText,
@@ -11,12 +10,11 @@ import {
   Search,
   User,
   Users,
-  Wrench
 } from 'lucide-react'
 import { useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
 import { api } from '../../api'
 import { StatusBadge } from '../../components/StatusBadge'
+import { formatDate, formatDateTime } from '../../utils/date'
 import type { Course, CourseStatus } from '../../types'
 
 type StatusFilter = 'ALL' | CourseStatus
@@ -50,8 +48,8 @@ export function AdminCourses() {
     <div className="page admin-page">
       <div className="admin-header-row">
         <div>
-          <h2>课程制作管理</h2>
-          <p className="admin-subtitle">管理全平台用户创建并提交的课程,跟踪制作排期与交付进度</p>
+          <h2>全部课程</h2>
+          <p className="admin-subtitle">查看全平台课程及其当前状态</p>
         </div>
         <button className="secondary-button" onClick={loadCourses} title="刷新列表">
           <RotateCw size={15} /> 刷新
@@ -101,7 +99,7 @@ export function AdminCourses() {
             <input
               value={keyword}
               onChange={(e) => setKeyword(e.target.value)}
-              placeholder="搜索课程名、用户手机、姓名或单位…"
+              placeholder="搜索课程名、用户手机或姓名…"
             />
           </div>
           <button type="submit" className="secondary-button">
@@ -129,8 +127,8 @@ export function AdminCourses() {
                 <th style={{ minWidth: 200 }}>提交制作用户信息</th>
                 <th>素材 / 讲师 / 交付</th>
                 <th>当前状态</th>
+                <th>创建 / 更新时间</th>
                 <th>提交 / 交付时间</th>
-                <th style={{ textAlign: 'right' }}>操作</th>
               </tr>
             </thead>
             <tbody>
@@ -162,8 +160,8 @@ export function AdminCourses() {
                         <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 className="user-org-text" title={course.organizationName || '个人工作区'}>
+                          <Building2 size={12} /> {course.organizationName || '个人工作区'}
                         </span>
                         <span className="user-phone-text">
                           <Phone size={12} /> {course.creator?.phone || '无手机号'}
@@ -189,6 +187,16 @@ export function AdminCourses() {
                   <td>
                     <StatusBadge status={course.status} />
                   </td>
+                  <td>
+                    <div className="time-cell">
+                      <small title={`创建时间:${formatDateTime(course.createdAt)}`}>
+                        建: {formatDate(course.createdAt)}
+                      </small>
+                      <small title={`最后更新时间:${formatDateTime(course.updatedAt || course.createdAt)}`} style={{ color: '#0f172a', fontWeight: 500 }}>
+                        更: {formatDateTime(course.updatedAt || course.createdAt)}
+                      </small>
+                    </div>
+                  </td>
                   <td>
                     <div className="time-cell">
                       <small title="提交时间">
@@ -201,11 +209,6 @@ export function AdminCourses() {
                       )}
                     </div>
                   </td>
-                  <td style={{ textAlign: 'right' }}>
-                    <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
-                      <Wrench size={13} /> 制作管理
-                    </Link>
-                  </td>
                 </tr>
               ))}
             </tbody>

+ 5 - 5
src/pages/admin/AdminDashboard.tsx

@@ -46,7 +46,7 @@ export function AdminDashboard() {
           <p className="admin-subtitle">监控平台用户动态与课程制作全周期流转状态</p>
         </div>
         <div className="admin-header-actions">
-          <Link to="/admin/courses" className="primary-button">
+          <Link to="/admin/production-queue" className="primary-button">
             <Wrench size={16} />
             <span>进入制作工作台</span>
           </Link>
@@ -114,14 +114,14 @@ export function AdminDashboard() {
           <div>
             <h3>待制作与进行中课程</h3>
           </div>
-          <Link to="/admin/courses" className="text-link">
+          <Link to="/admin/production-queue" className="text-link">
             查看全部课程 <ArrowRight size={14} />
           </Link>
         </div>
 
         {pendingCourses.length === 0 ? (
           <div className="empty-state compact-empty">
-            <Sparkles size={32} style={{ color: '#245bd6', marginBottom: 8 }} />
+            <Sparkles size={32} style={{ color: '#0875bd', marginBottom: 8 }} />
             <h3>当前没有待处理的制作任务</h3>
             <p>当用户提交新课程后,系统会自动汇入此制作队列中。</p>
           </div>
@@ -179,8 +179,8 @@ export function AdminDashboard() {
                       </small>
                     </td>
                     <td style={{ textAlign: 'right' }}>
-                      <Link to={`/admin/courses/${course.id}`} className="primary-button small-button">
-                        进入制作 <ArrowRight size={13} />
+                      <Link to="/admin/production-queue" className="secondary-button small-button">
+                        查看待制作队列 <ArrowRight size={13} />
                       </Link>
                     </td>
                   </tr>

+ 50 - 0
src/pages/admin/AdminDigitalInstructors.tsx

@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import { Link, useLocation, useSearchParams } from 'react-router-dom'
+import { api } from '../../api'
+import { ProtectedImage } from '../../components/ProtectedImage'
+import type { DigitalInstructor } from '../../types'
+
+type Org = { id: string; name: string }
+export function AdminDigitalInstructors() {
+  const [searchParams, setSearchParams] = useSearchParams()
+  const location = useLocation()
+  const orgId = searchParams.get('organizationId') || ''
+  const [orgs, setOrgs] = useState<Org[]>([])
+  const [items, setItems] = useState<DigitalInstructor[]>([])
+  const [error, setError] = useState('')
+  const [notice, setNotice] = useState((location.state as { notice?: string } | null)?.notice || '')
+  const [busy, setBusy] = useState(false)
+  const [deleteTarget, setDeleteTarget] = useState<DigitalInstructor | null>(null)
+  useEffect(() => { api.admin.organizations().then(result => setOrgs(result.organizations)).catch(cause => setError((cause as Error).message)) }, [])
+  async function load(id: string) {
+    if (!id) { setItems([]); return }
+    try { setItems((await api.digitalInstructors.list(id)).instructors) }
+    catch (cause) { setError((cause as Error).message) }
+  }
+  useEffect(() => { load(orgId) }, [orgId])
+  async function duplicate(item: DigitalInstructor) {
+    setBusy(true); setError('')
+    try { const result = await api.digitalInstructors.duplicate(item.id,orgId); setNotice(`已复制为「${result.instructor.name}」`); await load(orgId) }
+    catch (cause) { setError((cause as Error).message) }
+    finally { setBusy(false) }
+  }
+  async function remove() {
+    if (!deleteTarget) return
+    setBusy(true); setError('')
+    try { await api.digitalInstructors.delete(deleteTarget.id,orgId); setNotice('数字讲师已删除'); setDeleteTarget(null); await load(orgId) }
+    catch (cause) { setError((cause as Error).message); setDeleteTarget(null) }
+    finally { setBusy(false) }
+  }
+  return <div className="page digital-library-page"><div className="digital-library-heading"><div><h1>组织数字讲师</h1><p>查看并维护组织的数字讲师资产</p></div></div>
+    <div className="digital-library-toolbar"><select aria-label="选择组织" value={orgId} onChange={event => setSearchParams(event.target.value ? { organizationId: event.target.value } : {})}><option value="">选择组织</option>{orgs.map(org => <option key={org.id} value={org.id}>{org.name}</option>)}</select></div>
+    {error && <div className="notice notice-error" role="alert">{error}</div>}{notice && <div className="notice notice-success">{notice}</div>}
+    {orgId && !items.length && <div className="digital-library-empty">该组织还没有数字讲师。</div>}
+    <div className="digital-library-grid">{items.map(item => <article className="digital-library-card digital-asset-card" key={item.id}>
+      <div className="digital-asset-photo">{item.images[0] ? <ProtectedImage src={item.images[0].url} alt={item.name} /> : <span>暂无照片</span>}</div>
+      <div className="digital-asset-body"><div className="digital-asset-title"><h2>{item.name}</h2><span className={item.ready ? 'digital-state-ready' : 'digital-state-incomplete'}>{item.ready ? '素材齐备' : '待补齐'}</span></div><p className="digital-asset-note">{item.note || '暂无备注'}</p><p className="digital-asset-meta">所有者:{item.ownerName} · 创建者:{item.creatorName}</p><p className="digital-asset-meta">创建于 {new Date(item.createdAt).toLocaleString('zh-CN')}</p>{item.usageCount > 0 && <p className="digital-asset-meta">用于 {item.usageCount} 门课程,暂不能删除</p>}
+        <div className="digital-asset-actions"><Link className="secondary-button" to={`/admin/digital-instructors/${item.id}/edit?organizationId=${encodeURIComponent(orgId)}`}>编辑</Link><details className="digital-more-menu"><summary className="secondary-button">更多操作</summary><div><button type="button" onClick={() => duplicate(item)} disabled={busy}>复制讲师</button><button type="button" className="digital-danger-action" disabled={busy || item.usageCount > 0} onClick={() => setDeleteTarget(item)}>{item.usageCount > 0 ? '已有课程使用,不能删除' : '删除讲师'}</button></div></details></div>
+      </div>
+    </article>)}</div>
+    {deleteTarget && <div className="digital-confirm-backdrop" role="presentation"><div className="digital-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="admin-digital-delete-title"><h2 id="admin-digital-delete-title">删除数字讲师?</h2><p>确定删除「{deleteTarget.name}」吗?</p><div><button type="button" className="secondary-button" onClick={() => setDeleteTarget(null)} disabled={busy}>取消</button><button type="button" className="primary-button digital-danger-button" onClick={remove} disabled={busy}>确认删除</button></div></div></div>}
+  </div>
+}

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

@@ -1,5 +1,6 @@
 import {
   BookOpen,
+  Clock3,
   LayoutDashboard,
   LogOut,
   ShieldCheck,
@@ -46,12 +47,17 @@ export function AdminLayout() {
           </NavLink>
           <NavLink to="/admin/courses" className={({ isActive }) => (isActive ? 'active' : '')}>
             <BookOpen size={16} />
-            <span>课程制作</span>
+            <span>全部课程</span>
+          </NavLink>
+          <NavLink to="/admin/production-queue" className={({ isActive }) => (isActive ? 'active' : '')}>
+            <Clock3 size={16} /><span>待制作课程</span>
           </NavLink>
           <NavLink to="/admin/users" className={({ isActive }) => (isActive ? 'active' : '')}>
             <Users size={16} />
             <span>用户管理</span>
           </NavLink>
+          <NavLink to="/admin/organizations" className={({ isActive }) => (isActive ? 'active' : '')}>组织管理</NavLink>
+          <NavLink to="/admin/digital-instructors" className={({ isActive }) => (isActive ? 'active' : '')}>数字讲师</NavLink>
         </nav>
 
         <div className="admin-top-actions">

+ 122 - 0
src/pages/admin/AdminOrganizations.tsx

@@ -0,0 +1,122 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { api } from '../../api'
+import { formatDateTime } from '../../utils/date'
+
+export function AdminOrganizations() {
+  const [organizations, setOrganizations] = useState<any[]>([])
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  async function load() { try { setOrganizations((await api.admin.organizations()).organizations) } catch (e) { setError((e as Error).message) } }
+  useEffect(() => { load() }, [])
+  async function create(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const form = new FormData(e.currentTarget)
+    try { await api.admin.createOrganization(String(form.get('name')), String(form.get('slug'))); setMessage('组织已创建'); (e.currentTarget as HTMLFormElement).reset(); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  return <div className="page admin-page"><div className="admin-header-row"><div><h2>组织管理</h2><p>创建组织并管理成员与积分</p></div></div>
+    {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}
+    <section className="admin-section-block"><h3>创建组织</h3><form onSubmit={create} className="org-inline-form">
+      <label>组织名称<input name="name" required maxLength={120} placeholder="杭州市计算机学校" /></label>
+      <label>网址标识<input name="slug" required pattern="[a-z0-9-]{3,80}" placeholder="hangzhou-computer-school" /></label>
+      <button className="primary-button">创建组织</button></form></section>
+    <section className="admin-section-block"><h3>全部组织</h3><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>名称</th><th>状态</th><th>成员</th><th>积分余额</th><th>操作</th></tr></thead><tbody>
+      {organizations.map((o) => <tr key={o.id}><td>{o.name}</td><td>{o.is_active ? '正常' : '已停用'}</td><td>{o.members_count}</td><td>{o.points_balance}</td><td><Link className="secondary-button small-button" to={`/admin/organizations/${o.id}`}>管理</Link></td></tr>)}
+    </tbody></table></div></section></div>
+}
+
+export function AdminOrganizationDetail() {
+  const { id = '' } = useParams()
+  const [data, setData] = useState<any>(null)
+  const [users, setUsers] = useState<any[]>([])
+  const [error, setError] = useState('')
+  const [message, setMessage] = useState('')
+  const [showAudit, setShowAudit] = useState(false)
+  async function load() { try { setData(await api.admin.organization(id)) } catch (x) { setError((x as Error).message) } }
+  useEffect(() => { load() }, [id])
+  async function setMember(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const f = new FormData(e.currentTarget)
+    const phone = String(f.get('phone'))
+    try {
+      const person = (await api.admin.listUsers(phone)).users.find((u) => u.phone === phone)
+      if (!person) return setError('未找到该手机号对应的用户')
+      await api.admin.setOrganizationMember(id, person.id, String(f.get('role')) as 'MEMBER' | 'MANAGER')
+      setMessage('成员关系已更新'); await load()
+    }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function search(phone: string) { try { setUsers((await api.admin.listUsers(phone)).users) } catch (x) { setError((x as Error).message) } }
+  async function review(applicationId: string, approve: boolean) { try { await api.admin.reviewOrganizationApplication(id, applicationId, approve); await load() } catch (x) { setError((x as Error).message) } }
+  async function points(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.adjustOrganizationPoints(id, Number(f.get('amount')), String(f.get('reason'))); setMessage('积分已调整'); await load() }
+    catch (x) { setError((x as Error).message) }
+  }
+  async function rename(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.renameOrganization(id, String(f.get('name'))); setMessage('组织名称已更新'); await load() } catch (x) { setError((x as Error).message) } }
+  async function updateSlug(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const f = new FormData(e.currentTarget)
+    try { await api.admin.updateOrganizationSlug(id, String(f.get('slug'))); setMessage('公开页面访问后缀已更新'); await load() } catch (x) { setError((x as Error).message) } }
+  if (!data) return <div className="page admin-page">{error || '正在加载组织…'}</div>
+  return <div className="page admin-page"><Link className="back-link" to="/admin/organizations">← 全部组织</Link><h2>{data.organization.name} · {data.organization.is_active ? '正常' : '已停用'}</h2>
+    {error && <div className="notice notice-error">{error}</div>}{message && <div className="notice notice-success">{message}</div>}
+    <section className="admin-section-block"><h3>组织资料</h3>
+      <form className="org-inline-form" onSubmit={rename}><label>组织名称<input name="name" defaultValue={data.organization.name} required maxLength={120} /></label><button className="secondary-button">保存名称</button></form>
+      <form className="org-inline-form" onSubmit={updateSlug}><label>URL 访问后缀<input key={data.organization.slug} name="slug" defaultValue={data.organization.slug} required pattern="[a-z0-9-]{3,80}" title="3-80位小写字母、数字或连字符(-)" placeholder="pure-cosmos" /></label><button className="secondary-button">保存后缀</button></form>
+      <p>公开页面:<a href={`/organizations/${data.organization.slug}`} target="_blank" rel="noreferrer">{window.location.origin}/organizations/{data.organization.slug}</a></p>
+      <button className="secondary-button" onClick={async () => {
+        if (!confirm(data.organization.is_active ? '停用后,新用户不能加入且不能新建组织课程,已有记录保留。继续吗?' : '恢复该组织的新加入和课程创建?')) return
+        try { await api.admin.setOrganizationActive(id, !data.organization.is_active); await load() } catch (x) { setError((x as Error).message) }
+      }}>{data.organization.is_active ? '停用组织' : '恢复组织'}</button></section>
+    <section className="admin-section-block"><h3>组织积分账户 · {data.organization.points_balance} 积分</h3><form className="org-inline-form" onSubmit={points}>
+      <label>积分变化(扣减填负数)<input name="amount" type="number" required step="1" /></label><label>原因<input name="reason" required maxLength={200} /></label><button className="primary-button">调整积分</button></form>
+      {data.ledger.map((l: any) => <div className="org-list-row" key={l.id}><span>{l.reason} · {l.amount > 0 ? '+' : ''}{l.amount}</span><time>{new Date(l.created_at).toLocaleString('zh-CN')}</time></div>)}
+    </section>
+    <section className="admin-section-block"><h3>加入申请</h3>{data.applications.filter((a: any) => a.status === 'PENDING').map((a: any) => <div className="org-list-row" key={a.id}><span>{a.contact_name || a.phone} · {a.phone}</span><div><button className="primary-button small-button" onClick={() => review(a.id, true)}>同意</button><button className="secondary-button small-button" onClick={() => review(a.id, false)}>拒绝</button></div></div>)}</section>
+    <section className="admin-section-block"><h3>组织课程</h3>{data.courses.map((course: any) => <div className="org-list-row" key={course.id}>
+      <span style={{ display: 'inline-flex', flexDirection: 'column', gap: '2px' }}>
+        <strong>{course.name} · {course.creator_name || '未填姓名'} · {course.status}</strong>
+        <small style={{ color: '#64748b' }}>创建于 {formatDateTime(course.created_at)} · 更新于 {formatDateTime(course.updated_at || course.created_at)}</small>
+      </span>
+      <Link className="secondary-button small-button" to="/admin/courses">查看全部课程</Link></div>)}</section>
+    <section className="admin-section-block"><h3>成员与管理员</h3><form className="org-inline-form" onSubmit={setMember}>
+      <label>用户手机号<input name="phone" required pattern="1[0-9]{10}" onChange={(e) => e.target.value.length === 11 && search(e.target.value)} /></label>
+      <label>组织身份<select name="role"><option value="MEMBER">普通成员</option><option value="MANAGER">组织管理员</option></select></label><button className="primary-button">添加或更新</button></form>
+      {users.length > 0 && <p>匹配用户:{users.map((u) => `${u.contactName || '未填姓名'} ${u.phone}`).join('、')}</p>}
+      {data.members.map((m: any) => <div className="org-list-row" key={m.id}><span>{m.contact_name || '未填姓名'} · {m.phone} · {m.role === 'MANAGER' ? '组织管理员' : '成员'}</span><div>
+        <button className="secondary-button small-button" onClick={async () => { await api.admin.setOrganizationMember(id, m.id, m.role === 'MANAGER' ? 'MEMBER' : 'MANAGER'); await load() }}>{m.role === 'MANAGER' ? '撤销管理员' : '设为管理员'}</button>
+        <button className="secondary-button small-button" onClick={async () => { if (!confirm(`移除 ${m.contact_name || m.phone} 的组织成员身份?`)) return; await api.admin.removeOrganizationMember(id, m.id); await load() }}>移出组织</button></div></div>)}
+    </section>
+    <section className="admin-section-block">
+      <div
+        style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
+        onClick={() => setShowAudit((prev) => !prev)}
+      >
+        <h3 style={{ margin: 0 }}>组织操作日志</h3>
+        <button
+          type="button"
+          className="secondary-button small-button"
+          onClick={(e) => {
+            e.stopPropagation()
+            setShowAudit((prev) => !prev)
+          }}
+        >
+          {showAudit ? '收起日志' : '展开日志'}
+        </button>
+      </div>
+      {showAudit && (
+        <div style={{ marginTop: '16px' }}>
+          {data.audit.length === 0 ? (
+            <p style={{ color: '#64748b', marginTop: '12px' }}>暂无操作日志</p>
+          ) : (
+            data.audit.map((a: any) => (
+              <details className="org-audit-event" key={a.id}>
+                <summary>{a.actor_name || '系统'} · {a.action} · {new Date(a.created_at).toLocaleString('zh-CN')}</summary>
+                <pre>{JSON.stringify(a.details, null, 2)}</pre>
+              </details>
+            ))
+          )}
+        </div>
+      )}
+    </section>
+  </div>
+}

+ 104 - 0
src/pages/admin/AdminProductionQueue.tsx

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

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

@@ -18,11 +18,9 @@ import {
 import { useEffect, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { api } from '../../api'
-import { useAuth } from '../../AuthContext'
 import type { User } from '../../types'
 
 export function AdminUsers() {
-  const { user: currentUser } = useAuth()
   const [users, setUsers] = useState<User[]>([])
   const [loading, setLoading] = useState(true)
   const [keyword, setKeyword] = useState('')
@@ -67,29 +65,6 @@ export function AdminUsers() {
     loadUsers()
   }
 
-  const handleToggleRole = async (targetUser: User) => {
-    const nextRole = targetUser.role === 'ADMIN' ? 'USER' : 'ADMIN'
-    const confirmText =
-      nextRole === 'ADMIN'
-        ? `确定要将用户【${targetUser.contactName || targetUser.phone}】提升为管理员吗?`
-        : `确定要撤销用户【${targetUser.contactName || targetUser.phone}】的管理员权限吗?`
-
-    if (!confirm(confirmText)) return
-
-    setBusyId(targetUser.id)
-    setError('')
-    setSuccessMsg('')
-    try {
-      await api.admin.updateUserRole(targetUser.id, nextRole)
-      setSuccessMsg(`用户 ${targetUser.phone} 权限已更新为:${nextRole === 'ADMIN' ? '管理员' : '普通用户'}`)
-      loadUsers()
-    } catch (err: any) {
-      setError(err.message || '更新角色失败')
-    } finally {
-      setBusyId(null)
-    }
-  }
-
   const handleCreateUser = async (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault()
     const form = new FormData(e.currentTarget)
@@ -102,8 +77,7 @@ export function AdminUsers() {
     try {
       await api.admin.createUser({
         ...login,
-        contactName: String(form.get('contactName') || '').trim(),
-        organization: String(form.get('organization') || '').trim()
+        contactName: String(form.get('contactName') || '').trim()
       })
       setCreatedLogin(login)
       setSuccessMsg(`用户 ${login.phone} 创建成功`)
@@ -182,7 +156,7 @@ export function AdminUsers() {
             <input
               value={keyword}
               onChange={(e) => setKeyword(e.target.value)}
-              placeholder="搜索手机号、姓名、单位或微信…"
+              placeholder="搜索手机号、姓名或微信…"
             />
           </div>
           <button type="submit" className="secondary-button">
@@ -209,7 +183,7 @@ export function AdminUsers() {
             <thead>
               <tr>
                 <th>用户姓名 / 手机号</th>
-                <th>所属单位 / 微信号</th>
+                <th>所属组织 / 微信号</th>
                 <th>用户角色</th>
                 <th>课程统计</th>
                 <th>注册时间</th>
@@ -227,20 +201,10 @@ export function AdminUsers() {
                     </div>
                   </td>
                   <td>
-                    <span>{u.organization || '未填写单位'}</span>
+                    <span>{u.organization || '未加入组织'}</span>
                     <small className="sub-text">微信号:{u.wechat || '未填写'}</small>
                   </td>
-                  <td>
-                    {u.role === 'ADMIN' ? (
-                      <span className="role-tag role-admin">
-                        <ShieldCheck size={13} /> 管理员
-                      </span>
-                    ) : (
-                      <span className="role-tag role-user">
-                        <UserCheck size={13} /> 普通创作者
-                      </span>
-                    )}
-                  </td>
+                  <td><span className="role-tag role-user"><UserCheck size={13} /> 普通用户</span></td>
                   <td>
                     <div className="badge-group">
                       <span className="badge-count" title="总课程数">
@@ -280,19 +244,6 @@ export function AdminUsers() {
                         {busyId === u.id ? '处理中…' : '重置密码'}
                       </button>
 
-                      {/* 权限切换按钮 */}
-                      {u.id !== currentUser?.id && (
-                        <button
-                          type="button"
-                          disabled={busyId === u.id}
-                          className={`small-button ${
-                            u.role === 'ADMIN' ? 'secondary-button' : 'primary-button'
-                          }`}
-                          onClick={() => handleToggleRole(u)}
-                        >
-                          {u.role === 'ADMIN' ? '降为普通' : '设为管理员'}
-                        </button>
-                      )}
                     </div>
                   </td>
                 </tr>
@@ -345,7 +296,6 @@ export function AdminUsers() {
                   </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">