refactor: 重构 Dashboard 页面和 ToolCard 组件,全面迁移至 shadcn 样式并优化布局
This commit is contained in:
@@ -17,7 +17,7 @@ describe('ToolCard 组件', () => {
|
|||||||
description="这是一个测试工具"
|
description="这是一个测试工具"
|
||||||
colorKey="primary"
|
colorKey="primary"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
onClick={() => {}}
|
onNavigate={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -26,14 +26,14 @@ describe('ToolCard 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('无描述时仅渲染标题', () => {
|
it('无描述时仅渲染标题', () => {
|
||||||
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onClick={() => {}} />);
|
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
|
|
||||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
it('应渲染图标', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard title="带图标" colorKey="primary" icon={Clock} onClick={() => {}} />,
|
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const svgElement = container.querySelector('svg');
|
const svgElement = container.querySelector('svg');
|
||||||
@@ -46,7 +46,7 @@ describe('ToolCard 组件', () => {
|
|||||||
title="带快照"
|
title="带快照"
|
||||||
colorKey="primary"
|
colorKey="primary"
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
onClick={() => {}}
|
onNavigate={() => {}}
|
||||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -56,25 +56,32 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
it('未提供快照时不渲染快照区域', () => {
|
it('未提供快照时不渲染快照区域', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard title="无快照" colorKey="primary" icon={Clock} onClick={() => {}} />,
|
<ToolCard
|
||||||
|
title="无快照"
|
||||||
|
colorKey="primary"
|
||||||
|
icon={Clock}
|
||||||
|
onClick={() => {}}
|
||||||
|
onNavigate={function (): void {
|
||||||
|
throw new Error('Function not implemented.');
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||||
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onClick={() => {}} />);
|
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
expect(button).toHaveAttribute('tabIndex', '0');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('交互测试', () => {
|
describe('交互测试', () => {
|
||||||
it('点击时应调用 onClick', () => {
|
it('点击时应调用 onClick', () => {
|
||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onClick={handleClick} />);
|
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /可点击/ });
|
const button = screen.getByRole('button', { name: /可点击/ });
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
@@ -84,7 +91,9 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
it('按 Enter 键时应调用 onClick', async () => {
|
it('按 Enter 键时应调用 onClick', async () => {
|
||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
render(<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onClick={handleClick} />);
|
render(
|
||||||
|
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -99,7 +108,7 @@ describe('ToolCard 组件', () => {
|
|||||||
describe('样式测试', () => {
|
describe('样式测试', () => {
|
||||||
it('应应用自定义颜色代码', () => {
|
it('应应用自定义颜色代码', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onClick={() => {}} />,
|
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const svgElement = container.querySelector('svg');
|
const svgElement = container.querySelector('svg');
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
import React from 'react';
|
||||||
import type { LucideProps } from 'lucide-react';
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
import type { PaletteColorKey } from '@/config/features';
|
import type { PaletteColorKey } from '@/config/features';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||||
primary: '#1976d2',
|
primary: '13, 148, 136', // teal
|
||||||
success: '#2e7d32',
|
success: '22, 163, 74', // green
|
||||||
warning: '#e65100',
|
warning: '217, 119, 6', // amber (存储清理的橙色轴)
|
||||||
error: '#c62828',
|
error: '220, 38, 38', // red
|
||||||
secondary: '#9c27b0',
|
secondary: '147, 51, 2 purple',
|
||||||
info: '#0288d1',
|
info: '37, 99, 235', // blue
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ToolCardProps {
|
export interface ToolCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
snapshot?: React.ReactNode;
|
snapshot?: React.ReactNode;
|
||||||
colorKey: PaletteColorKey;
|
colorKey: PaletteColorKey;
|
||||||
icon: ComponentType<LucideProps>;
|
icon: ComponentType<LucideProps>;
|
||||||
onClick: () => void;
|
onNavigate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ToolCard({
|
export default function ToolCard({
|
||||||
@@ -27,70 +29,78 @@ export default function ToolCard({
|
|||||||
snapshot,
|
snapshot,
|
||||||
colorKey,
|
colorKey,
|
||||||
icon: IconComponent,
|
icon: IconComponent,
|
||||||
onClick,
|
onNavigate,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
}: ToolCardProps) {
|
}: ToolCardProps) {
|
||||||
const colorCode = PALETTE_COLORS[colorKey];
|
const rgbValues = PALETTE_COLORS[colorKey];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative rounded-2xl border border-border h-full box-border transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:-translate-y-1"
|
|
||||||
style={{
|
style={{
|
||||||
borderColor: undefined,
|
['--tool-color' as string]: rgbValues,
|
||||||
['--hover-color' as string]: colorCode,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = colorCode;
|
|
||||||
e.currentTarget.style.boxShadow = `0 12px 24px -10px ${colorCode}33`;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '';
|
|
||||||
e.currentTarget.style.boxShadow = '';
|
|
||||||
}}
|
}}
|
||||||
|
/* 💡 核心修复点:
|
||||||
|
- 坚决不用 h-full 或固定高度,锁死 h-auto(高度自适应流),配合 py-4 px-4 牢牢把内容包裹在卡片体内。
|
||||||
|
- 废除原先会乱飘的内联 style 属性擦写,全权放权给 Tailwind 的声明式 hover 变体。
|
||||||
|
*/
|
||||||
|
className={cn(
|
||||||
|
'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border',
|
||||||
|
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
|
'hover:bg-muted/30',
|
||||||
|
'hover:border-[rgba(var(--tool-color),0.45)]',
|
||||||
|
'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<button
|
{/* 上半部分:核心信息交互排版轴 */}
|
||||||
type="button"
|
<div className="flex items-center justify-between w-full relative min-w-0 min-h-[44px]">
|
||||||
tabIndex={0}
|
<div className="flex gap-3 items-center min-w-0 flex-1 pr-2">
|
||||||
onClick={onClick}
|
{/* 左侧圆形图标容器 */}
|
||||||
className="h-full flex flex-col items-stretch justify-start p-5 gap-3 w-full text-left cursor-pointer bg-transparent border-none"
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-start w-full">
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
className={cn(
|
||||||
style={{
|
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0 transition-colors duration-300',
|
||||||
backgroundColor: `${colorCode}11`,
|
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
|
||||||
color: colorCode,
|
'text-[rgb(var(--tool-color))]',
|
||||||
}}
|
)}
|
||||||
>
|
>
|
||||||
<IconComponent size={20} />
|
<IconComponent className="h-5 w-5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="font-bold text-sm leading-tight text-foreground flex items-center gap-1">
|
{/* 中间文字描述区:利用 flex-1 min-w-0 防御文本过长发生恶性撑开 */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
|
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</h4>
|
||||||
{description && (
|
{description && (
|
||||||
<span className="block text-xs text-muted-foreground font-medium mt-1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[200px]">
|
<p className="text-[11px] font-medium text-muted-foreground/90 mt-0.5 leading-normal break-words">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight
|
|
||||||
className="w-3 h-3 text-muted-foreground mt-1 transition-all duration-300 ease-in-out group-hover:translate-x-1"
|
{/* 右侧指示小箭头 */}
|
||||||
style={{ color: undefined }}
|
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 transition-all duration-300 ease-in-out group-hover:translate-x-0.5">
|
||||||
onMouseEnter={(e) => {
|
<ChevronRight className="h-4 w-4" />
|
||||||
e.currentTarget.style.color = colorCode;
|
</div>
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
{/* 覆盖整个上半部分的绝对定位隐形跳转层(A11y 无障碍标准合规) */}
|
||||||
e.currentTarget.style.color = '';
|
<button
|
||||||
}}
|
type="button"
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-label={`进入 ${title}`}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer bg-transparent border-none opacity-0 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 下半部分:未来的动态预览沙箱独立承载区 */}
|
||||||
{snapshot != null && (
|
{snapshot != null && (
|
||||||
<div className="mt-auto pt-4 border-t border-dashed border-border w-full">{snapshot}</div>
|
<div className="mt-1 pt-3 border-t border-dashed border-border/80 w-full relative z-10 select-text">
|
||||||
|
{snapshot}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,27 @@ import { getFeatureByKey } from '@/config/features';
|
|||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
||||||
const { t } = useTranslation(['features']);
|
const { t } = useTranslation(['features']);
|
||||||
|
|
||||||
const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]);
|
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-2 p-2">
|
/* 💡 核心修复点:
|
||||||
|
- 彻底移除限制死高度的 auto-rows-fr,换用弹性自适应的 auto-rows-auto。
|
||||||
|
- 将 gap-2 / p-2 扩展为标准的 gap-3.5 / p-3.5,彻底消灭挤压颤噪。
|
||||||
|
*/
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-full',
|
||||||
|
'animate-in fade-in duration-300 select-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{pageOrder.map((key) => {
|
{pageOrder.map((key) => {
|
||||||
if (!visibleSet.has(key as PageType)) return null;
|
if (!visibleSet.has(key)) return null;
|
||||||
|
|
||||||
const feature = getFeatureByKey(key);
|
const feature = getFeatureByKey(key);
|
||||||
if (!feature?.themeColorKey || feature.icon == null) return null;
|
if (!feature?.themeColorKey || feature.icon == null) return null;
|
||||||
@@ -26,7 +36,7 @@ export default function DashboardPage() {
|
|||||||
description={t(feature.descriptionKey)}
|
description={t(feature.descriptionKey)}
|
||||||
colorKey={feature.themeColorKey}
|
colorKey={feature.themeColorKey}
|
||||||
icon={feature.icon}
|
icon={feature.icon}
|
||||||
onClick={() => navigateTo(key)}
|
onNavigate={() => navigateTo(key as PageType)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user