DigitalInstructorCreateForm.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import { useEffect, useRef, useState } from 'react'
  2. import { api } from '../api'
  3. import { ProtectedImage } from './ProtectedImage'
  4. import { ProtectedAudio } from './ProtectedAudio'
  5. import type { DigitalInstructor } from '../types'
  6. type Props = {
  7. workspaceName: string
  8. organizationNames: string[] | null
  9. onCreated: () => void
  10. onCancel: () => void
  11. existing?: DigitalInstructor
  12. workspaceId?: string
  13. ownerOptions?: { id: string; contact_name: string }[]
  14. canManageOwner?: boolean
  15. }
  16. const imageTypes = ['image/jpeg', 'image/png', 'image/webp']
  17. const audioTypes = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a', 'audio/webm', 'audio/ogg']
  18. const maxImages = 19
  19. const readingScript = '大家好,欢迎来到今天的课程。接下来,我会用清晰、自然的语速介绍本节内容。学习时可以先了解整体思路,再结合具体例子逐步练习。遇到不明白的地方,请暂停思考,之后继续学习。希望这段讲解能帮助大家掌握新的知识。'
  20. export function DigitalInstructorCreateForm({ workspaceName, organizationNames, onCreated, onCancel, existing, workspaceId, ownerOptions, canManageOwner }: Props) {
  21. const [name, setName] = useState(existing?.name || '')
  22. const [note, setNote] = useState(existing?.note || '')
  23. const [noteOpen, setNoteOpen] = useState(!!existing?.note)
  24. const [images, setImages] = useState<File[]>([])
  25. const [audio, setAudio] = useState<File | null>(null)
  26. const [removedImageIds, setRemovedImageIds] = useState<string[]>([])
  27. const [coverChoice, setCoverChoice] = useState(existing?.images[0] ? `existing:${existing.images[0].id}` : '')
  28. const [ownerId, setOwnerId] = useState(existing?.ownerId || '')
  29. const [imageUrls, setImageUrls] = useState<string[]>([])
  30. const [audioUrl, setAudioUrl] = useState('')
  31. const [preview, setPreview] = useState<{ src: string; alt: string; protected: boolean } | null>(null)
  32. const [error, setError] = useState('')
  33. const [busy, setBusy] = useState(false)
  34. const [recordingStatus, setRecordingStatus] = useState<'idle' | 'requesting' | 'countdown' | 'recording' | 'processing'>('idle')
  35. const [recordingSeconds, setRecordingSeconds] = useState(0)
  36. const [countdown, setCountdown] = useState(3)
  37. const [photoGuideOpen, setPhotoGuideOpen] = useState(false)
  38. const recorderRef = useRef<MediaRecorder | null>(null)
  39. const streamRef = useRef<MediaStream | null>(null)
  40. const recordingStartedAtRef = useRef(0)
  41. const discardRecordingRef = useRef(false)
  42. const mountedRef = useRef(true)
  43. useEffect(() => {
  44. mountedRef.current = true
  45. return () => {
  46. mountedRef.current = false
  47. discardRecordingRef.current = true
  48. if (recorderRef.current?.state === 'recording') recorderRef.current.stop()
  49. streamRef.current?.getTracks().forEach(track => track.stop())
  50. }
  51. }, [])
  52. useEffect(() => {
  53. if (recordingStatus !== 'recording') return
  54. const timer = window.setInterval(() => setRecordingSeconds(Math.floor((Date.now() - recordingStartedAtRef.current) / 1000)), 250)
  55. return () => window.clearInterval(timer)
  56. }, [recordingStatus])
  57. useEffect(() => {
  58. if (recordingStatus !== 'countdown') return
  59. if (countdown === 0) {
  60. try {
  61. if (recorderRef.current?.state === 'inactive') recorderRef.current.start(1000)
  62. recordingStartedAtRef.current = Date.now()
  63. setRecordingSeconds(0)
  64. setRecordingStatus('recording')
  65. } catch {
  66. streamRef.current?.getTracks().forEach(track => track.stop())
  67. streamRef.current = null
  68. recorderRef.current = null
  69. setRecordingStatus('idle')
  70. setError('录音无法开始,请重试或上传录音文件。')
  71. }
  72. return
  73. }
  74. const timer = window.setTimeout(() => setCountdown(value => value - 1), 1000)
  75. return () => window.clearTimeout(timer)
  76. }, [recordingStatus, countdown])
  77. useEffect(() => {
  78. const urls = images.map(file => URL.createObjectURL(file))
  79. setImageUrls(urls)
  80. return () => urls.forEach(url => URL.revokeObjectURL(url))
  81. }, [images])
  82. useEffect(() => {
  83. if (!audio) { setAudioUrl(''); return }
  84. const url = URL.createObjectURL(audio)
  85. setAudioUrl(url)
  86. return () => URL.revokeObjectURL(url)
  87. }, [audio])
  88. useEffect(() => {
  89. if (!preview && !photoGuideOpen) return
  90. const close = (event: KeyboardEvent) => { if (event.key === 'Escape') { setPreview(null); setPhotoGuideOpen(false) } }
  91. window.addEventListener('keydown', close)
  92. return () => window.removeEventListener('keydown', close)
  93. }, [preview, photoGuideOpen])
  94. const duplicate = !!organizationNames?.some(otherName => otherName.trim().toLocaleLowerCase() === name.trim().toLocaleLowerCase())
  95. const retainedImages = existing?.images.filter(image => !removedImageIds.includes(image.id)) || []
  96. function chooseImages(files: FileList | null) {
  97. if (!files?.length) return
  98. const picked = Array.from(files)
  99. if (picked.some(file => !imageTypes.includes(file.type) || file.size === 0 || file.size > 20 * 1024 * 1024)) {
  100. setError('照片仅支持 JPG、PNG、WebP;每张不超过 20 MB,且不能为空。')
  101. return
  102. }
  103. const next = [...images]
  104. for (const file of picked) {
  105. if (!next.some(existing => existing.name === file.name && existing.size === file.size && existing.lastModified === file.lastModified)) next.push(file)
  106. }
  107. if (next.length + retainedImages.length > maxImages) { setError(`最多保留 ${maxImages} 张照片。`); return }
  108. setError('')
  109. setImages(next)
  110. }
  111. function chooseAudio(file?: File) {
  112. if (!file) return
  113. if (!audioTypes.includes(file.type) || file.size === 0 || file.size > 50 * 1024 * 1024) {
  114. setError('录音仅支持 WAV、MP3、M4A、WebM、Ogg;文件不超过 50 MB,且不能为空。')
  115. return
  116. }
  117. setError('')
  118. setAudio(file)
  119. }
  120. async function startRecording() {
  121. if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
  122. setError('当前浏览器无法直接录音,请使用上传录音文件。')
  123. return
  124. }
  125. setError('')
  126. setRecordingStatus('requesting')
  127. try {
  128. const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
  129. if (!mountedRef.current) { stream.getTracks().forEach(track => track.stop()); return }
  130. const mimeType = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']
  131. .find(type => MediaRecorder.isTypeSupported(type))
  132. const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
  133. const chunks: BlobPart[] = []
  134. streamRef.current = stream
  135. recorderRef.current = recorder
  136. discardRecordingRef.current = false
  137. recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data) }
  138. recorder.onerror = () => {
  139. discardRecordingRef.current = true
  140. stream.getTracks().forEach(track => track.stop())
  141. if (mountedRef.current) { setRecordingStatus('idle'); setError('录音中断,请重试或上传录音文件。') }
  142. }
  143. recorder.onstop = () => {
  144. stream.getTracks().forEach(track => track.stop())
  145. streamRef.current = null
  146. recorderRef.current = null
  147. if (!mountedRef.current) return
  148. setRecordingStatus('idle')
  149. if (discardRecordingRef.current) return
  150. const type = recorder.mimeType.split(';')[0] || 'audio/webm'
  151. const extension = type === 'audio/mp4' ? 'm4a' : type === 'audio/ogg' ? 'ogg' : 'webm'
  152. const blob = new Blob(chunks, { type })
  153. chooseAudio(new File([blob], `讲师录音-${Date.now()}.${extension}`, { type }))
  154. }
  155. setCountdown(3)
  156. setRecordingStatus('countdown')
  157. } catch {
  158. streamRef.current?.getTracks().forEach(track => track.stop())
  159. streamRef.current = null
  160. if (mountedRef.current) { setRecordingStatus('idle'); setError('麦克风未启用或无法访问。请允许麦克风权限,或上传已有录音文件。') }
  161. }
  162. }
  163. function stopRecording(discard = false) {
  164. discardRecordingRef.current = discard
  165. if (recordingStatus === 'countdown') {
  166. streamRef.current?.getTracks().forEach(track => track.stop())
  167. streamRef.current = null
  168. recorderRef.current = null
  169. setRecordingStatus('idle')
  170. return
  171. }
  172. if (recorderRef.current?.state === 'recording') {
  173. setRecordingStatus('processing')
  174. recorderRef.current.stop()
  175. }
  176. }
  177. async function save() {
  178. if (!name.trim()) { setError('请先填写讲师姓名。'); return }
  179. if (duplicate) { setError('这个组织已有同名讲师,请换一个名字。'); return }
  180. if (recordingStatus !== 'idle') { setError('请先结束录音,再保存讲师。'); return }
  181. setBusy(true); setError('')
  182. try {
  183. if (existing) {
  184. await api.digitalInstructors.manage(existing.id, {
  185. name: name.trim(), note: note.trim(), ownerId: canManageOwner ? (ownerId || undefined) : undefined, images, audio, removeImageIds: removedImageIds,
  186. coverImageId: coverChoice.startsWith('existing:') ? coverChoice.slice(9) : undefined,
  187. coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined
  188. }, workspaceId)
  189. } else await api.digitalInstructors.create({ name: name.trim(), note: note.trim(), images, audio, coverNewIndex: coverChoice.startsWith('new:') ? Number(coverChoice.slice(4)) : undefined })
  190. onCreated()
  191. } catch (cause) { setError((cause as Error).message) }
  192. finally { setBusy(false) }
  193. }
  194. return <section className="digital-create-panel" aria-label={existing ? '编辑数字讲师' : '创建数字讲师'}>
  195. <div className="digital-create-intro"><div><span className="digital-create-eyebrow">{existing ? '编辑讲师' : '新建讲师'} · {workspaceName}</span><h2>{existing ? '编辑数字讲师' : '创建数字讲师'}</h2><p>按顺序填写资料、准备照片和录音。素材可以稍后补齐,提交课程制作前需要两项齐备。</p></div></div>
  196. {error && <div className="notice notice-error" role="alert">{error}</div>}
  197. <div className="digital-create-steps">
  198. <section className="digital-step"><div className="digital-step-title"><span>01</span><div><h3>讲师资料</h3><p>用一个容易辨认的名称管理这位讲师。</p></div></div>
  199. <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>
  200. {organizationNames && <p className={duplicate ? 'digital-field-error' : 'digital-field-hint'}>{duplicate ? '这个组织已有同名讲师,请换一个名字。' : '同一组织中的讲师不能重名。'}</p>}
  201. <button type="button" className="digital-note-toggle" aria-expanded={noteOpen} onClick={() => setNoteOpen(open => !open)}>{noteOpen ? '▾ 收起备注' : `+ 添加备注${note ? ' · 已填写' : '(选填)'}`}</button>
  202. {noteOpen && <label className="digital-field">备注 <span>选填,仅用于内部记录</span><textarea value={note} onChange={event => setNote(event.target.value)} maxLength={1000} placeholder="例如:适合讲解哪类课程、后续需要补充什么素材" rows={3} /></label>}
  203. {existing && (existing.organizationId || workspaceId !== 'personal') && (
  204. canManageOwner && ownerOptions && ownerOptions.length > 0 ? (
  205. <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>
  206. ) : (
  207. <label className="digital-field">所有者<input type="text" value={existing.ownerName === '未知用户' ? '历史导入' : (existing.ownerName || '未知用户')} disabled readOnly /><span className="digital-field-hint">默认所有者为创建者本人,仅管理员可修改</span></label>
  208. )
  209. )}</div>
  210. </section>
  211. <section className="digital-step"><div className="digital-step-title"><span>02</span><div><h3>上传参考照片</h3><p>建议使用清晰的正面半身照,便于生成稳定的数字人形象。</p></div></div>
  212. <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>
  213. <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>
  214. <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>
  215. {(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>
  216. </section>
  217. <section className="digital-step"><div className="digital-step-title"><span>03</span><div><h3>上传声音录音</h3><p>请上传讲师本人连续、自然的人声录音。</p></div></div>
  218. <div className="digital-step-body"><div className="digital-guidance"><strong>录音时留意</strong><ul><li>在安静环境录制,关好门窗,避开空调、风扇和明显回声。</li><li>距麦克风约 15–20 厘米,不喷麦、不捂麦;照着下面的文字自然朗读。录满 10 秒会提示时长达标,更长的连续录音通常更有利于后续声音处理。</li></ul></div>
  219. <div className="digital-reading-script"><div><strong>录音朗读稿</strong><span>开始录音后照着读,读完可继续自然讲述</span></div><p>{readingScript}</p></div>
  220. <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>
  221. <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>
  222. {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>}
  223. {!audio && existing?.audio[0] && <div className="digital-audio-preview"><strong>当前录音:{existing.audio[0].name}</strong><ProtectedAudio src={existing.audio[0].url} /></div>}
  224. </div>
  225. </section>
  226. </div>
  227. <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>
  228. {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>}
  229. {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>}
  230. </section>
  231. }