| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- 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>
- }
|