feat: MyWorks 添加 TanStack Virtual 虚拟滚动 + TTS 预估剔除标点

- 成品网格:按行虚拟滚动,>50 个时启用,ResizeObserver 动态计算列数
- 草稿列表:始终启用虚拟滚动
- TTS 积分预估:剔除标点/空白,仅统计中文字、英文、数字
This commit is contained in:
小鱼开发
2026-05-16 14:23:21 +08:00
parent 38468735e3
commit 38f314481a
2 changed files with 102 additions and 21 deletions
@@ -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#> 标记的逻辑一致)