feat: MyWorks 添加 TanStack Virtual 虚拟滚动 + TTS 预估剔除标点
- 成品网格:按行虚拟滚动,>50 个时启用,ResizeObserver 动态计算列数 - 草稿列表:始终启用虚拟滚动 - TTS 积分预估:剔除标点/空白,仅统计中文字、英文、数字
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
const productsGridRef = useRef<HTMLDivElement>(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<ProductItem[]>;
|
||||
@@ -398,10 +436,38 @@ export default function MyWorks() {
|
||||
{activeTab === 'products' && (
|
||||
<>
|
||||
{products.length > 0 ? (
|
||||
<div className="works-grid">
|
||||
{products.map(product => (
|
||||
<ProductCard key={product.path} product={product} onDelete={openDeleteModal} onRename={handleRenameProduct} />
|
||||
))}
|
||||
<div ref={productsGridRef} className="works-grid" style={{ overflow: 'auto', flex: 1, minHeight: 0, display: enableProductVirtualization ? 'block' : undefined }}>
|
||||
{enableProductVirtualization ? (
|
||||
<div style={{ height: `${productsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}>
|
||||
{productsVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const startIdx = virtualRow.index * productColumns;
|
||||
const rowProducts = products.slice(startIdx, startIdx + productColumns);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${productColumns}, 1fr)`, gap: 'var(--spacing-lg)', height: '100%' }}>
|
||||
{rowProducts.map(product => (
|
||||
<ProductCard key={product.path} product={product} onDelete={openDeleteModal} onRename={handleRenameProduct} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
products.map(product => (
|
||||
<ProductCard key={product.path} product={product} onDelete={openDeleteModal} onRename={handleRenameProduct} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state-page">
|
||||
@@ -415,22 +481,35 @@ export default function MyWorks() {
|
||||
)}
|
||||
{activeTab === 'drafts' && (
|
||||
<>
|
||||
<div className="drafts-list-container">
|
||||
<div ref={draftsListRef} className="drafts-list-container" style={{ overflow: 'auto', flex: 1, minHeight: 0 }}>
|
||||
{drafts.length > 0 ? (
|
||||
<div className="drafts-list">
|
||||
{drafts.map(draft => (
|
||||
<DraftListItem
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
onClick={handleOpenDraft}
|
||||
isEditing={editingDraftId === draft.id}
|
||||
editValue={editDraftTitle}
|
||||
onEdit={e => handleEditDraft(e, draft)}
|
||||
onSave={saveDraftTitle}
|
||||
onChange={setEditDraftTitle}
|
||||
onDelete={e => openDeleteDraftModal(e, draft)}
|
||||
/>
|
||||
))}
|
||||
<div style={{ height: `${draftsVirtualizer.getTotalSize()}px`, position: 'relative', width: '100%' }}>
|
||||
{draftsVirtualizer.getVirtualItems().map(virtualItem => {
|
||||
const draft = drafts[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<DraftListItem
|
||||
draft={draft}
|
||||
onClick={handleOpenDraft}
|
||||
isEditing={editingDraftId === draft.id}
|
||||
editValue={editDraftTitle}
|
||||
onEdit={e => handleEditDraft(e, draft)}
|
||||
onSave={saveDraftTitle}
|
||||
onChange={setEditDraftTitle}
|
||||
onDelete={e => openDeleteDraftModal(e, draft)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state-full">
|
||||
|
||||
@@ -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#> 标记的逻辑一致)
|
||||
|
||||
Reference in New Issue
Block a user