AdminProductionQueue.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. import { Clock3, Download, FileText, RefreshCw, Search, UserRound } from 'lucide-react'
  2. import { useEffect, useState } from 'react'
  3. import { api, downloadPrivateFile } from '../../api'
  4. import { ProtectedImage } from '../../components/ProtectedImage'
  5. import { formatDateTime } from '../../utils/date'
  6. import { notesHtml, notesText } from '../../utils/lectureNotes'
  7. import type { Course } from '../../types'
  8. export function AdminProductionQueue() {
  9. const [courses, setCourses] = useState<Course[]>([])
  10. const [loading, setLoading] = useState(true)
  11. const [error, setError] = useState('')
  12. const [keyword, setKeyword] = useState('')
  13. const [query, setQuery] = useState('')
  14. const [status, setStatus] = useState<'WAITING_PRODUCTION' | 'IN_PRODUCTION'>('WAITING_PRODUCTION')
  15. const [busyId, setBusyId] = useState('')
  16. const [notice, setNotice] = useState('')
  17. const [files, setFiles] = useState<Record<string, File[]>>({})
  18. const [notes, setNotes] = useState<Record<string, string>>({})
  19. const [points, setPoints] = useState<Record<string, string>>({})
  20. useEffect(() => {
  21. let active = true
  22. setLoading(true)
  23. setError('')
  24. api.admin.listCourses({ status, keyword: query })
  25. .then(({ courses }) => { if (active) setCourses(courses) })
  26. .catch((err) => { if (active) setError(err.message) })
  27. .finally(() => { if (active) setLoading(false) })
  28. return () => { active = false }
  29. }, [query, status])
  30. const refresh = () => {
  31. setLoading(true)
  32. setError('')
  33. api.admin.listCourses({ status, keyword: query })
  34. .then(({ courses }) => setCourses(courses))
  35. .catch((err) => setError(err.message))
  36. .finally(() => setLoading(false))
  37. }
  38. const run = async (id: string, action: () => Promise<unknown>, message: string) => {
  39. setBusyId(id)
  40. setError('')
  41. setNotice('')
  42. try { await action(); setNotice(message); refresh() }
  43. catch (err) { setError((err as Error).message) }
  44. finally { setBusyId('') }
  45. }
  46. return <div className="page admin-page production-queue-page">
  47. <div className="admin-header-row">
  48. <div><h2>待制作课程</h2><p className="admin-subtitle">团队制作准备清单 · 查看已提交课程的资料、素材和数字讲师</p></div>
  49. <button type="button" className="secondary-button" onClick={refresh}><RefreshCw size={15} /> 刷新队列</button>
  50. </div>
  51. <div className="production-queue-toolbar">
  52. <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>
  53. <form onSubmit={(event) => { event.preventDefault(); setQuery(keyword.trim()) }}>
  54. <Search size={16} /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索课程、分类或提交人" aria-label="搜索待制作课程" />
  55. <button type="submit" className="secondary-button">搜索</button>
  56. </form>
  57. </div>
  58. {error && <div className="notice notice-error" role="alert">{error}</div>}
  59. {notice && <div className="notice notice-success" role="status">{notice}</div>}
  60. {loading ? <div className="center-loading">正在读取待制作课程…</div> : !error && courses.length === 0 ?
  61. <div className="empty-state"><Clock3 size={32} /><h3>{query ? '没有匹配的课程' : status === 'WAITING_PRODUCTION' ? '当前没有等待制作的课程' : '当前没有制作中的课程'}</h3><p>{query ? '可以调整搜索词或清空搜索。' : '课程状态变化后,这里的列表会随之更新。'}</p></div> :
  62. <div className="production-queue-list">{courses.map((course) => {
  63. const instructor = course.digitalInstructorSnapshot
  64. const notesCount = course.episodes.filter((episode) => notesText(episode.lectureNotes || '').trim()).length
  65. return <article className="production-queue-card" key={course.id}>
  66. <div className="production-queue-card-head">
  67. <div><span className="production-queue-index">等待制作</span><h3>{course.name}</h3><p>{course.description || '未填写课程简介'}</p></div>
  68. </div>
  69. <div className="production-queue-meta">
  70. <span><UserRound size={14} /> {course.creator?.contactName || '未知提交人'} · {course.creator?.phone || '无联系电话'} · {course.organizationName || '个人工作区'}</span>
  71. <span><Clock3 size={14} /> 提交于 {formatDateTime(course.submittedAt || course.createdAt)}</span>
  72. <span><FileText size={14} /> {course.episodes.length} 集 · {notesCount} 份讲稿 · {course.assets.length} 份课程素材</span>
  73. </div>
  74. <div className="production-queue-grid">
  75. <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>
  76. <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>
  77. <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>
  78. </div>
  79. <details className="production-queue-more"><summary>查看完整制作资料</summary>
  80. <div className="production-queue-more-content">
  81. <div><h4>提交人资料</h4><p>姓名:{course.creator?.contactName || '未填写'}</p><p>电话:{course.creator?.phone || '未填写'}</p><p>微信:{course.creator?.wechat || '未填写'}</p></div>
  82. <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>
  83. </div>
  84. </details>
  85. <div className="production-queue-actions">
  86. {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> : <>
  87. <label>上传交付文件<input type="file" multiple onChange={(event) => setFiles((current) => ({ ...current, [course.id]: Array.from(event.target.files || []) }))} /></label>
  88. <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>
  89. {!!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>}
  90. <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>
  91. <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>
  92. <label>交付说明<textarea value={notes[course.id] || ''} onChange={(event) => setNotes((current) => ({ ...current, [course.id]: event.target.value }))} placeholder="填写交付说明" /></label>
  93. <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>
  94. <span>已上传 {course.deliverables?.length || 0} 份交付文件</span>
  95. </>}
  96. </div>
  97. </article>
  98. })}</div>}
  99. </div>
  100. }