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="这是一个测试工具" 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');
+73 -63
View File
@@ -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={cn(
<div className="flex justify-between items-start w-full"> 'flex items-center justify-center w-10 h-10 rounded-xl shrink-0 transition-colors duration-300',
<div className="flex gap-3 items-center"> 'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
<div 'text-[rgb(var(--tool-color))]',
className="flex items-center justify-center w-10 h-10 rounded-xl" )}
style={{ >
backgroundColor: `${colorCode}11`, <IconComponent className="h-5 w-5 shrink-0" />
color: colorCode, </div>
}}
> {/* 中间文字描述区:利用 flex-1 min-w-0 防御文本过长发生恶性撑开 */}
<IconComponent size={20} /> <div className="flex-1 min-w-0 flex flex-col">
</div> <h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
<div> {title}
<span className="font-bold text-sm leading-tight text-foreground flex items-center gap-1"> </h4>
{title} {description && (
</span> <p className="text-[11px] font-medium text-muted-foreground/90 mt-0.5 leading-normal break-words">
{description && ( {description}
<span className="block text-xs text-muted-foreground font-medium mt-1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[200px]"> </p>
{description} )}
</span>
)}
</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 }}
onMouseEnter={(e) => {
e.currentTarget.style.color = colorCode;
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '';
}}
/>
</div> </div>
{snapshot != null && ( {/* 右侧指示小箭头 */}
<div className="mt-auto pt-4 border-t border-dashed border-border w-full">{snapshot}</div> <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" />
</button> </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> </div>
); );
} }
+14 -4
View File
@@ -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)}
/> />
); );
})} })}