refactor: 将 PageHeader 组件样式从 MUI sx 迁移至 Tailwind 类名,并更新测试
This commit is contained in:
+61
-59
@@ -1,13 +1,15 @@
|
|||||||
import { ReactNode, useMemo } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
import { getEntryPointType } from '@/config/features';
|
import { getEntryPointType } from '@/config/features';
|
||||||
|
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
||||||
|
|
||||||
/**
|
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
* PageHeader 组件属性接口
|
|
||||||
*/
|
|
||||||
export interface PageHeaderProps {
|
|
||||||
/** 要显示的图标组件 */
|
/** 要显示的图标组件 */
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
/** 图标的颜色,默认使用主题 primary.main 色 */
|
/**
|
||||||
|
* 图标的颜色,支持:
|
||||||
|
* 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
|
||||||
|
* 2. 原生颜色值 (如 '#3b82f6')
|
||||||
|
*/
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
/** 主标题文本 */
|
/** 主标题文本 */
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,86 +17,86 @@ export interface PageHeaderProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
/** 在标题右侧显示的徽章/标签组件(可选) */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
/** 图标容器的自定义样式 */
|
/** 覆盖图标容器的类名 */
|
||||||
iconSx?: React.CSSProperties;
|
iconClassName?: string;
|
||||||
/** 标题文本的自定义样式 */
|
/** 覆盖主标题的类名 */
|
||||||
titleSx?: React.CSSProperties;
|
titleClassName?: string;
|
||||||
/** 副标题文本的自定义样式 */
|
/** 覆盖副标题的类名 */
|
||||||
subtitleSx?: React.CSSProperties;
|
subtitleClassName?: string;
|
||||||
/** 整个组件的自定义样式 */
|
|
||||||
sx?: React.CSSProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PageHeader - 通用页面标题栏组件
|
|
||||||
*
|
|
||||||
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<AccessTimeIcon />}
|
|
||||||
* iconColor="#1976d2"
|
|
||||||
* title="时间戳转换"
|
|
||||||
* subtitle="Unix 毫秒数转换与格式化"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<StorageIcon />}
|
|
||||||
* iconColor={storageCleanerPageStyles.warningColor}
|
|
||||||
* title="存储清理"
|
|
||||||
* subtitle={domain}
|
|
||||||
* badge={<Badge>已占用 {size}</Badge>}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default function PageHeader({
|
export default function PageHeader({
|
||||||
icon,
|
icon,
|
||||||
iconColor = '#3b82f6',
|
iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
badge,
|
badge,
|
||||||
iconSx,
|
iconClassName,
|
||||||
titleSx,
|
titleClassName,
|
||||||
subtitleSx,
|
subtitleClassName,
|
||||||
sx,
|
className,
|
||||||
|
...props
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
const entryPointType = useMemo(() => getEntryPointType(), []);
|
const entryPointType = useMemo(() => getEntryPointType(), []);
|
||||||
|
|
||||||
|
// 扩展环境判断:如果是 popup 形式则不渲染头部
|
||||||
if (entryPointType === 'popup') {
|
if (entryPointType === 'popup') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断传入的是否是 Hex/RGB 等原生颜色值
|
||||||
|
const isRawColor =
|
||||||
|
iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 mb-6" style={sx}>
|
<div className={cn('flex items-center gap-3 mb-6', className)} {...props}>
|
||||||
{/* 图标容器 */}
|
{/* 图标容器 */}
|
||||||
<div
|
<div
|
||||||
className="p-2 rounded-lg flex items-center"
|
className={cn(
|
||||||
style={{
|
'p-2 rounded-lg flex items-center justify-center shrink-0',
|
||||||
backgroundColor: `${iconColor}15`,
|
// 如果不是原生颜色,直接当作 Tailwind 类名注入
|
||||||
|
!isRawColor && iconColor,
|
||||||
|
iconClassName,
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isRawColor
|
||||||
|
? {
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
...iconSx,
|
// 使用 CSS inline 变量或 color-mix 安全处理透明度,不再暴力拼接 "15"
|
||||||
}}
|
backgroundColor: `color-mix(in srgb, ${iconColor} 8%, transparent)`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
|
||||||
|
<div className="[&>svg]:h-5 [&>svg]:w-5">{icon}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||||
{/* 标题行(含徽章) */}
|
{/* 标题行(含徽章) */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center gap-2">
|
||||||
<span className="text-base font-extrabold tracking-tight leading-tight" style={titleSx}>
|
<h1
|
||||||
|
className={cn(
|
||||||
|
'text-base font-extrabold tracking-tight leading-tight text-foreground truncate',
|
||||||
|
titleClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</h1>
|
||||||
{badge}
|
{badge && <div className="shrink-0">{badge}</div>}
|
||||||
</div>
|
</div>
|
||||||
{/* 副标题 */}
|
|
||||||
|
{/* 副标题 - 使用 p 标签(block)保证换行 */}
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<span className="text-xs font-semibold text-muted-foreground" style={subtitleSx}>
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-semibold text-muted-foreground truncate',
|
||||||
|
subtitleClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,9 +47,8 @@ describe('PageHeader 组件系统', () => {
|
|||||||
|
|
||||||
it('应默认使用蓝色作为 primary 色', () => {
|
it('应默认使用蓝色作为 primary 色', () => {
|
||||||
render(<PageHeader {...defaultProps} />);
|
render(<PageHeader {...defaultProps} />);
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement;
|
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
||||||
expect(iconContainer).toHaveStyle('background-color: #3b82f615');
|
expect(iconContainer).toHaveClass('text-blue-500');
|
||||||
expect(iconContainer).toHaveStyle('color: #3b82f6');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
it('应渲染 badge 组件', () => {
|
||||||
@@ -58,28 +57,28 @@ describe('PageHeader 组件系统', () => {
|
|||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应支持自定义 iconSx', () => {
|
it('应支持自定义 iconClassName', () => {
|
||||||
render(<PageHeader {...defaultProps} iconSx={{ borderRadius: '8px' }} />);
|
render(<PageHeader {...defaultProps} iconClassName="custom-icon-class" />);
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement;
|
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
||||||
expect(iconContainer).toHaveStyle('border-radius: 8px');
|
expect(iconContainer).toHaveClass('custom-icon-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应支持自定义 titleSx', () => {
|
it('应支持自定义 titleClassName', () => {
|
||||||
render(<PageHeader {...defaultProps} titleSx={{ fontSize: '1.2rem' }} />);
|
render(<PageHeader {...defaultProps} titleClassName="custom-title-class" />);
|
||||||
const title = screen.getByText('时间戳转换');
|
const title = screen.getByText('时间戳转换');
|
||||||
expect(title).toHaveStyle('font-size: 1.2rem');
|
expect(title).toHaveClass('custom-title-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应支持自定义 subtitleSx', () => {
|
it('应支持自定义 subtitleClassName', () => {
|
||||||
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
render(<PageHeader {...defaultProps} subtitleClassName="custom-subtitle-class" />);
|
||||||
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
|
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
|
||||||
expect(subtitle).toHaveStyle('color: rgb(255, 0, 0)');
|
expect(subtitle).toHaveClass('custom-subtitle-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应支持自定义 sx', () => {
|
it('应支持自定义 className', () => {
|
||||||
const { container } = render(<PageHeader {...defaultProps} sx={{ marginBottom: '2rem' }} />);
|
const { container } = render(<PageHeader {...defaultProps} className="custom-page-header" />);
|
||||||
const outerElement = container.firstChild;
|
const outerElement = container.firstChild;
|
||||||
expect(outerElement).toHaveStyle('margin-bottom: 2rem');
|
expect(outerElement).toHaveClass('custom-page-header');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('无副标题时不渲染副标题区域', () => {
|
it('无副标题时不渲染副标题区域', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user