|
@@ -20,7 +20,6 @@ import {
|
|
|
Trash2,
|
|
Trash2,
|
|
|
Upload,
|
|
Upload,
|
|
|
UserPlus,
|
|
UserPlus,
|
|
|
- X,
|
|
|
|
|
ZoomIn
|
|
ZoomIn
|
|
|
} from 'lucide-react'
|
|
} from 'lucide-react'
|
|
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
|
@@ -80,8 +79,8 @@ export function CourseWorkspace() {
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
|
}, [previewImage])
|
|
}, [previewImage])
|
|
|
|
|
|
|
|
- // 全局资产待上传
|
|
|
|
|
- const [globalFiles, setGlobalFiles] = useState<File[]>([])
|
|
|
|
|
|
|
+ const uploadInProgress = useRef(false)
|
|
|
|
|
+ const [uploadingTarget, setUploadingTarget] = useState<string | null>(null)
|
|
|
|
|
|
|
|
// 分集展开折叠状态 Map (episodeId -> boolean)
|
|
// 分集展开折叠状态 Map (episodeId -> boolean)
|
|
|
const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
|
|
const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
|
|
@@ -89,10 +88,6 @@ export function CourseWorkspace() {
|
|
|
const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
|
|
const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
|
|
|
const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
|
|
const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
|
|
|
|
|
|
|
|
- // 分集上传文件输入引用 Map (episodeId -> File[])
|
|
|
|
|
- const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
|
|
|
|
|
- const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
|
|
|
|
|
-
|
|
|
|
|
// 通用资产备注状态
|
|
// 通用资产备注状态
|
|
|
const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
|
|
const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
|
|
|
const [assetNotesDraft, setAssetNotesDraft] = useState('')
|
|
const [assetNotesDraft, setAssetNotesDraft] = useState('')
|
|
@@ -128,21 +123,27 @@ export function CourseWorkspace() {
|
|
|
setTimeout(() => setSuccessNotice(''), 4000)
|
|
setTimeout(() => setSuccessNotice(''), 4000)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 上传全局通用资产
|
|
|
|
|
- async function handleUploadGlobalAssets() {
|
|
|
|
|
- if (!globalFiles.length) return
|
|
|
|
|
- if (globalFiles.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
|
|
|
|
|
- setBusy(true)
|
|
|
|
|
|
|
+ // 选择后直接上传;同步锁避免同一轮渲染中重复选择导致响应互相覆盖。
|
|
|
|
|
+ async function handleUploadAssets(files: File[], episodeId?: string) {
|
|
|
|
|
+ if (!files.length || uploadInProgress.current) return
|
|
|
|
|
+ if (files.length > 20) { setError('每次最多上传 20 份文件,请分批选择'); return }
|
|
|
|
|
+ uploadInProgress.current = true
|
|
|
|
|
+ setUploadingTarget(episodeId || 'global')
|
|
|
setError('')
|
|
setError('')
|
|
|
try {
|
|
try {
|
|
|
- const res = await api.uploadAssets(id, globalFiles)
|
|
|
|
|
|
|
+ const res = await api.uploadAssets(id, files, episodeId)
|
|
|
setCourse(res.course)
|
|
setCourse(res.course)
|
|
|
- setGlobalFiles([])
|
|
|
|
|
- notify(res.notesImported ? `资产上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '全局资产上传成功')
|
|
|
|
|
|
|
+ notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : episodeId ? '分集素材上传成功' : '通用附件上传成功')
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- setError((e as Error).message)
|
|
|
|
|
|
|
+ const message = (e as Error).message
|
|
|
|
|
+ try {
|
|
|
|
|
+ const latest = await api.getCourse(id)
|
|
|
|
|
+ setCourse(latest.course)
|
|
|
|
|
+ } catch { /* Keep the upload error visible if refreshing also fails. */ }
|
|
|
|
|
+ setError(`上传失败:${message}。请核对素材列表,再选择未上传的文件重试。`)
|
|
|
} finally {
|
|
} finally {
|
|
|
- setBusy(false)
|
|
|
|
|
|
|
+ uploadInProgress.current = false
|
|
|
|
|
+ setUploadingTarget(null)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -182,25 +183,6 @@ export function CourseWorkspace() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 上传特定分集的专属资产
|
|
|
|
|
- async function handleUploadEpisodeAssets(episodeId: string) {
|
|
|
|
|
- const files = epFilesMap[episodeId] || []
|
|
|
|
|
- if (!files.length) return
|
|
|
|
|
- if (files.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
|
|
|
|
|
- setUploadingEpId(episodeId)
|
|
|
|
|
- setError('')
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await api.uploadAssets(id, files, episodeId)
|
|
|
|
|
- setCourse(res.course)
|
|
|
|
|
- setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
|
|
|
|
|
- notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '分集素材上传成功')
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- setError((e as Error).message)
|
|
|
|
|
- } finally {
|
|
|
|
|
- setUploadingEpId(null)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
// 保存分集讲稿正文
|
|
// 保存分集讲稿正文
|
|
|
async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
|
|
async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
|
|
|
setSavingNotesId(episode.id)
|
|
setSavingNotesId(episode.id)
|
|
@@ -717,7 +699,6 @@ export function CourseWorkspace() {
|
|
|
{episodes.map((ep) => {
|
|
{episodes.map((ep) => {
|
|
|
const isExpanded = !!expandedMap[ep.id]
|
|
const isExpanded = !!expandedMap[ep.id]
|
|
|
const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
|
|
const epAssets = course.assets?.filter((a) => a.episodeId === ep.id) || []
|
|
|
- const pendingFiles = epFilesMap[ep.id] || []
|
|
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className={'episode-card ' + (isExpanded ? 'expanded' : 'collapsed')} key={ep.id}>
|
|
<div className={'episode-card ' + (isExpanded ? 'expanded' : 'collapsed')} key={ep.id}>
|
|
@@ -787,18 +768,19 @@ export function CourseWorkspace() {
|
|
|
<strong>上传素材</strong>
|
|
<strong>上传素材</strong>
|
|
|
{epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
|
|
{epAssets.length > 0 && <span className="asset-count-label">{epAssets.length} 份</span>}
|
|
|
</div>
|
|
</div>
|
|
|
- <label className="ep-file-label compact-upload-button">
|
|
|
|
|
|
|
+ <label className="ep-file-label compact-upload-button" aria-disabled={!!uploadingTarget}>
|
|
|
<Upload size={14} />
|
|
<Upload size={14} />
|
|
|
- <span>添加文件(可多选)</span>
|
|
|
|
|
|
|
+ <span>{uploadingTarget === ep.id ? '正在上传…' : '添加文件(可多选)'}</span>
|
|
|
<input
|
|
<input
|
|
|
type="file"
|
|
type="file"
|
|
|
accept={assetAccept}
|
|
accept={assetAccept}
|
|
|
multiple
|
|
multiple
|
|
|
hidden
|
|
hidden
|
|
|
|
|
+ disabled={!!uploadingTarget}
|
|
|
onChange={(e) => {
|
|
onChange={(e) => {
|
|
|
const selected = Array.from(e.target.files || [])
|
|
const selected = Array.from(e.target.files || [])
|
|
|
- setEpFilesMap((prev) => ({ ...prev, [ep.id]: [...(prev[ep.id] || []), ...selected] }))
|
|
|
|
|
e.target.value = ''
|
|
e.target.value = ''
|
|
|
|
|
+ void handleUploadAssets(selected, ep.id)
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
</label>
|
|
</label>
|
|
@@ -826,41 +808,7 @@ export function CourseWorkspace() {
|
|
|
</div>
|
|
</div>
|
|
|
))}
|
|
))}
|
|
|
{!epAssets.length && (
|
|
{!epAssets.length && (
|
|
|
- <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频,支持一次多选和分次添加。</p>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* 待上传文件 */}
|
|
|
|
|
- <div className="ep-upload-zone">
|
|
|
|
|
- {pendingFiles.length > 0 && (
|
|
|
|
|
- <div className="ep-pending-files">
|
|
|
|
|
- <div className="pending-chips">
|
|
|
|
|
- {pendingFiles.map((f, i) => (
|
|
|
|
|
- <span key={i} className="pending-chip">
|
|
|
|
|
- {f.name} ({size(f.size)})
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- onClick={() =>
|
|
|
|
|
- setEpFilesMap((prev) => ({
|
|
|
|
|
- ...prev,
|
|
|
|
|
- [ep.id]: pendingFiles.filter((_, x) => x !== i)
|
|
|
|
|
- }))
|
|
|
|
|
- }
|
|
|
|
|
- >
|
|
|
|
|
- <X size={12} />
|
|
|
|
|
- </button>
|
|
|
|
|
- </span>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- className="primary-button small-button full-button"
|
|
|
|
|
- disabled={uploadingEpId === ep.id}
|
|
|
|
|
- onClick={() => handleUploadEpisodeAssets(ep.id)}
|
|
|
|
|
- >
|
|
|
|
|
- {uploadingEpId === ep.id ? '正在上传…' : ('上传 ' + pendingFiles.length + ' 份文件至本集')}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频;选择后自动上传,支持多选和分次添加。</p>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -1003,10 +951,10 @@ export function CourseWorkspace() {
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
|
|
|
|
|
|
|
+ <div className="drop-zone compact" aria-disabled={!!uploadingTarget} onClick={() => { if (!uploadingTarget) globalInputRef.current?.click() }}>
|
|
|
<FilePlus2 />
|
|
<FilePlus2 />
|
|
|
- <h3>添加通用附件(可多选)</h3>
|
|
|
|
|
- <small>支持 PPTX、PDF、Word、图片和视频;可分次添加,每次最多 20 份</small>
|
|
|
|
|
|
|
+ <h3>{uploadingTarget === 'global' ? '正在上传…' : '添加通用附件(可多选)'}</h3>
|
|
|
|
|
+ <small>支持 PPTX、PDF、Word、图片和视频;选择后自动上传,每次最多 20 份</small>
|
|
|
</div>
|
|
</div>
|
|
|
<input
|
|
<input
|
|
|
hidden
|
|
hidden
|
|
@@ -1014,32 +962,13 @@ export function CourseWorkspace() {
|
|
|
ref={globalInputRef}
|
|
ref={globalInputRef}
|
|
|
type="file"
|
|
type="file"
|
|
|
accept={assetAccept}
|
|
accept={assetAccept}
|
|
|
- onChange={(e) => { setGlobalFiles((prev) => [...prev, ...Array.from(e.target.files || [])]); e.target.value = '' }}
|
|
|
|
|
|
|
+ disabled={!!uploadingTarget}
|
|
|
|
|
+ onChange={(e) => {
|
|
|
|
|
+ const selected = Array.from(e.target.files || [])
|
|
|
|
|
+ e.target.value = ''
|
|
|
|
|
+ void handleUploadAssets(selected)
|
|
|
|
|
+ }}
|
|
|
/>
|
|
/>
|
|
|
-
|
|
|
|
|
- {globalFiles.length > 0 && (
|
|
|
|
|
- <div className="selected-stack">
|
|
|
|
|
- {globalFiles.map((f, i) => (
|
|
|
|
|
- <div className="selected-file" key={i}>
|
|
|
|
|
- <FileText />
|
|
|
|
|
- <div>
|
|
|
|
|
- <strong>{f.name}</strong>
|
|
|
|
|
- <span>{size(f.size)}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <button onClick={() => setGlobalFiles(globalFiles.filter((_, x) => x !== i))}>
|
|
|
|
|
- <X size={16} />
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- ))}
|
|
|
|
|
- <button
|
|
|
|
|
- className="primary-button full-button"
|
|
|
|
|
- disabled={busy}
|
|
|
|
|
- onClick={handleUploadGlobalAssets}
|
|
|
|
|
- >
|
|
|
|
|
- {busy ? '正在上传…' : ('上传 ' + globalFiles.length + ' 个全局文件')}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
</section>
|
|
</section>
|
|
|
</main>
|
|
</main>
|
|
|
|
|
|