fix: 统一字段列表卡片高度,最大字段数改为40

- MAX_FIELDS: 20 → 40
- 固定卡片高度52px,无论虚拟/非虚拟模式视觉一致
- 5条以上容器高度固定,启用虚拟滚动
- 主页面添加字段时检查MAX_FIELDS限制
This commit is contained in:
雨霖铃
2026-06-06 09:58:57 +08:00
parent 49e7f1ea55
commit af0b2d8ed1
2 changed files with 110 additions and 23 deletions
@@ -1,8 +1,10 @@
/**
* 字段列表组件
* 展示所有字段配置,支持添加、删除、拖拽排序
* 超过 5 条时启用虚拟列表滚动
*/
import { useState, useRef, useCallback } from 'react';
import { Plus, GripVertical, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/utils/chromeI18n';
@@ -25,6 +27,15 @@ import { CSS } from '@dnd-kit/utilities';
import type { FieldConfig } from '@/types/testDataGenerator';
import FieldItem from './FieldItem';
/** 最大字段数量 */
export const MAX_FIELDS = 40;
/** 虚拟列表配置 */
const VISIBLE_COUNT = 5;
const ROW_HEIGHT = 60; // 卡片高度 52 + 间距 8
const ROW_GAP = 8;
const OVERSCAN = 2;
interface FieldListProps {
fields: FieldConfig[];
onUpdate: (index: number, field: FieldConfig) => void;
@@ -56,6 +67,7 @@ function SortableFieldItem({
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 0,
height: ROW_HEIGHT - ROW_GAP, // 固定卡片高度 52px,与虚拟模式一致
};
return (
@@ -70,22 +82,24 @@ function SortableFieldItem({
: 'border-border hover:border-muted-foreground/30'
}`}
>
<div className="flex items-center gap-2 p-3">
<div className="flex items-center gap-2 h-full px-3 py-2">
{/* 拖拽手柄 */}
<button
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 touch-none"
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 touch-none shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<div className="flex-1 min-w-0">
<FieldItem field={field} onClick={onSelect} isSelected={isSelected} />
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
@@ -105,6 +119,8 @@ export default function FieldList({
onReorder,
}: FieldListProps) {
const { t } = useI18n('testDataGenerator');
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -126,19 +142,50 @@ export default function FieldList({
}
};
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
}, []);
const isVirtual = fields.length > VISIBLE_COUNT;
const totalHeight = fields.length * ROW_HEIGHT;
const fixedContainerHeight = VISIBLE_COUNT * ROW_HEIGHT;
// 少于 5 条时高度自适应,5 条及以上固定为 5 条的高度
const containerStyle =
fields.length < VISIBLE_COUNT
? { height: 'auto' as const, overflowY: 'visible' as const }
: { height: fixedContainerHeight, overflowY: 'auto' as const };
// 虚拟列表:计算可见范围
const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const visibleEnd = Math.min(
fields.length,
Math.ceil((scrollTop + fixedContainerHeight) / ROW_HEIGHT) + OVERSCAN,
);
const visibleFields = isVirtual ? fields.slice(visibleStart, visibleEnd) : fields;
const isMaxReached = fields.length >= MAX_FIELDS;
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground">
{t('testDataGenerator_fields')} ({fields.length})
{t('testDataGenerator_fields')} ({fields.length}/{MAX_FIELDS})
</h3>
<Button variant="outline" size="sm" onClick={onAdd} className="h-8 gap-1.5">
<Button
variant="outline"
size="sm"
onClick={onAdd}
disabled={isMaxReached}
className="h-8 gap-1.5"
>
<Plus className="h-4 w-4" />
{t('testDataGenerator_addField')}
</Button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 min-h-0">
<div className="flex-1 min-h-0">
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<GripVertical className="h-10 w-10 text-muted-foreground/40 mb-3" />
@@ -154,6 +201,48 @@ export default function FieldList({
onDragEnd={handleDragEnd}
>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
<div
ref={containerRef}
className="overflow-y-auto"
style={containerStyle}
onScroll={handleScroll}
>
{isVirtual ? (
/* 虚拟滚动模式 */
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: visibleStart * ROW_HEIGHT,
left: 0,
right: 0,
}}
>
{visibleFields.map((field, vi) => {
const realIndex = fields.findIndex((f) => f.id === field.id);
const isLast = visibleStart + vi === fields.length - 1;
return (
<div
key={field.id}
style={{
height: ROW_HEIGHT,
paddingBottom: isLast ? 0 : ROW_GAP,
}}
>
<SortableFieldItem
field={field}
isSelected={selectedIndex === realIndex}
onSelect={() => onSelect(realIndex)}
onRemove={() => onRemove(realIndex)}
/>
</div>
);
})}
</div>
</div>
) : (
/* 普通模式(≤5 条) */
<div className="space-y-2">
{fields.map((field, index) => (
<SortableFieldItem
key={field.id}
@@ -163,6 +252,9 @@ export default function FieldList({
onRemove={() => onRemove(index)}
/>
))}
</div>
)}
</div>
</SortableContext>
</DndContext>
)}
+2 -7
View File
@@ -15,7 +15,7 @@ function generateId(): string {
return `field_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
import FieldList from './components/FieldList';
import FieldList, { MAX_FIELDS } from './components/FieldList';
import FieldEditor from './components/FieldEditor';
import GenerateOptions from './components/GenerateOptions';
import GenerateButton from './components/GenerateButton';
@@ -98,6 +98,7 @@ export default function TestDataGeneratorPage() {
// 添加新字段
const handleAddField = useCallback(() => {
if (fields.length >= MAX_FIELDS) return;
const newField: FieldConfig = {
id: generateId(),
name: `field${fields.length + 1}`,
@@ -178,12 +179,6 @@ export default function TestDataGeneratorPage() {
return (
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
<div className="max-w-7xl mx-auto p-4 sm:p-6">
{/* 页面标题 */}
<div className="mb-6">
<h1 className="text-xl font-semibold text-foreground">{t('testDataGenerator_title')}</h1>
<p className="text-sm text-muted-foreground mt-1">{t('testDataGenerator_description')}</p>
</div>
{/* 主要内容区域 - 左右分栏 */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 items-start">
{/* 左侧面板 - 字段配置 */}