|
@@ -26,6 +26,8 @@ import {
|
|
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
|
import { api, downloadPrivateFile } from '../api'
|
|
import { api, downloadPrivateFile } from '../api'
|
|
|
|
|
+import { useWorkspace } from '../WorkspaceContext'
|
|
|
|
|
+import { canPreviewAsset, CourseAssetPreview } from '../components/CourseAssetPreview'
|
|
|
import { ProtectedImage } from '../components/ProtectedImage'
|
|
import { ProtectedImage } from '../components/ProtectedImage'
|
|
|
import { InstructorVoicePlayer } from '../components/InstructorVoicePlayer'
|
|
import { InstructorVoicePlayer } from '../components/InstructorVoicePlayer'
|
|
|
import { StatusBadge } from '../components/StatusBadge'
|
|
import { StatusBadge } from '../components/StatusBadge'
|
|
@@ -35,9 +37,13 @@ import { DeleteCourseModal } from '../components/DeleteCourseModal'
|
|
|
import { ContactModal } from '../components/ContactModal'
|
|
import { ContactModal } from '../components/ContactModal'
|
|
|
import { EditCourseModal } from '../components/EditCourseModal'
|
|
import { EditCourseModal } from '../components/EditCourseModal'
|
|
|
import { CourseProductionProgress } from '../components/CourseProductionProgress'
|
|
import { CourseProductionProgress } from '../components/CourseProductionProgress'
|
|
|
|
|
+import { LectureNotesDialog } from '../components/LectureNotesDialog'
|
|
|
|
|
+import { notesHtml, notesText } from '../utils/lectureNotes'
|
|
|
import contactQrCode from '../assets/contact-group-qrcode.jpg'
|
|
import contactQrCode from '../assets/contact-group-qrcode.jpg'
|
|
|
import { formatDateTime } from '../utils/date'
|
|
import { formatDateTime } from '../utils/date'
|
|
|
-import type { Course, Episode } from '../types'
|
|
|
|
|
|
|
+import type { Course, CourseAsset, Episode } from '../types'
|
|
|
|
|
+
|
|
|
|
|
+const assetAccept = '.ppt,.pptx,.pdf,.doc,.docx,image/*,video/mp4,video/webm,video/quicktime,.m4v'
|
|
|
|
|
|
|
|
const size = (n: number) =>
|
|
const size = (n: number) =>
|
|
|
n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
|
|
n > 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : Math.ceil(n / 1024) + ' KB'
|
|
@@ -45,6 +51,7 @@ const size = (n: number) =>
|
|
|
export function CourseWorkspace() {
|
|
export function CourseWorkspace() {
|
|
|
const { id = '' } = useParams()
|
|
const { id = '' } = useParams()
|
|
|
const navigate = useNavigate()
|
|
const navigate = useNavigate()
|
|
|
|
|
+ const { workspaceId, selectWorkspace } = useWorkspace()
|
|
|
const globalInputRef = useRef<HTMLInputElement>(null)
|
|
const globalInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
|
const [course, setCourse] = useState<Course | null>(null)
|
|
const [course, setCourse] = useState<Course | null>(null)
|
|
@@ -62,6 +69,7 @@ export function CourseWorkspace() {
|
|
|
const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
|
|
const [isEditingWaitingCourse, setIsEditingWaitingCourse] = useState(false)
|
|
|
const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
|
|
const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null)
|
|
|
const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
|
|
const [previewImage, setPreviewImage] = useState<{ url: string; name: string } | null>(null)
|
|
|
|
|
+ const [previewAsset, setPreviewAsset] = useState<CourseAsset | null>(null)
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!previewImage) return
|
|
if (!previewImage) return
|
|
@@ -78,27 +86,35 @@ export function CourseWorkspace() {
|
|
|
// 分集展开折叠状态 Map (episodeId -> boolean)
|
|
// 分集展开折叠状态 Map (episodeId -> boolean)
|
|
|
const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
|
|
const [expandedMap, setExpandedMap] = useState<Record<string, boolean>>({})
|
|
|
|
|
|
|
|
- // 分集讲稿内联草稿状态 (episodeId -> string)
|
|
|
|
|
- const [notesDraftMap, setNotesDraftMap] = useState<Record<string, string>>({})
|
|
|
|
|
const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
|
|
const [savingNotesId, setSavingNotesId] = useState<string | null>(null)
|
|
|
|
|
+ const [editingNotesEpisode, setEditingNotesEpisode] = useState<Episode | null>(null)
|
|
|
|
|
|
|
|
// 分集上传文件输入引用 Map (episodeId -> File[])
|
|
// 分集上传文件输入引用 Map (episodeId -> File[])
|
|
|
const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
|
|
const [epFilesMap, setEpFilesMap] = useState<Record<string, File[]>>({})
|
|
|
const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
|
|
const [uploadingEpId, setUploadingEpId] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
+ // 通用资产备注状态
|
|
|
|
|
+ const [isEditingAssetNotes, setIsEditingAssetNotes] = useState(false)
|
|
|
|
|
+ const [assetNotesDraft, setAssetNotesDraft] = useState('')
|
|
|
|
|
+ const [savingAssetNotes, setSavingAssetNotes] = useState(false)
|
|
|
|
|
+
|
|
|
const loadCourse = () => {
|
|
const loadCourse = () => {
|
|
|
|
|
+ setError('')
|
|
|
api
|
|
api
|
|
|
.getCourse(id)
|
|
.getCourse(id)
|
|
|
.then((r) => {
|
|
.then((r) => {
|
|
|
|
|
+ setError('')
|
|
|
setCourse(r.course)
|
|
setCourse(r.course)
|
|
|
|
|
+ // 自动自愈:当课程所属工作区与当前工作区不一致时,自动对齐切换工作区
|
|
|
|
|
+ const courseWorkspaceId = r.course.organizationId || 'personal'
|
|
|
|
|
+ if (courseWorkspaceId !== (localStorage.getItem('xinghen_workspace') || 'personal')) {
|
|
|
|
|
+ selectWorkspace(courseWorkspaceId)
|
|
|
|
|
+ }
|
|
|
const initialExpand: Record<string, boolean> = {}
|
|
const initialExpand: Record<string, boolean> = {}
|
|
|
- const initialDrafts: Record<string, string> = {}
|
|
|
|
|
r.course.episodes?.forEach((ep, idx) => {
|
|
r.course.episodes?.forEach((ep, idx) => {
|
|
|
if (idx < 2) initialExpand[ep.id] = true
|
|
if (idx < 2) initialExpand[ep.id] = true
|
|
|
- initialDrafts[ep.id] = ep.lectureNotes || ''
|
|
|
|
|
})
|
|
})
|
|
|
setExpandedMap((prev) => ({ ...initialExpand, ...prev }))
|
|
setExpandedMap((prev) => ({ ...initialExpand, ...prev }))
|
|
|
- setNotesDraftMap((prev) => ({ ...initialDrafts, ...prev }))
|
|
|
|
|
})
|
|
})
|
|
|
.catch((e) => setError(e.message))
|
|
.catch((e) => setError(e.message))
|
|
|
}
|
|
}
|
|
@@ -115,13 +131,14 @@ export function CourseWorkspace() {
|
|
|
// 上传全局通用资产
|
|
// 上传全局通用资产
|
|
|
async function handleUploadGlobalAssets() {
|
|
async function handleUploadGlobalAssets() {
|
|
|
if (!globalFiles.length) return
|
|
if (!globalFiles.length) return
|
|
|
|
|
+ if (globalFiles.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
|
|
|
setBusy(true)
|
|
setBusy(true)
|
|
|
setError('')
|
|
setError('')
|
|
|
try {
|
|
try {
|
|
|
const res = await api.uploadAssets(id, globalFiles)
|
|
const res = await api.uploadAssets(id, globalFiles)
|
|
|
setCourse(res.course)
|
|
setCourse(res.course)
|
|
|
setGlobalFiles([])
|
|
setGlobalFiles([])
|
|
|
- notify('全局资产上传成功')
|
|
|
|
|
|
|
+ notify(res.notesImported ? `资产上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '全局资产上传成功')
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
setError((e as Error).message)
|
|
setError((e as Error).message)
|
|
|
} finally {
|
|
} finally {
|
|
@@ -129,17 +146,54 @@ export function CourseWorkspace() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 保存通用资产备注
|
|
|
|
|
+ async function handleSaveAssetNotes() {
|
|
|
|
|
+ if (!course) return
|
|
|
|
|
+ setSavingAssetNotes(true)
|
|
|
|
|
+ setError('')
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.updateCourse(course.id, { assetNotes: assetNotesDraft.trim() })
|
|
|
|
|
+ setCourse(res.course)
|
|
|
|
|
+ setIsEditingAssetNotes(false)
|
|
|
|
|
+ notify('通用资产备注已保存')
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ setError((e as Error).message)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSavingAssetNotes(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清空通用资产备注
|
|
|
|
|
+ async function handleDeleteAssetNotes() {
|
|
|
|
|
+ if (!course) return
|
|
|
|
|
+ if (!confirm('确定要清空通用资产备注吗?')) return
|
|
|
|
|
+ setSavingAssetNotes(true)
|
|
|
|
|
+ setError('')
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await api.updateCourse(course.id, { assetNotes: '' })
|
|
|
|
|
+ setCourse(res.course)
|
|
|
|
|
+ setAssetNotesDraft('')
|
|
|
|
|
+ setIsEditingAssetNotes(false)
|
|
|
|
|
+ notify('通用资产备注已清空')
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ setError((e as Error).message)
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSavingAssetNotes(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 上传特定分集的专属资产
|
|
// 上传特定分集的专属资产
|
|
|
async function handleUploadEpisodeAssets(episodeId: string) {
|
|
async function handleUploadEpisodeAssets(episodeId: string) {
|
|
|
const files = epFilesMap[episodeId] || []
|
|
const files = epFilesMap[episodeId] || []
|
|
|
if (!files.length) return
|
|
if (!files.length) return
|
|
|
|
|
+ if (files.length > 20) { setError('每次最多上传 20 份文件,请分批上传'); return }
|
|
|
setUploadingEpId(episodeId)
|
|
setUploadingEpId(episodeId)
|
|
|
setError('')
|
|
setError('')
|
|
|
try {
|
|
try {
|
|
|
const res = await api.uploadAssets(id, files, episodeId)
|
|
const res = await api.uploadAssets(id, files, episodeId)
|
|
|
setCourse(res.course)
|
|
setCourse(res.course)
|
|
|
setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
|
|
setEpFilesMap((prev) => ({ ...prev, [episodeId]: [] }))
|
|
|
- notify('分集素材上传成功')
|
|
|
|
|
|
|
+ notify(res.notesImported ? `素材上传成功,已从 ${res.notesImported} 页演讲者备注填入口播稿` : '分集素材上传成功')
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
setError((e as Error).message)
|
|
setError((e as Error).message)
|
|
|
} finally {
|
|
} finally {
|
|
@@ -148,8 +202,7 @@ export function CourseWorkspace() {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 保存分集讲稿正文
|
|
// 保存分集讲稿正文
|
|
|
- async function handleSaveLectureNotes(episode: Episode) {
|
|
|
|
|
- const newNotes = notesDraftMap[episode.id] ?? episode.lectureNotes
|
|
|
|
|
|
|
+ async function handleSaveLectureNotes(episode: Episode, newNotes: string) {
|
|
|
setSavingNotesId(episode.id)
|
|
setSavingNotesId(episode.id)
|
|
|
setError('')
|
|
setError('')
|
|
|
try {
|
|
try {
|
|
@@ -158,9 +211,10 @@ export function CourseWorkspace() {
|
|
|
lectureNotes: newNotes
|
|
lectureNotes: newNotes
|
|
|
})
|
|
})
|
|
|
setCourse(res.course)
|
|
setCourse(res.course)
|
|
|
|
|
+ setEditingNotesEpisode(null)
|
|
|
notify(`《${episode.title}》口播讲稿已保存`)
|
|
notify(`《${episode.title}》口播讲稿已保存`)
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
- setError((e as Error).message)
|
|
|
|
|
|
|
+ throw e
|
|
|
} finally {
|
|
} finally {
|
|
|
setSavingNotesId(null)
|
|
setSavingNotesId(null)
|
|
|
}
|
|
}
|
|
@@ -663,8 +717,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 draftNotes = notesDraftMap[ep.id] ?? (ep.lectureNotes || '')
|
|
|
|
|
- const isNotesDirty = draftNotes !== (ep.lectureNotes || '')
|
|
|
|
|
const pendingFiles = epFilesMap[ep.id] || []
|
|
const pendingFiles = epFilesMap[ep.id] || []
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
@@ -682,7 +734,7 @@ export function CourseWorkspace() {
|
|
|
<div className="header-right" onClick={(e) => e.stopPropagation()}>
|
|
<div className="header-right" onClick={(e) => e.stopPropagation()}>
|
|
|
{ep.lectureNotes?.trim() ? (
|
|
{ep.lectureNotes?.trim() ? (
|
|
|
<span className="tag-pill tag-pill-success" title="已录入讲稿">
|
|
<span className="tag-pill tag-pill-success" title="已录入讲稿">
|
|
|
- 讲稿 ({ep.lectureNotes.length}字)
|
|
|
|
|
|
|
+ 讲稿 ({notesText(ep.lectureNotes).length}字)
|
|
|
</span>
|
|
</span>
|
|
|
) : (
|
|
) : (
|
|
|
<span className="tag-pill tag-pill-dim">未添加讲稿</span>
|
|
<span className="tag-pill tag-pill-dim">未添加讲稿</span>
|
|
@@ -717,70 +769,36 @@ export function CourseWorkspace() {
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* 展开后的分集详情:讲稿正文编辑 + 分集素材上传 */}
|
|
|
|
|
|
|
+ {/* 展开后的分集详情:分集素材上传 + 讲稿正文编辑 */}
|
|
|
{isExpanded && (
|
|
{isExpanded && (
|
|
|
<div className="episode-card-body">
|
|
<div className="episode-card-body">
|
|
|
<div className="episode-content-heading">
|
|
<div className="episode-content-heading">
|
|
|
<div>
|
|
<div>
|
|
|
<strong>本集制作内容</strong>
|
|
<strong>本集制作内容</strong>
|
|
|
- <span>口播稿与素材均为选填,可按实际制作方式添加</span>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="ep-body-grid">
|
|
<div className="ep-body-grid">
|
|
|
- {/* 口播稿(选填) */}
|
|
|
|
|
- <div className="ep-notes-box">
|
|
|
|
|
- <div className="box-title-row">
|
|
|
|
|
- <div className="title-left">
|
|
|
|
|
- <FileText size={15} />
|
|
|
|
|
- <strong>口播稿</strong>
|
|
|
|
|
- <span className="optional-label">选填</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- {isNotesDirty && <span className="dirty-badge">未保存</span>}
|
|
|
|
|
- </div>
|
|
|
|
|
- <textarea
|
|
|
|
|
- rows={5}
|
|
|
|
|
- className="ep-notes-textarea"
|
|
|
|
|
- value={draftNotes}
|
|
|
|
|
- onChange={(e) => {
|
|
|
|
|
- const val = e.target.value
|
|
|
|
|
- setNotesDraftMap((prev) => ({ ...prev, [ep.id]: val }))
|
|
|
|
|
- }}
|
|
|
|
|
- placeholder="可直接输入或粘贴本集的口播稿、字幕文稿…"
|
|
|
|
|
- />
|
|
|
|
|
- <div className="notes-footer-row">
|
|
|
|
|
- <small>共 {draftNotes.length} 字</small>
|
|
|
|
|
- <button
|
|
|
|
|
- type="button"
|
|
|
|
|
- className="primary-button small-button save-notes-btn"
|
|
|
|
|
- disabled={savingNotesId === ep.id || !isNotesDirty}
|
|
|
|
|
- onClick={() => handleSaveLectureNotes(ep)}
|
|
|
|
|
- >
|
|
|
|
|
- <Save size={13} />
|
|
|
|
|
- {savingNotesId === ep.id ? '保存中…' : '保存讲稿'}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* 本集素材(选填) */}
|
|
|
|
|
|
|
+ {/* 本集素材 */}
|
|
|
<div className="ep-assets-box">
|
|
<div className="ep-assets-box">
|
|
|
<div className="box-title-row">
|
|
<div className="box-title-row">
|
|
|
<div className="title-left">
|
|
<div className="title-left">
|
|
|
<FileSpreadsheet size={15} />
|
|
<FileSpreadsheet size={15} />
|
|
|
<strong>上传素材</strong>
|
|
<strong>上传素材</strong>
|
|
|
- <span className="optional-label">选填</span>
|
|
|
|
|
{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">
|
|
|
<Upload size={14} />
|
|
<Upload size={14} />
|
|
|
- <span>选择文件</span>
|
|
|
|
|
|
|
+ <span>添加文件(可多选)</span>
|
|
|
<input
|
|
<input
|
|
|
type="file"
|
|
type="file"
|
|
|
|
|
+ accept={assetAccept}
|
|
|
multiple
|
|
multiple
|
|
|
hidden
|
|
hidden
|
|
|
onChange={(e) => {
|
|
onChange={(e) => {
|
|
|
const selected = Array.from(e.target.files || [])
|
|
const selected = Array.from(e.target.files || [])
|
|
|
- setEpFilesMap((prev) => ({ ...prev, [ep.id]: selected }))
|
|
|
|
|
|
|
+ setEpFilesMap((prev) => ({ ...prev, [ep.id]: [...(prev[ep.id] || []), ...selected] }))
|
|
|
|
|
+ e.target.value = ''
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
</label>
|
|
</label>
|
|
@@ -795,6 +813,8 @@ export function CourseWorkspace() {
|
|
|
<strong title={a.originalName}>{a.originalName}</strong>
|
|
<strong title={a.originalName}>{a.originalName}</strong>
|
|
|
<span>{size(a.size)}</span>
|
|
<span>{size(a.size)}</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
|
|
|
|
|
+ <button type="button" className="icon-action-btn" title="下载素材" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={14} /></button>
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
className="icon-action-btn delete-btn"
|
|
className="icon-action-btn delete-btn"
|
|
@@ -806,7 +826,7 @@ export function CourseWorkspace() {
|
|
|
</div>
|
|
</div>
|
|
|
))}
|
|
))}
|
|
|
{!epAssets.length && (
|
|
{!epAssets.length && (
|
|
|
- <p className="empty-sub-tip">如有需要,可上传本集使用的 PPT、Word、图片等制作素材。</p>
|
|
|
|
|
|
|
+ <p className="empty-sub-tip">可上传 PPTX、PDF、Word、图片或视频,支持一次多选和分次添加。</p>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -844,6 +864,19 @@ export function CourseWorkspace() {
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* 口播稿 */}
|
|
|
|
|
+ <div className="ep-notes-box">
|
|
|
|
|
+ <div className="box-title-row">
|
|
|
|
|
+ <div className="title-left">
|
|
|
|
|
+ <FileText size={15} />
|
|
|
|
|
+ <strong>口播稿</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button type="button" className="secondary-button small-button" onClick={() => setEditingNotesEpisode(ep)}><Edit3 size={14} /> 编辑</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {ep.lectureNotes?.trim() ? <div className="lecture-preview" dangerouslySetInnerHTML={{ __html: notesHtml(ep.lectureNotes) }} /> : <p className="empty-sub-tip">尚未添加口播稿。点击“编辑”打开大幅编辑窗口。</p>}
|
|
|
|
|
+ {ep.lectureNotes?.trim() && <small className="lecture-count">共 {notesText(ep.lectureNotes).length} 字</small>}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
@@ -852,26 +885,118 @@ export function CourseWorkspace() {
|
|
|
})}
|
|
})}
|
|
|
</section>
|
|
</section>
|
|
|
|
|
|
|
|
- {/* 课程通用 / 全局素材专区 */}
|
|
|
|
|
|
|
+ {/* 通用资产专区 */}
|
|
|
<section className="paper-form global-assets-panel">
|
|
<section className="paper-form global-assets-panel">
|
|
|
<div className="panel-heading-row">
|
|
<div className="panel-heading-row">
|
|
|
<div>
|
|
<div>
|
|
|
- <h3>全套课程通用资产 ({globalAssets.length})</h3>
|
|
|
|
|
|
|
+ <h3>通用资产 ({globalAssets.length})</h3>
|
|
|
<p className="panel-intro">
|
|
<p className="panel-intro">
|
|
|
如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
|
|
如全课程大纲、全套通用 PPT 模板、讲师全局参考音频等不区分分集的通用资料。
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ {!isEditingAssetNotes && !course.assetNotes?.trim() && canEditCourseContent && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="secondary-button small-button add-asset-notes-btn"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setAssetNotesDraft('')
|
|
|
|
|
+ setIsEditingAssetNotes(true)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Plus size={14} /> 添加备注
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {/* 通用资产备注展示与编辑 */}
|
|
|
|
|
+ {(isEditingAssetNotes || !!course.assetNotes?.trim()) && (
|
|
|
|
|
+ <div className="asset-notes-card">
|
|
|
|
|
+ <div className="asset-notes-header">
|
|
|
|
|
+ <div className="notes-header-left">
|
|
|
|
|
+ <FileText size={15} />
|
|
|
|
|
+ <strong>通用备注</strong>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {canEditCourseContent && !isEditingAssetNotes && (
|
|
|
|
|
+ <div className="notes-actions">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="icon-action-btn"
|
|
|
|
|
+ title="修改备注"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setAssetNotesDraft(course.assetNotes || '')
|
|
|
|
|
+ setIsEditingAssetNotes(true)
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Edit3 size={13} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="icon-action-btn delete-btn"
|
|
|
|
|
+ title="清空备注"
|
|
|
|
|
+ onClick={handleDeleteAssetNotes}
|
|
|
|
|
+ disabled={savingAssetNotes}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Trash2 size={13} />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {isEditingAssetNotes ? (
|
|
|
|
|
+ <div className="asset-notes-edit-box">
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ rows={4}
|
|
|
|
|
+ className="asset-notes-textarea"
|
|
|
|
|
+ value={assetNotesDraft}
|
|
|
|
|
+ onChange={(e) => setAssetNotesDraft(e.target.value)}
|
|
|
|
|
+ placeholder="输入通用制作备注、课件风格要求、全局格式规范等说明文字…"
|
|
|
|
|
+ autoFocus
|
|
|
|
|
+ />
|
|
|
|
|
+ <div className="notes-footer-row">
|
|
|
|
|
+ <small>共 {assetNotesDraft.length} 字</small>
|
|
|
|
|
+ <div className="notes-btn-group">
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="secondary-button small-button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setIsEditingAssetNotes(false)
|
|
|
|
|
+ setAssetNotesDraft(course.assetNotes || '')
|
|
|
|
|
+ }}
|
|
|
|
|
+ disabled={savingAssetNotes}
|
|
|
|
|
+ >
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className="primary-button small-button"
|
|
|
|
|
+ onClick={handleSaveAssetNotes}
|
|
|
|
|
+ disabled={savingAssetNotes}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Save size={13} />
|
|
|
|
|
+ {savingAssetNotes ? '保存中…' : '保存备注'}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="asset-notes-text">
|
|
|
|
|
+ {course.assetNotes}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
<div className="asset-list">
|
|
<div className="asset-list">
|
|
|
{globalAssets.map((a) => (
|
|
{globalAssets.map((a) => (
|
|
|
<div className="asset-row" key={a.id}>
|
|
<div className="asset-row" key={a.id}>
|
|
|
<FileText />
|
|
<FileText />
|
|
|
<div>
|
|
<div>
|
|
|
<strong>{a.originalName}</strong>
|
|
<strong>{a.originalName}</strong>
|
|
|
- <span>{size(a.size)} · 全局通用</span>
|
|
|
|
|
|
|
+ <span>{size(a.size)} · 通用素材</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <button title="删除" onClick={() => handleDeleteAsset(a.id)}>
|
|
|
|
|
|
|
+ {canPreviewAsset(a) && <button type="button" className="asset-preview-action" onClick={() => setPreviewAsset(a)}>预览</button>}
|
|
|
|
|
+ <button type="button" title="下载" aria-label={`下载 ${a.originalName}`} onClick={() => void downloadPrivateFile(a.url, a.originalName).catch((cause) => setError((cause as Error).message))}><Download size={16} /></button>
|
|
|
|
|
+ <button type="button" title="删除" aria-label={`删除 ${a.originalName}`} onClick={() => handleDeleteAsset(a.id)}>
|
|
|
<Trash2 size={16} />
|
|
<Trash2 size={16} />
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
@@ -880,15 +1005,16 @@ export function CourseWorkspace() {
|
|
|
|
|
|
|
|
<div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
|
|
<div className="drop-zone compact" onClick={() => globalInputRef.current?.click()}>
|
|
|
<FilePlus2 />
|
|
<FilePlus2 />
|
|
|
- <h3>选择全局通用资产</h3>
|
|
|
|
|
- <small>支持任意常见课件/文档文件,单个文件不超过 200 MB</small>
|
|
|
|
|
|
|
+ <h3>添加通用附件(可多选)</h3>
|
|
|
|
|
+ <small>支持 PPTX、PDF、Word、图片和视频;可分次添加,每次最多 20 份</small>
|
|
|
</div>
|
|
</div>
|
|
|
<input
|
|
<input
|
|
|
hidden
|
|
hidden
|
|
|
multiple
|
|
multiple
|
|
|
ref={globalInputRef}
|
|
ref={globalInputRef}
|
|
|
type="file"
|
|
type="file"
|
|
|
- onChange={(e) => setGlobalFiles(Array.from(e.target.files || []))}
|
|
|
|
|
|
|
+ accept={assetAccept}
|
|
|
|
|
+ onChange={(e) => { setGlobalFiles((prev) => [...prev, ...Array.from(e.target.files || [])]); e.target.value = '' }}
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
{globalFiles.length > 0 && (
|
|
{globalFiles.length > 0 && (
|
|
@@ -1067,6 +1193,8 @@ export function CourseWorkspace() {
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
{/* 单集添加/编辑弹窗 */}
|
|
{/* 单集添加/编辑弹窗 */}
|
|
|
|
|
+ {previewAsset && <CourseAssetPreview key={previewAsset.id} asset={previewAsset} courseId={id} onClose={() => setPreviewAsset(null)} />}
|
|
|
|
|
+ {editingNotesEpisode && <LectureNotesDialog key={editingNotesEpisode.id} title={editingNotesEpisode.title} initialValue={course.episodes?.find((episode) => episode.id === editingNotesEpisode.id)?.lectureNotes || ''} onClose={() => setEditingNotesEpisode(null)} onSave={(value) => handleSaveLectureNotes(editingNotesEpisode, value)} saving={savingNotesId === editingNotesEpisode.id} />}
|
|
|
<EpisodeModal
|
|
<EpisodeModal
|
|
|
isOpen={showEpisodeModal}
|
|
isOpen={showEpisodeModal}
|
|
|
onClose={() => {
|
|
onClose={() => {
|