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([]) const [audio, setAudio] = useState(null) const [removedImageIds, setRemovedImageIds] = useState([]) const [coverChoice, setCoverChoice] = useState(existing?.images[0] ? `existing:${existing.images[0].id}` : '') const [ownerId, setOwnerId] = useState(existing?.ownerId || '') const [imageUrls, setImageUrls] = useState([]) 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(null) const streamRef = useRef(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
{existing ? '编辑讲师' : '新建讲师'} · {workspaceName}

{existing ? '编辑数字讲师' : '创建数字讲师'}

按顺序填写资料、准备照片和录音。素材可以稍后补齐,提交课程制作前需要两项齐备。

{error &&
{error}
}
01

讲师资料

用一个容易辨认的名称管理这位讲师。

{organizationNames &&

{duplicate ? '这个组织已有同名讲师,请换一个名字。' : '同一组织中的讲师不能重名。'}

} {noteOpen &&