From 38f314481a570312eee67e4e44c1ca90b32c66cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Sat, 16 May 2026 14:23:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MyWorks=20=E6=B7=BB=E5=8A=A0=20TanStack?= =?UTF-8?q?=20Virtual=20=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8=20+=20TTS=20?= =?UTF-8?q?=E9=A2=84=E4=BC=B0=E5=89=94=E9=99=A4=E6=A0=87=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 成品网格:按行虚拟滚动,>50 个时启用,ResizeObserver 动态计算列数 - 草稿列表:始终启用虚拟滚动 - TTS 积分预估:剔除标点/空白,仅统计中文字、英文、数字 --- .../src/pages/ContentManagement/MyWorks.tsx | 117 +++++++++++++++--- .../pages/VideoCreation/VoiceSynthesis.tsx | 6 +- 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/tauri-app/src/pages/ContentManagement/MyWorks.tsx b/tauri-app/src/pages/ContentManagement/MyWorks.tsx index 99ab562..aafb0f2 100644 --- a/tauri-app/src/pages/ContentManagement/MyWorks.tsx +++ b/tauri-app/src/pages/ContentManagement/MyWorks.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { invoke } from '@tauri-apps/api/core'; import { openPath } from '@tauri-apps/plugin-opener'; import { getLocalFileUrl } from '../../utils/fileUrl'; @@ -285,6 +286,43 @@ export default function MyWorks() { const [editDraftTitle, setEditDraftTitle] = useState(''); const { navigate } = useNavigation(); + // ── 虚拟滚动 ─────────────────────────────────────────────── + const draftsListRef = useRef(null); + const productsGridRef = useRef(null); + + // 成品网格列数(由容器宽度动态计算) + const [productColumns, setProductColumns] = useState(4); + + useEffect(() => { + const el = productsGridRef.current; + if (!el) return; + const ro = new ResizeObserver(entries => { + const width = entries[0].contentRect.width; + // 卡片最小 200px + gap 16px (var(--spacing-lg)) + setProductColumns(Math.max(1, Math.floor((width + 16) / 216))); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // 草稿列表虚拟滚动(列表项高度约 64px) + const draftsVirtualizer = useVirtualizer({ + count: drafts.length, + getScrollElement: () => draftsListRef.current, + estimateSize: () => 64, + overscan: 5, + }); + + // 成品网格按行虚拟滚动(仅当数量超过阈值时启用,避免少量数据的开销) + const enableProductVirtualization = products.length > 50; + const productRowCount = Math.ceil(products.length / productColumns); + const productsVirtualizer = useVirtualizer({ + count: productRowCount, + getScrollElement: () => productsGridRef.current, + estimateSize: () => 280, + overscan: 2, + }); + const loadProducts = async () => { try { const result = await invoke('list_local_products') as ApiResponse; @@ -398,10 +436,38 @@ export default function MyWorks() { {activeTab === 'products' && ( <> {products.length > 0 ? ( -
- {products.map(product => ( - - ))} +
+ {enableProductVirtualization ? ( +
+ {productsVirtualizer.getVirtualItems().map(virtualRow => { + const startIdx = virtualRow.index * productColumns; + const rowProducts = products.slice(startIdx, startIdx + productColumns); + return ( +
+
+ {rowProducts.map(product => ( + + ))} +
+
+ ); + })} +
+ ) : ( + products.map(product => ( + + )) + )}
) : (
@@ -415,22 +481,35 @@ export default function MyWorks() { )} {activeTab === 'drafts' && ( <> -
+
{drafts.length > 0 ? ( -
- {drafts.map(draft => ( - handleEditDraft(e, draft)} - onSave={saveDraftTitle} - onChange={setEditDraftTitle} - onDelete={e => openDeleteDraftModal(e, draft)} - /> - ))} +
+ {draftsVirtualizer.getVirtualItems().map(virtualItem => { + const draft = drafts[virtualItem.index]; + return ( +
+ handleEditDraft(e, draft)} + onSave={saveDraftTitle} + onChange={setEditDraftTitle} + onDelete={e => openDeleteDraftModal(e, draft)} + /> +
+ ); + })}
) : (
diff --git a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx index 6d2a757..93098c7 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx +++ b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx @@ -73,7 +73,8 @@ export default function VoiceSynthesis() { () => segments.map(s => s.voiceover?.trim() || '【空镜】').join('\n'), [segments] ); - const totalChars = mergedText.length; + // 剔除标点和空白,仅统计有效字符(中文、英文、数字) + const totalChars = mergedText.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').length; // TTS 预计积分:按 0.25 秒/字(后端配置 seconds_per_char: 0.25),除以语速倍速 // 加上镜头切换停顿(segment↔empty_shot: 0.5s,同类型: 0.3s),每 5 秒 1 积分,最低 1 积分 @@ -82,7 +83,8 @@ export default function VoiceSynthesis() { if (validSegments.length === 0) {return { min: 0, max: 0 };} // 纯朗读时间(与后端配置 seconds_per_char: 0.25 保持一致) - const totalChars = validSegments.reduce((sum, s) => sum + s.voiceover!.trim().length, 0); + // 剔除标点和空白,仅统计有效字符 + const totalChars = validSegments.reduce((sum, s) => sum + s.voiceover!.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').length, 0); const speechSeconds = (totalChars * 0.25) / (speed || 1); // 镜头切换停顿时间(与 handleGenerate 中插入 <#x#> 标记的逻辑一致)