refactor: 重构 Dashboard 页面和 ToolCard 组件,全面迁移至 shadcn 样式并优化布局

This commit is contained in:
雨霖铃
2026-05-22 21:56:09 +08:00
parent 917fca5947
commit ae0b923e11
3 changed files with 106 additions and 77 deletions
+19 -10
View File
@@ -17,7 +17,7 @@ describe('ToolCard 组件', () => {
description="这是一个测试工具"
colorKey="primary"
icon={Clock}
onClick={() => {}}
onNavigate={() => {}}
/>,
);
@@ -26,14 +26,14 @@ describe('ToolCard 组件', () => {
});
it('无描述时仅渲染标题', () => {
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onClick={() => {}} />);
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
expect(screen.getByText('仅标题')).toBeInTheDocument();
});
it('应渲染图标', () => {
const { container } = render(
<ToolCard title="带图标" colorKey="primary" icon={Clock} onClick={() => {}} />,
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
@@ -46,7 +46,7 @@ describe('ToolCard 组件', () => {
title="带快照"
colorKey="primary"
icon={Clock}
onClick={() => {}}
onNavigate={() => {}}
snapshot={<div data-testid="snapshot"></div>}
/>,
);
@@ -56,25 +56,32 @@ describe('ToolCard 组件', () => {
it('未提供快照时不渲染快照区域', () => {
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();
});
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onClick={() => {}} />);
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
const button = screen.getByRole('button', { name: /可聚焦/ });
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('tabIndex', '0');
});
});
describe('交互测试', () => {
it('点击时应调用 onClick', () => {
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: /可点击/ });
fireEvent.click(button);
@@ -84,7 +91,9 @@ describe('ToolCard 组件', () => {
it('按 Enter 键时应调用 onClick', async () => {
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: /键盘可触发/ });
await act(async () => {
@@ -99,7 +108,7 @@ describe('ToolCard 组件', () => {
describe('样式测试', () => {
it('应应用自定义颜色代码', () => {
const { container } = render(
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onClick={() => {}} />,
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
+73 -63
View File
@@ -1,24 +1,26 @@
import { ChevronRight } from 'lucide-react';
import type { ComponentType } from 'react';
import React from 'react';
import type { LucideProps } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import type { PaletteColorKey } from '@/config/features';
import { cn } from '@/lib/utils';
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '#1976d2',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
secondary: '#9c27b0',
info: '#0288d1',
primary: '13, 148, 136', // teal
success: '22, 163, 74', // green
warning: '217, 119, 6', // amber (存储清理的橙色轴)
error: '220, 38, 38', // red
secondary: '147, 51, 2 purple',
info: '37, 99, 235', // blue
};
interface ToolCardProps {
export interface ToolCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
snapshot?: React.ReactNode;
colorKey: PaletteColorKey;
icon: ComponentType<LucideProps>;
onClick: () => void;
onNavigate: () => void;
}
export default function ToolCard({
@@ -27,70 +29,78 @@ export default function ToolCard({
snapshot,
colorKey,
icon: IconComponent,
onClick,
onNavigate,
className,
...props
}: ToolCardProps) {
const colorCode = PALETTE_COLORS[colorKey];
const rgbValues = PALETTE_COLORS[colorKey];
return (
<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={{
borderColor: undefined,
['--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 = '';
['--tool-color' as string]: rgbValues,
}}
/* 💡 核心修复点:
- 坚决不用 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"
tabIndex={0}
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
className="flex items-center justify-center w-10 h-10 rounded-xl"
style={{
backgroundColor: `${colorCode}11`,
color: colorCode,
}}
>
<IconComponent size={20} />
</div>
<div>
<span className="font-bold text-sm leading-tight text-foreground flex items-center gap-1">
{title}
</span>
{description && (
<span className="block text-xs text-muted-foreground font-medium mt-1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[200px]">
{description}
</span>
)}
</div>
{/* 上半部分:核心信息交互排版轴 */}
<div className="flex items-center justify-between w-full relative min-w-0 min-h-[44px]">
<div className="flex gap-3 items-center min-w-0 flex-1 pr-2">
{/* 左侧圆形图标容器 */}
<div
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0 transition-colors duration-300',
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
'text-[rgb(var(--tool-color))]',
)}
>
<IconComponent className="h-5 w-5 shrink-0" />
</div>
{/* 中间文字描述区:利用 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}
</h4>
{description && (
<p className="text-[11px] font-medium text-muted-foreground/90 mt-0.5 leading-normal break-words">
{description}
</p>
)}
</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 }}
onMouseEnter={(e) => {
e.currentTarget.style.color = colorCode;
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '';
}}
/>
</div>
{snapshot != null && (
<div className="mt-auto pt-4 border-t border-dashed border-border w-full">{snapshot}</div>
)}
</button>
{/* 右侧指示小箭头 */}
<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">
<ChevronRight className="h-4 w-4" />
</div>
{/* 覆盖整个上半部分的绝对定位隐形跳转层(A11y 无障碍标准合规) */}
<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>
{/* 下半部分:未来的动态预览沙箱独立承载区 */}
{snapshot != null && (
<div className="mt-1 pt-3 border-t border-dashed border-border/80 w-full relative z-10 select-text">
{snapshot}
</div>
)}
</div>
);
}
+14 -4
View File
@@ -4,17 +4,27 @@ import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export default function DashboardPage() {
const { navigateTo, visiblePages, pageOrder } = useRouter();
const { t } = useTranslation(['features']);
const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]);
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
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) => {
if (!visibleSet.has(key as PageType)) return null;
if (!visibleSet.has(key)) return null;
const feature = getFeatureByKey(key);
if (!feature?.themeColorKey || feature.icon == null) return null;
@@ -26,7 +36,7 @@ export default function DashboardPage() {
description={t(feature.descriptionKey)}
colorKey={feature.themeColorKey}
icon={feature.icon}
onClick={() => navigateTo(key)}
onNavigate={() => navigateTo(key as PageType)}
/>
);
})}