home.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import React, { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
  2. import { Button, Nav, Modal, Form, Dropdown, Row, Col, Container, Card, ListGroup, Badge, Table, Alert } from 'react-bootstrap';
  3. import { useHistory } from 'react-router-dom';
  4. import './home.css'
  5. import { toast } from 'react-toastify';
  6. import VideoDownload from '../../components/videoDownload';
  7. import SubtitleUpload from '../../components/subtitleUpload';
  8. import AudioUpload from '../../components/audioUpload';
  9. import VideoUpload from '../../components/videoUpload';
  10. import { bookService, bookInfoService, getDbPath } from '../../db';
  11. import CozeApiSettings from '../../components/CozeApiSettings';
  12. import { hasValidToken } from '../../utils/cozeConfig';
  13. const Home = forwardRef((props, ref) => {
  14. const [showProject, setShowProject] = useState(false);
  15. const [projects, setProjects] = useState([]);
  16. const [loading, setLoading] = useState(false);
  17. const [showApiSettings, setShowApiSettings] = useState(false);
  18. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  19. const [projectToDelete, setProjectToDelete] = useState(null);
  20. const [showAudioPlayer, setShowAudioPlayer] = useState(false);
  21. const [currentAudio, setCurrentAudio] = useState(null);
  22. const [projectAudioFile, setProjectAudioFile] = useState(null);
  23. const [projectVideoFile, setProjectVideoFile] = useState(null);
  24. const history = useHistory();
  25. const handleAddProject = () => {
  26. setShowProject(true);
  27. };
  28. // 处理API设置
  29. const handleOpenApiSettings = () => {
  30. setShowApiSettings(true);
  31. };
  32. const handleCloseApiSettings = () => {
  33. setShowApiSettings(false);
  34. };
  35. // 加载项目列表
  36. const loadProjects = async () => {
  37. try {
  38. setLoading(true);
  39. const books = await bookService.getAllBooks();
  40. setProjects(books);
  41. } catch (error) {
  42. console.error('加载项目列表失败:', error);
  43. toast.error('加载项目列表失败');
  44. } finally {
  45. setLoading(false);
  46. }
  47. };
  48. // 查看项目详情
  49. const viewProjectDetail = (projectId) => {
  50. history.push(`/project/${projectId}`);
  51. };
  52. // 下载SRT字幕
  53. const downloadSubtitleSRT = async (projectId, projectTitle) => {
  54. try {
  55. setLoading(true);
  56. // 获取项目详情
  57. const segments = await bookInfoService.getBookInfoByBookId(projectId);
  58. if (!segments || segments.length === 0) {
  59. toast.error('该项目没有字幕内容可下载');
  60. return;
  61. }
  62. // 生成SRT格式内容
  63. const srtContent = segments
  64. .sort((a, b) => a.segment_id - b.segment_id)
  65. .map((segment) => {
  66. return `${segment.segment_id}\n${segment.start_time} --> ${segment.end_time}\n${segment.text}\n`;
  67. })
  68. .join('\n');
  69. // 创建Blob对象
  70. const blob = new Blob([srtContent], { type: 'text/plain;charset=utf-8' });
  71. // 创建下载链接
  72. const url = URL.createObjectURL(blob);
  73. const link = document.createElement('a');
  74. link.href = url;
  75. link.download = `${projectTitle || '项目'}.srt`;
  76. // 触发下载
  77. document.body.appendChild(link);
  78. link.click();
  79. // 清理
  80. document.body.removeChild(link);
  81. URL.revokeObjectURL(url);
  82. toast.success('字幕下载成功');
  83. } catch (error) {
  84. console.error('下载字幕失败:', error);
  85. toast.error(`下载字幕失败: ${error.message}`);
  86. } finally {
  87. setLoading(false);
  88. }
  89. };
  90. // 下载纯文本文案
  91. const downloadPlainText = async (projectId, projectTitle) => {
  92. try {
  93. setLoading(true);
  94. // 获取项目详情
  95. const segments = await bookInfoService.getBookInfoByBookId(projectId);
  96. if (!segments || segments.length === 0) {
  97. toast.error('该项目没有文本内容可下载');
  98. return;
  99. }
  100. // 提取所有纯文本内容(不包含时间信息)
  101. const textContent = segments
  102. .sort((a, b) => a.segment_id - b.segment_id)
  103. .map(segment => segment.text)
  104. .join('\n\n');
  105. // 创建Blob对象
  106. const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8' });
  107. // 创建下载链接
  108. const url = URL.createObjectURL(blob);
  109. const link = document.createElement('a');
  110. link.href = url;
  111. link.download = `${projectTitle || '项目'}_文案.txt`;
  112. // 触发下载
  113. document.body.appendChild(link);
  114. link.click();
  115. // 清理
  116. document.body.removeChild(link);
  117. URL.revokeObjectURL(url);
  118. toast.success('文案下载成功');
  119. } catch (error) {
  120. console.error('下载文案失败:', error);
  121. toast.error(`下载文案失败: ${error.message}`);
  122. } finally {
  123. setLoading(false);
  124. }
  125. };
  126. // 复制文案到剪贴板
  127. const copyProjectText = async (projectId, projectTitle) => {
  128. try {
  129. setLoading(true);
  130. // 获取项目详情
  131. const segments = await bookInfoService.getBookInfoByBookId(projectId);
  132. if (!segments || segments.length === 0) {
  133. toast.error('该项目没有文本内容可复制');
  134. return;
  135. }
  136. // 提取所有纯文本内容(不包含时间信息)
  137. const textContent = segments
  138. .sort((a, b) => a.segment_id - b.segment_id)
  139. .map(segment => segment.text)
  140. .join('\n\n');
  141. // 复制到剪贴板
  142. await navigator.clipboard.writeText(textContent);
  143. toast.success('文案已复制到剪贴板');
  144. } catch (error) {
  145. console.error('复制文案失败:', error);
  146. toast.error(`复制文案失败: ${error.message}`);
  147. } finally {
  148. setLoading(false);
  149. }
  150. };
  151. // 返回主页
  152. const backToHome = () => {
  153. setShowProject(false);
  154. loadProjects(); // 刷新项目列表
  155. };
  156. // 格式化时间
  157. const formatTime = (timeString) => {
  158. return timeString.replace(',', '.');
  159. };
  160. // 计算持续时间(秒)
  161. const calculateDuration = (startTime, endTime) => {
  162. const parseTime = (time) => {
  163. const [hours, minutes, seconds] = time.replace(',', '.').split(':').map(parseFloat);
  164. return hours * 3600 + minutes * 60 + seconds;
  165. };
  166. const startSeconds = parseTime(startTime);
  167. const endSeconds = parseTime(endTime);
  168. return (endSeconds - startSeconds).toFixed(2);
  169. };
  170. // 打开数据库路径
  171. const openDatabasePath = async () => {
  172. try {
  173. if (window.electron && window.electron.files) {
  174. // 获取数据库路径
  175. const dbPath = await getDbPath();
  176. if (!dbPath) {
  177. toast.error('无法获取数据库路径');
  178. return;
  179. }
  180. console.log('数据库路径:', dbPath);
  181. // 获取数据库所在目录
  182. let dbDir = dbPath;
  183. const lastSlashIndex = Math.max(dbPath.lastIndexOf('/'), dbPath.lastIndexOf('\\'));
  184. if (lastSlashIndex !== -1) {
  185. dbDir = dbPath.substring(0, lastSlashIndex);
  186. }
  187. console.log('数据库目录:', dbDir);
  188. // 打开文件夹
  189. window.electron.files.showItemInFolder(dbDir);
  190. toast.success('已打开数据库所在文件夹');
  191. } else {
  192. toast.info('此功能仅在桌面应用中可用');
  193. }
  194. } catch (error) {
  195. console.error('打开数据库路径失败:', error);
  196. toast.error(`打开数据库路径失败: ${error.message}`);
  197. }
  198. };
  199. // 打开资源文件夹
  200. const openResourcesPath = async () => {
  201. try {
  202. if (window.electron && window.electron.ipcRenderer) {
  203. // 获取资源目录路径
  204. const result = await window.electron.ipcRenderer.invoke('get-resources-path');
  205. if (!result.success) {
  206. toast.error(`无法获取资源目录路径: ${result.error}`);
  207. return;
  208. }
  209. console.log('资源目录路径:', result.path);
  210. // 打开文件夹
  211. window.electron.files.showItemInFolder(result.path);
  212. toast.success('已打开资源文件夹');
  213. } else {
  214. toast.info('此功能仅在桌面应用中可用');
  215. }
  216. } catch (error) {
  217. console.error('打开资源文件夹失败:', error);
  218. toast.error(`打开资源文件夹失败: ${error.message}`);
  219. }
  220. };
  221. // 删除项目
  222. const handleDeleteProject = async (project) => {
  223. setProjectToDelete(project);
  224. setShowDeleteConfirm(true);
  225. };
  226. // 确认删除项目
  227. const confirmDeleteProject = async () => {
  228. if (!projectToDelete) return;
  229. try {
  230. setLoading(true);
  231. await bookService.deleteBook(projectToDelete.id);
  232. toast.success(`项目 "${projectToDelete.title}" 已删除`);
  233. setShowDeleteConfirm(false);
  234. setProjectToDelete(null);
  235. await loadProjects(); // 刷新项目列表
  236. } catch (error) {
  237. console.error('删除项目失败:', error);
  238. toast.error(`删除项目失败: ${error.message}`);
  239. } finally {
  240. setLoading(false);
  241. }
  242. };
  243. // 取消删除
  244. const cancelDeleteProject = () => {
  245. setShowDeleteConfirm(false);
  246. setProjectToDelete(null);
  247. };
  248. // 播放音频
  249. const playAudio = (projectId, audioFileName) => {
  250. // 由于实际开发中,音频文件可能存储在特定位置,这里仅为示例,使用音频文件名
  251. // 实际应用中可能需要通过其他方式获取真实的音频文件路径
  252. setCurrentAudio({
  253. projectId,
  254. fileName: audioFileName
  255. });
  256. setShowAudioPlayer(true);
  257. };
  258. // 关闭音频播放器
  259. const closeAudioPlayer = () => {
  260. setShowAudioPlayer(false);
  261. setCurrentAudio(null);
  262. };
  263. // 组件挂载时加载项目列表
  264. useEffect(() => {
  265. loadProjects();
  266. }, []);
  267. return (
  268. <>
  269. <div className='home-container'>
  270. <Container>
  271. {!showProject ? (
  272. <>
  273. <div className="header-container">
  274. <div className="d-flex justify-content-between align-items-center">
  275. <Button
  276. variant="primary"
  277. onClick={handleAddProject}
  278. className="add-project-btn"
  279. >
  280. 添加项目
  281. </Button>
  282. <Button
  283. variant={hasValidToken() ? "outline-success" : "outline-warning"}
  284. onClick={handleOpenApiSettings}
  285. className="api-settings-btn"
  286. >
  287. {hasValidToken() ? "Coze API设置 ✓" : "配置Coze API"}
  288. </Button>
  289. </div>
  290. </div>
  291. {!hasValidToken() && (
  292. <Alert variant="warning" className="mt-3">
  293. <Alert.Heading>请配置Coze API Token</Alert.Heading>
  294. <p>
  295. 您尚未配置Coze API Token,文本转描述词功能将无法使用。
  296. 请点击右上角的"配置Coze API"按钮进行设置。
  297. </p>
  298. </Alert>
  299. )}
  300. <div className="welcome-container">
  301. {projects.length > 0 ? (
  302. <div className="project-list-container">
  303. <h4 className="mb-3">项目列表</h4>
  304. <div className="mb-3 d-flex gap-2">
  305. <Button
  306. variant="outline-secondary"
  307. size="sm"
  308. onClick={openDatabasePath}
  309. >
  310. <i className="bi bi-folder"></i> 打开数据库文件夹
  311. </Button>
  312. <Button
  313. variant="outline-secondary"
  314. size="sm"
  315. onClick={openResourcesPath}
  316. >
  317. <i className="bi bi-folder"></i> 打开资源文件夹
  318. </Button>
  319. </div>
  320. <div className="table-responsive">
  321. <Table striped hover className="project-table">
  322. <thead>
  323. <tr>
  324. <th>项目名称</th>
  325. <th>创建时间</th>
  326. <th>字幕文件</th>
  327. <th>音频文件</th>
  328. <th>视频文件</th>
  329. <th>操作</th>
  330. </tr>
  331. </thead>
  332. <tbody>
  333. {projects.map((project) => (
  334. <tr key={project.id}>
  335. <td className="project-title-cell">{project.title}</td>
  336. <td>{new Date(project.created_at).toLocaleString()}</td>
  337. <td>
  338. {project.subtitle_path ? (
  339. <Badge variant="info">{project.subtitle_path}</Badge>
  340. ) : (
  341. <span className="text-muted">无</span>
  342. )}
  343. </td>
  344. <td>
  345. {project.audio_path ? (
  346. <div className="file-path-badge" title={project.audio_path}>
  347. <Badge bg="success">
  348. {project.audio_path.length > 15
  349. ? project.audio_path.substring(0, 6) + '...' + project.audio_path.substring(project.audio_path.length - 6)
  350. : project.audio_path}
  351. </Badge>
  352. </div>
  353. ) : (
  354. <span className="text-muted">无</span>
  355. )}
  356. </td>
  357. <td>
  358. {project.video_path ? (
  359. <div className="file-path-badge" title={project.video_path}>
  360. <Badge bg="primary">
  361. {project.video_path.length > 15
  362. ? project.video_path.substring(0, 6) + '...' + project.video_path.substring(project.video_path.length - 6)
  363. : project.video_path}
  364. </Badge>
  365. </div>
  366. ) : (
  367. <span className="text-muted">无</span>
  368. )}
  369. </td>
  370. <td className="action-buttons">
  371. <Button
  372. variant="outline-primary"
  373. size="sm"
  374. onClick={() => viewProjectDetail(project.id)}
  375. >
  376. 查看详情
  377. </Button>
  378. <Button
  379. variant="outline-info"
  380. size="sm"
  381. onClick={() => downloadSubtitleSRT(project.id, project.title)}
  382. >
  383. 下载字幕
  384. </Button>
  385. <Button
  386. variant="outline-warning"
  387. size="sm"
  388. onClick={() => copyProjectText(project.id, project.title)}
  389. >
  390. 复制文案
  391. </Button>
  392. {project.audio_path && (
  393. <Button
  394. variant="outline-info"
  395. size="sm"
  396. onClick={() => playAudio(project.id, project.audio_path)}
  397. >
  398. 播放音频
  399. </Button>
  400. )}
  401. <Button
  402. variant="outline-danger"
  403. size="sm"
  404. onClick={() => handleDeleteProject(project)}
  405. >
  406. 删除
  407. </Button>
  408. </td>
  409. </tr>
  410. ))}
  411. </tbody>
  412. </Table>
  413. </div>
  414. </div>
  415. ) : (
  416. <p className="text-center text-muted">点击左上角"添加项目"按钮开始创建新项目</p>
  417. )}
  418. </div>
  419. </>
  420. ) : (
  421. <>
  422. <div className="header-container">
  423. <Button
  424. variant="outline-secondary"
  425. onClick={backToHome}
  426. className="back-btn"
  427. >
  428. 返回
  429. </Button>
  430. </div>
  431. <AudioUpload onAudioSelected={(audioFile) => setProjectAudioFile(audioFile)} />
  432. <VideoUpload onVideoSelected={(videoFile) => setProjectVideoFile(videoFile)} />
  433. <SubtitleUpload
  434. onProjectCreated={backToHome}
  435. projectAudioFile={projectAudioFile}
  436. projectVideoFile={projectVideoFile}
  437. />
  438. </>
  439. )}
  440. </Container>
  441. </div>
  442. {/* API设置对话框 */}
  443. <Modal
  444. show={showApiSettings}
  445. onHide={handleCloseApiSettings}
  446. size="lg"
  447. centered
  448. >
  449. <Modal.Header closeButton>
  450. <Modal.Title>Coze API设置</Modal.Title>
  451. </Modal.Header>
  452. <Modal.Body>
  453. <CozeApiSettings onSave={handleCloseApiSettings} />
  454. </Modal.Body>
  455. </Modal>
  456. {/* 删除确认对话框 */}
  457. <Modal
  458. show={showDeleteConfirm}
  459. onHide={cancelDeleteProject}
  460. centered
  461. >
  462. <Modal.Header closeButton>
  463. <Modal.Title>确认删除</Modal.Title>
  464. </Modal.Header>
  465. <Modal.Body>
  466. {projectToDelete && (
  467. <p>您确定要删除项目 "{projectToDelete.title}" 吗?此操作将同时删除所有相关的分镜数据,且不可恢复!</p>
  468. )}
  469. </Modal.Body>
  470. <Modal.Footer>
  471. <Button variant="secondary" onClick={cancelDeleteProject}>
  472. 取消
  473. </Button>
  474. <Button
  475. variant="danger"
  476. onClick={confirmDeleteProject}
  477. disabled={loading}
  478. >
  479. {loading ? '删除中...' : '确认删除'}
  480. </Button>
  481. </Modal.Footer>
  482. </Modal>
  483. {/* 音频播放器模态框 */}
  484. <Modal
  485. show={showAudioPlayer}
  486. onHide={closeAudioPlayer}
  487. centered
  488. >
  489. <Modal.Header closeButton>
  490. <Modal.Title>音频播放</Modal.Title>
  491. </Modal.Header>
  492. <Modal.Body>
  493. {currentAudio && (
  494. <div className="audio-player-container">
  495. <p>项目音频: {currentAudio.fileName}</p>
  496. <div className="audio-player">
  497. <audio
  498. controls
  499. autoPlay
  500. className="w-100"
  501. src={`${currentAudio.fileName}`} // 实际开发中需要提供真实的音频文件URL
  502. >
  503. 您的浏览器不支持音频播放
  504. </audio>
  505. </div>
  506. <div className="text-muted mt-2">
  507. <small>注意: 这里仅展示文件名,实际应用中需要处理真实的音频文件路径</small>
  508. </div>
  509. </div>
  510. )}
  511. </Modal.Body>
  512. <Modal.Footer>
  513. <Button variant="secondary" onClick={closeAudioPlayer}>
  514. 关闭
  515. </Button>
  516. </Modal.Footer>
  517. </Modal>
  518. </>
  519. );
  520. });
  521. export default Home;