| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105 |
- import { Clock3, Download, FileText, RefreshCw, Search, UserRound } from 'lucide-react'
- import { useEffect, useState } from 'react'
- import { api, downloadPrivateFile } from '../../api'
- import { ProtectedImage } from '../../components/ProtectedImage'
- import { formatDateTime } from '../../utils/date'
- import { notesHtml, notesText } from '../../utils/lectureNotes'
- import type { Course } from '../../types'
- export function AdminProductionQueue() {
- const [courses, setCourses] = useState<Course[]>([])
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState('')
- const [keyword, setKeyword] = useState('')
- const [query, setQuery] = useState('')
- const [status, setStatus] = useState<'WAITING_PRODUCTION' | 'IN_PRODUCTION'>('WAITING_PRODUCTION')
- const [busyId, setBusyId] = useState('')
- const [notice, setNotice] = useState('')
- const [files, setFiles] = useState<Record<string, File[]>>({})
- const [notes, setNotes] = useState<Record<string, string>>({})
- const [points, setPoints] = useState<Record<string, string>>({})
- useEffect(() => {
- let active = true
- setLoading(true)
- setError('')
- api.admin.listCourses({ status, keyword: query })
- .then(({ courses }) => { if (active) setCourses(courses) })
- .catch((err) => { if (active) setError(err.message) })
- .finally(() => { if (active) setLoading(false) })
- return () => { active = false }
- }, [query, status])
- const refresh = () => {
- setLoading(true)
- setError('')
- api.admin.listCourses({ status, keyword: query })
- .then(({ courses }) => setCourses(courses))
- .catch((err) => setError(err.message))
- .finally(() => setLoading(false))
- }
- const run = async (id: string, action: () => Promise<unknown>, message: string) => {
- setBusyId(id)
- setError('')
- setNotice('')
- try { await action(); setNotice(message); refresh() }
- catch (err) { setError((err as Error).message) }
- finally { setBusyId('') }
- }
- return <div className="page admin-page production-queue-page">
- <div className="admin-header-row">
- <div><h2>待制作课程</h2><p className="admin-subtitle">团队制作准备清单 · 查看已提交课程的资料、素材和数字讲师</p></div>
- <button type="button" className="secondary-button" onClick={refresh}><RefreshCw size={15} /> 刷新队列</button>
- </div>
- <div className="production-queue-toolbar">
- <div className="production-queue-tabs"><button type="button" className={status === 'WAITING_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('WAITING_PRODUCTION')}>待制作</button><button type="button" className={status === 'IN_PRODUCTION' ? 'active' : ''} onClick={() => setStatus('IN_PRODUCTION')}>制作中</button><strong><Clock3 size={17} /> {loading ? '…' : courses.length} 门</strong></div>
- <form onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()) }}>
- <Search size={16} /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程、分类或提交人" aria-label="搜索待制作课程" />
- <button type="submit" className="secondary-button">搜索</button>
- </form>
- </div>
- {error && <div className="notice notice-error" role="alert">{error}</div>}
- {notice && <div className="notice notice-success" role="status">{notice}</div>}
- {loading ? <div className="center-loading">正在读取待制作课程…</div> : !error && courses.length === 0 ?
- <div className="empty-state"><Clock3 size={32} /><h3>{query ? '没有匹配的课程' : status === 'WAITING_PRODUCTION' ? '当前没有等待制作的课程' : '当前没有制作中的课程'}</h3><p>{query ? '可以调整搜索词或清空搜索。' : '课程状态变化后,这里的列表会随之更新。'}</p></div> :
- <div className="production-queue-list">{courses.map((course) => {
- const instructor = course.digitalInstructorSnapshot
- const notesCount = course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length
- return <article className="production-queue-card" key={course.id}>
- <div className="production-queue-card-head">
- <div><span className="production-queue-index">等待制作</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
- </div>
- <div className="production-queue-meta">
- <span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.creator?.phone || '无联系电话'} · {course.organizationName || '个人工作区'}</span>
- <span><Clock3 size={14} /> 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</span>
- <span><FileText size={14} /> {course.episodes.length} 集 · {notesCount} 份讲稿 · {course.assets.length} 份课程素材</span>
- </div>
- <div className="production-queue-grid">
- <section><h4>课程资料与分集</h4>{course.episodes.length ? <ul>{course.episodes.map((episode) => <li key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong><span>{episode.summary || '未填写分集简介'} · {notesText(episode.lectureNotes || '').trim() ? `${notesText(episode.lectureNotes).length} 字讲稿` : '暂无讲稿'}</span></li>)}</ul> : <p>暂无分集资料</p>}</section>
- <section><h4>上传素材({course.assets.length})</h4>{course.assets.length ? <ul>{course.assets.map((asset) => <li key={asset.id}><span>{asset.originalName}</span><button type="button" className="production-file-action" onClick={() => downloadPrivateFile(asset.url, asset.originalName)} aria-label={`下载 ${asset.originalName}`}><Download size={14} /> 下载</button></li>)}</ul> : <p>暂无上传素材</p>}</section>
- <section><h4>数字讲师</h4>{instructor ? <div className="production-instructor"><div className="production-instructor-photos">{instructor.images?.map((photo) => <ProtectedImage key={photo.id} src={photo.url} alt={`${instructor.name} 照片`} />)}</div><strong>{instructor.name}</strong><p>{instructor.note || '无备注'}</p><small>照片 {instructor.images?.length || 0} 张 · 录音 {instructor.audio?.length || 0} 份</small>{instructor.audio?.map((audio) => <button key={audio.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(audio.url, audio.name)}><Download size={14} /> {audio.name}</button>)}</div> : <p>未保存数字讲师快照,请进入详情核对历史讲师资料。</p>}</section>
- </div>
- <details className="production-queue-more"><summary>查看完整制作资料</summary>
- <div className="production-queue-more-content">
- <div><h4>提交人资料</h4><p>姓名:{course.creator?.contactName || '未填写'}</p><p>电话:{course.creator?.phone || '未填写'}</p><p>微信:{course.creator?.wechat || '未填写'}</p></div>
- <div><h4>分集讲稿</h4>{course.episodes.map((episode) => <section key={episode.id}><strong>第 {episode.episodeNumber} 集 · {episode.title}</strong>{episode.lectureNotes ? <div dangerouslySetInnerHTML={{ __html: notesHtml(episode.lectureNotes) }} /> : <p>未填写讲稿</p>}</section>)}</div>
- </div>
- </details>
- <div className="production-queue-actions">
- {status === 'WAITING_PRODUCTION' ? <button type="button" className="primary-button" disabled={busyId === course.id} onClick={() => run(course.id, () => api.admin.updateStatus(course.id, 'IN_PRODUCTION'), '已接单,课程进入制作中')}>接单并开始制作</button> : <>
- <label>上传交付文件<input type="file" multiple onChange={(event) => setFiles((current) => ({ ...current, [course.id]: Array.from(event.target.files || []) }))} /></label>
- <button type="button" className="secondary-button" disabled={busyId === course.id || !(files[course.id]?.length)} onClick={() => run(course.id, () => api.admin.uploadDeliverables(course.id, files[course.id]), '交付文件已上传')}>上传所选文件</button>
- {!!course.deliverables?.length && <div className="production-deliverables">{course.deliverables.map((file) => <button key={file.id} type="button" className="production-file-action" onClick={() => downloadPrivateFile(file.url, file.originalName)}><Download size={14} /> {file.originalName}</button>)}</div>}
- <label>实际积分<input type="number" min="1" max="100000" value={points[course.id] ?? String(course.actualPoints ?? course.estimatedPoints)} onChange={(event) => setPoints((current) => ({ ...current, [course.id]: event.target.value }))} /></label>
- <button type="button" className="secondary-button" disabled={busyId === course.id || !Number.isInteger(Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints)) || Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints) < 1} onClick={() => run(course.id, () => api.admin.updatePoints(course.id, Number(points[course.id] ?? course.actualPoints ?? course.estimatedPoints), '制作团队调整实际消耗'), '实际积分已更新')}>保存积分</button>
- <label>交付说明<textarea value={notes[course.id] || ''} onChange={(event) => setNotes((current) => ({ ...current, [course.id]: event.target.value }))} placeholder="填写交付说明" /></label>
- <button type="button" className="primary-button" disabled={busyId === course.id || !course.deliverables?.length} onClick={() => { if (window.confirm(`确认完成并交付「${course.name}」?`)) run(course.id, () => api.admin.completeCourse(course.id, notes[course.id] || ''), '课程已完成并交付') }}>确认完成并交付</button>
- <span>已上传 {course.deliverables?.length || 0} 份交付文件</span>
- </>}
- </div>
- </article>
- })}</div>}
- </div>
- }
|