重写所有页面和组件:用纯 Tailwind CSS 替换 MUI 组件和图标

This commit is contained in:
雨霖铃
2026-05-22 00:51:11 +08:00
parent 1bc3ee59a0
commit e2dd5f5915
8 changed files with 763 additions and 680 deletions
+10 -23
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import ToolCard from '@/pages/Dashboard/ToolCard'; import ToolCard from '@/pages/Dashboard/ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { Clock } from 'lucide-react';
describe('ToolCard 组件', () => { describe('ToolCard 组件', () => {
beforeEach(() => { beforeEach(() => {
@@ -16,7 +16,7 @@ describe('ToolCard 组件', () => {
title="测试工具" title="测试工具"
description="这是一个测试工具" description="这是一个测试工具"
colorKey="primary" colorKey="primary"
icon={AccessTimeIcon} icon={Clock}
onClick={() => {}} onClick={() => {}}
/>, />,
); );
@@ -26,16 +26,14 @@ describe('ToolCard 组件', () => {
}); });
it('无描述时仅渲染标题', () => { it('无描述时仅渲染标题', () => {
render( render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onClick={() => {}} />);
<ToolCard title="仅标题" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
);
expect(screen.getByText('仅标题')).toBeInTheDocument(); expect(screen.getByText('仅标题')).toBeInTheDocument();
}); });
it('应渲染图标', () => { it('应渲染图标', () => {
const { container } = render( const { container } = render(
<ToolCard title="带图标" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />, <ToolCard title="带图标" colorKey="primary" icon={Clock} onClick={() => {}} />,
); );
const svgElement = container.querySelector('svg'); const svgElement = container.querySelector('svg');
@@ -47,7 +45,7 @@ describe('ToolCard 组件', () => {
<ToolCard <ToolCard
title="带快照" title="带快照"
colorKey="primary" colorKey="primary"
icon={AccessTimeIcon} icon={Clock}
onClick={() => {}} onClick={() => {}}
snapshot={<div data-testid="snapshot"></div>} snapshot={<div data-testid="snapshot"></div>}
/>, />,
@@ -58,16 +56,14 @@ describe('ToolCard 组件', () => {
it('未提供快照时不渲染快照区域', () => { it('未提供快照时不渲染快照区域', () => {
const { container } = render( const { container } = render(
<ToolCard title="无快照" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />, <ToolCard title="无快照" colorKey="primary" icon={Clock} onClick={() => {}} />,
); );
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
}); });
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => { it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render( render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onClick={() => {}} />);
<ToolCard title="可聚焦" colorKey="primary" icon={AccessTimeIcon} onClick={() => {}} />,
);
const button = screen.getByRole('button', { name: /可聚焦/ }); const button = screen.getByRole('button', { name: /可聚焦/ });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
@@ -78,9 +74,7 @@ describe('ToolCard 组件', () => {
describe('交互测试', () => { describe('交互测试', () => {
it('点击时应调用 onClick', () => { it('点击时应调用 onClick', () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render( render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onClick={handleClick} />);
<ToolCard title="可点击" colorKey="primary" icon={AccessTimeIcon} onClick={handleClick} />,
);
const button = screen.getByRole('button', { name: /可点击/ }); const button = screen.getByRole('button', { name: /可点击/ });
fireEvent.click(button); fireEvent.click(button);
@@ -90,14 +84,7 @@ describe('ToolCard 组件', () => {
it('按 Enter 键时应调用 onClick', async () => { it('按 Enter 键时应调用 onClick', async () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render( render(<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onClick={handleClick} />);
<ToolCard
title="键盘可触发"
colorKey="primary"
icon={AccessTimeIcon}
onClick={handleClick}
/>,
);
const button = screen.getByRole('button', { name: /键盘可触发/ }); const button = screen.getByRole('button', { name: /键盘可触发/ });
await act(async () => { await act(async () => {
@@ -112,7 +99,7 @@ describe('ToolCard 组件', () => {
describe('样式测试', () => { describe('样式测试', () => {
it('应应用自定义颜色代码', () => { it('应应用自定义颜色代码', () => {
const { container } = render( const { container } = render(
<ToolCard title="自定义颜色" colorKey="warning" icon={AccessTimeIcon} onClick={() => {}} />, <ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onClick={() => {}} />,
); );
const svgElement = container.querySelector('svg'); const svgElement = container.querySelector('svg');
+21 -34
View File
@@ -1,15 +1,17 @@
import { type ComponentType, lazy } from 'react'; import { type ComponentType, lazy } from 'react';
import type { SvgIconProps } from '@mui/material/SvgIcon'; import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { Clock } from 'lucide-react'; import {
import StorageIcon from '@mui/icons-material/Storage'; Clock,
import QrCodeIcon from '@mui/icons-material/QrCode'; Database,
import DescriptionIcon from '@mui/icons-material/Description'; QrCode,
import VpnKeyIcon from '@mui/icons-material/VpnKey'; FileText,
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; Key,
import TransformIcon from '@mui/icons-material/Transform'; GitCompareArrows,
import CodeIcon from '@mui/icons-material/Code'; ArrowLeftRight,
import ArticleIcon from '@mui/icons-material/Article'; Code,
File,
} from 'lucide-react';
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
@@ -25,31 +27,16 @@ const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml')); const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown')); const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
/**
* 功能配置接口
*
* 整合了路由信息和仪表盘卡片元数据,作为功能的单一事实来源
*/
export interface FeatureConfig { export interface FeatureConfig {
/** 页面类型标识 */
key: PageType; key: PageType;
/** 功能名称翻译键 */
labelKey: string; labelKey: string;
/** 功能描述翻译键 */
descriptionKey: string; descriptionKey: string;
/** 主题颜色键(用于仪表盘卡片,映射到 theme.palette[key].main */
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
/** 图标组件引用(用于仪表盘卡片,按需实例化) */ icon?: ComponentType<LucideProps>;
icon?: ComponentType<SvgIconProps>;
/** 默认是否在仪表盘显示 */
defaultVisible: boolean; defaultVisible: boolean;
/** 不同显示模式对应的组件 */
components: { components: {
/** 弹窗模式组件 */
popup: ComponentType; popup: ComponentType;
/** 侧边栏模式组件 */
sidepanel: ComponentType; sidepanel: ComponentType;
/** 标签页模式组件 */
tab: ComponentType; tab: ComponentType;
}; };
} }
@@ -84,7 +71,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:storageCleaner.title', labelKey: 'features:storageCleaner.title',
descriptionKey: 'features:storageCleaner.description', descriptionKey: 'features:storageCleaner.description',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: StorageIcon, icon: Database,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: StorageCleanerPage, popup: StorageCleanerPage,
@@ -97,7 +84,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:qrCode.title', labelKey: 'features:qrCode.title',
descriptionKey: 'features:qrCode.description', descriptionKey: 'features:qrCode.description',
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCodeIcon, icon: QrCode,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: QrCodePage, popup: QrCodePage,
@@ -110,7 +97,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:textStatistics.title', labelKey: 'features:textStatistics.title',
descriptionKey: 'features:textStatistics.description', descriptionKey: 'features:textStatistics.description',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: DescriptionIcon, icon: FileText,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: TextStatisticsPage, popup: TextStatisticsPage,
@@ -123,7 +110,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:jwt.title', labelKey: 'features:jwt.title',
descriptionKey: 'features:jwt.description', descriptionKey: 'features:jwt.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: VpnKeyIcon, icon: Key,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: JwtPage, popup: JwtPage,
@@ -136,7 +123,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:jsonDiff.title', labelKey: 'features:jsonDiff.title',
descriptionKey: 'features:jsonDiff.description', descriptionKey: 'features:jsonDiff.description',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: CompareArrowsIcon, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: JsonToolsPage, popup: JsonToolsPage,
@@ -149,7 +136,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:base64Converter.title', labelKey: 'features:base64Converter.title',
descriptionKey: 'features:base64Converter.description', descriptionKey: 'features:base64Converter.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: TransformIcon, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: Base64ConverterPage, popup: Base64ConverterPage,
@@ -162,7 +149,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:markdownToHtml.title', labelKey: 'features:markdownToHtml.title',
descriptionKey: 'features:markdownToHtml.description', descriptionKey: 'features:markdownToHtml.description',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: CodeIcon, icon: Code,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: MarkdownToHtmlPage, popup: MarkdownToHtmlPage,
@@ -175,7 +162,7 @@ export const FEATURES: FeatureConfig[] = [
labelKey: 'features:htmlToMarkdown.title', labelKey: 'features:htmlToMarkdown.title',
descriptionKey: 'features:htmlToMarkdown.description', descriptionKey: 'features:htmlToMarkdown.description',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: ArticleIcon, icon: File,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: HtmlToMarkdownPage, popup: HtmlToMarkdownPage,
+95 -206
View File
@@ -1,20 +1,5 @@
import { SyntheticEvent, useEffect, useMemo, useState } from 'react'; import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { import { Settings, RefreshCw, GripVertical } from 'lucide-react';
alpha,
Box,
CircularProgress,
IconButton,
Paper,
Stack,
Switch,
Tab,
Tabs,
Tooltip,
Typography,
} from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import RefreshIcon from '@mui/icons-material/Refresh';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -43,12 +28,19 @@ import {
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { useTheme, type PaletteColor, type Theme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { PaletteColorKey } from '@/config/features'; import type { PaletteColorKey } from '@/config/features';
const getPaletteColor = (theme: Theme, key: PaletteColorKey): PaletteColor => const PALETTE_COLORS: Record<PaletteColorKey, string> = {
(theme.palette as unknown as Record<string, PaletteColor>)[key]; primary: '#1976d2',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
secondary: '#9c27b0',
info: '#0288d1',
};
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
const isValidPage = (page: unknown): page is PageType => { const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page); return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
@@ -75,7 +67,6 @@ function SortableFeatureRow({
isDisabled, isDisabled,
onToggle, onToggle,
}: SortableFeatureRowProps) { }: SortableFeatureRowProps) {
const theme = useTheme();
const { t } = useTranslation(['features']); const { t } = useTranslation(['features']);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pageKey, id: pageKey,
@@ -85,131 +76,85 @@ function SortableFeatureRow({
if (!feature) return null; if (!feature) return null;
const colorKey = feature.themeColorKey ?? 'primary'; const colorKey = feature.themeColorKey ?? 'primary';
const colorCode = getPaletteColor(theme, colorKey).main; const colorCode = getColorCode(colorKey);
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
zIndex: isDragging ? 1 : 'auto', zIndex: isDragging ? 1 : 'auto',
position: 'relative' as const, position: 'relative' as const,
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
}; };
return ( return (
<Box <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
sx={{ className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-gray-50 ${
display: 'flex', isLast ? '' : 'border-b border-gray-200'
alignItems: 'center', }`}
justifyContent: 'space-between',
p: { xs: 2, sm: 2.5 },
borderBottom: isLast ? 'none' : '1px solid',
borderColor: 'divider',
bgcolor: isDragging ? alpha(colorCode, 0.04) : 'transparent',
boxShadow: isDragging ? `0 8px 20px ${alpha('#000', 0.08)}` : 'none',
transition: 'background-color 0.2s, box-shadow 0.2s',
'&:hover': {
bgcolor: alpha(colorCode, 0.02),
},
'&:hover .drag-handle': {
color: 'text.secondary',
},
}}
> >
<Stack <div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
direction="row" {/* 拖拽手柄 */}
spacing={{ xs: 1.5, sm: 2 }} <div
alignItems="center" className="drag-handle text-gray-300 cursor-grab touch-none transition-colors duration-200 hover:text-gray-500 active:cursor-grabbing"
sx={{ flex: 1, minWidth: 0 }}
>
{/* 拖拽手柄 - 整行可拖,手柄是视觉暗示 */}
<Box
className="drag-handle"
{...attributes} {...attributes}
{...listeners} {...listeners}
sx={{
color: alpha(theme.palette.text.primary, 0.2),
display: 'flex',
cursor: 'grab',
touchAction: 'none',
transition: 'color 0.2s',
'&:active': { cursor: 'grabbing' },
'&:hover': { color: 'text.secondary' },
}}
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`} aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
> >
<DragIndicatorIcon fontSize="small" /> <GripVertical size={16} />
</Box> </div>
{/* 功能图标 */} {/* 功能图标 */}
<Box <div
sx={{ className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
width: 36, style={{
height: 36, backgroundColor: `${colorCode}1a`,
borderRadius: 2.5,
bgcolor: alpha(colorCode, 0.1),
color: colorCode, color: colorCode,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}} }}
> >
{feature.icon && <feature.icon sx={{ fontSize: 20 }} />} {feature.icon && <feature.icon size={20} />}
</Box> </div>
{/* 文本信息 */} {/* 文本信息 */}
<Box sx={{ minWidth: 0, flex: 1 }}> <div className="min-w-0 flex-1">
<Typography <div className="font-bold text-[0.95rem] leading-tight text-gray-900">
variant="subtitle2"
sx={{
fontWeight: 700,
color: 'text.primary',
fontSize: '0.95rem',
lineHeight: 1.3,
}}
>
{t(feature.labelKey)} {t(feature.labelKey)}
</Typography> </div>
{feature.descriptionKey && ( {feature.descriptionKey && (
<Tooltip title={t(feature.descriptionKey)} placement="top-start" enterDelay={400}> <div
<Typography className="text-xs text-gray-500 font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
variant="caption" title={t(feature.descriptionKey)}
color="text.secondary"
sx={{
fontWeight: 500,
mt: 0.25,
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
}}
> >
{t(feature.descriptionKey)} {t(feature.descriptionKey)}
</Typography> </div>
</Tooltip>
)} )}
</Box> </div>
</Stack> </div>
<Switch {/* 开关 */}
color="primary" <button
checked={isChecked} type="button"
onChange={() => onToggle(pageKey)} role="switch"
aria-checked={isChecked}
disabled={isDisabled} disabled={isDisabled}
onClick={() => onToggle(pageKey)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
isChecked ? 'bg-blue-600' : 'bg-gray-200'
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${
isChecked ? 'translate-x-6' : 'translate-x-1'
}`}
/> />
</Box> </button>
</div>
); );
} }
/**
* Options 设置页面主组件
* 支持对不同窗口入口的功能显示和排序进行独立配置
*/
export default function App() { export default function App() {
const theme = useTheme();
const { t } = useTranslation(['features', 'common']); const { t } = useTranslation(['features', 'common']);
const initialWindowType = useMemo(() => { const initialWindowType = useMemo(() => {
@@ -360,120 +305,64 @@ export default function App() {
if (!isLoaded) { if (!isLoaded) {
return ( return (
<Box <div className="flex justify-center items-center min-h-screen">
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }} <div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
> </div>
<CircularProgress size={24} />
</Box>
); );
} }
return ( return (
<Box <div className="app min-h-screen bg-gray-50 flex flex-col">
className="app"
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
display: 'flex',
flexDirection: 'column',
}}
>
<PageErrorBoundary> <PageErrorBoundary>
{/* 顶部标题与导航栏 */} {/* 顶部标题与导航栏 */}
<Box <div className="w-full bg-white border-b border-gray-200 pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
sx={{ <div className="max-w-3xl mx-auto">
width: '100%',
bgcolor: 'background.paper',
borderBottom: '1px solid',
borderColor: 'divider',
pt: { xs: 3, sm: 5 },
pb: 0,
px: { xs: 2, sm: 4 },
}}
>
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
<PageHeader <PageHeader
icon={<SettingsIcon />} icon={<Settings size={20} />}
iconColor={theme.palette.primary.main} iconColor="#1976d2"
title="应用设置" title="应用设置"
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序" subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
sx={{ marginBottom: '1rem' }} sx={{ marginBottom: '1rem' }}
/> />
{/* Tab 与恢复按钮同行 */} {/* Tab 与恢复按钮同行 */}
<Stack <div className="flex items-end justify-between border-b-0">
direction="row" <div className="flex-1 flex">
alignItems="flex-end" {(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
justifyContent="space-between" <button
sx={{ borderBottom: 'none' }} key={type}
onClick={(e) => handleWindowTypeChange(e, type)}
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
windowType === type
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
> >
<Tabs {type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
value={windowType} </button>
onChange={handleWindowTypeChange} ))}
indicatorColor="primary" </div>
textColor="primary" <button
sx={{
flex: 1,
minHeight: 'auto',
'& .MuiTab-root': {
fontWeight: 700,
fontSize: '0.9rem',
textTransform: 'none',
minWidth: { xs: 100, sm: 140 },
letterSpacing: '0.3px',
},
'& .MuiTabs-indicator': {
height: 3,
borderRadius: '3px 3px 0 0',
},
}}
>
<Tab value="popup" label="Popup 窗口" />
<Tab value="sidepanel" label="侧边栏" />
<Tab value="tab" label="标签页" />
</Tabs>
<Tooltip title="恢复当前模式默认" placement="top">
<IconButton
onClick={handleRestoreDefaults} onClick={handleRestoreDefaults}
size="small" className="mb-1 ml-2 p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
sx={{ title="恢复当前模式默认"
mb: 1,
ml: 1,
color: 'text.secondary',
'&:hover': {
color: 'primary.main',
bgcolor: alpha(theme.palette.primary.main, 0.08),
},
}}
aria-label="恢复当前模式默认"
> >
<RefreshIcon fontSize="small" /> <RefreshCw size={16} />
</IconButton> </button>
</Tooltip> </div>
</Stack> </div>
</Box> </div>
</Box>
{/* 主内容区域 */} {/* 主内容区域 */}
<Box sx={{ flex: 1, p: { xs: 2, sm: 4 } }}> <div className="flex-1 p-4 sm:p-8">
<Box sx={{ maxWidth: 800, mx: 'auto' }}> <div className="max-w-3xl mx-auto">
<Paper <div className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
bgcolor: 'background.paper',
boxShadow: '0 4px 24px rgba(0,0,0,0.03)',
}}
>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}> <SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <div className="flex flex-col">
{pageOrder.map((key, index, array) => { {pageOrder.map((key, index, array) => {
const isChecked = visiblePages.includes(key); const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1; const isDisabled = isChecked && visiblePages.length === 1;
@@ -488,15 +377,15 @@ export default function App() {
/> />
); );
})} })}
</Box> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</Paper> </div>
</Box> </div>
</Box> </div>
</PageErrorBoundary> </PageErrorBoundary>
<GlobalSnackbar {...snackbarProps} /> <GlobalSnackbar {...snackbarProps} />
</Box> </div>
); );
} }
+522 -48
View File
@@ -15,8 +15,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^9.0.1",
"@mui/material": "^7.3.8", "@mui/material": "^9.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.7",
@@ -1762,9 +1763,9 @@
} }
}, },
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.1.tgz",
"integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", "integrity": "sha512-GzamIIhZ1bH77dq7eKaeyRgJdkypsxin4jBFq2EMs4lBWRR0LFO1CSVMsoebn/VvjcNrnrOrjy48MkrkQUK2iw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -1772,12 +1773,12 @@
} }
}, },
"node_modules/@mui/icons-material": { "node_modules/@mui/icons-material": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.1.tgz",
"integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==", "integrity": "sha512-5PRpQjVLTNLyV/2J9J53Yz4R0tVbodG0BQDN2zQI1QBG1OPYM25ar+4N20eyFOfJT6zKglLzsnU70+zdVLaTkw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6" "@babel/runtime": "^7.29.2"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -1787,7 +1788,7 @@
"url": "https://opencollective.com/mui-org" "url": "https://opencollective.com/mui-org"
}, },
"peerDependencies": { "peerDependencies": {
"@mui/material": "^7.3.11", "@mui/material": "^9.0.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}, },
@@ -1798,22 +1799,22 @@
} }
}, },
"node_modules/@mui/material": { "node_modules/@mui/material": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.1.tgz",
"integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==", "integrity": "sha512-voyCpeUxcSWLN7KPZuq0pGCIt726T9K6kiVM3XUcywZDAlZSarLHaUxJVQpospbjjOzN53hwyjo8s6KoWl6utw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@mui/core-downloads-tracker": "^7.3.11", "@mui/core-downloads-tracker": "^9.0.1",
"@mui/system": "^7.3.11", "@mui/system": "^9.0.1",
"@mui/types": "^7.4.12", "@mui/types": "^9.0.0",
"@mui/utils": "^7.3.11", "@mui/utils": "^9.0.1",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12", "@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.2.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3", "react-is": "^19.2.4",
"react-transition-group": "^4.4.5" "react-transition-group": "^4.4.5"
}, },
"engines": { "engines": {
@@ -1826,7 +1827,7 @@
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.5.0", "@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@mui/material-pigment-css": "^7.3.11", "@mui/material-pigment-css": "^9.0.1",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -1847,13 +1848,13 @@
} }
}, },
"node_modules/@mui/private-theming": { "node_modules/@mui/private-theming": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.1.tgz",
"integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", "integrity": "sha512-pSIGq4Yw749KHEwlkYZWVERgHgwJELP6ODtBNUfV8V4oIb5H+h7IQDFXuk/b2oQccODK1enJAtiEzlgLZmq+8g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@mui/utils": "^7.3.11", "@mui/utils": "^9.0.1",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
}, },
"engines": { "engines": {
@@ -1874,12 +1875,12 @@
} }
}, },
"node_modules/@mui/styled-engine": { "node_modules/@mui/styled-engine": {
"version": "7.3.10", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz",
"integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", "integrity": "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@emotion/cache": "^11.14.0", "@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3", "@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0", "@emotion/sheet": "^1.4.0",
@@ -1908,16 +1909,16 @@
} }
}, },
"node_modules/@mui/system": { "node_modules/@mui/system": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.0.1.tgz",
"integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", "integrity": "sha512-WvlioaLxk6ewUIOfh0StxUvOPDS1mCfzaulcudsL1brZNXuh0N9FMk7RpH7ImJKjEz412SEy/V/yvqmtxbqxCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@mui/private-theming": "^7.3.11", "@mui/private-theming": "^9.0.1",
"@mui/styled-engine": "^7.3.10", "@mui/styled-engine": "^9.0.0",
"@mui/types": "^7.4.12", "@mui/types": "^9.0.0",
"@mui/utils": "^7.3.11", "@mui/utils": "^9.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"csstype": "^3.2.3", "csstype": "^3.2.3",
"prop-types": "^15.8.1" "prop-types": "^15.8.1"
@@ -1948,12 +1949,12 @@
} }
}, },
"node_modules/@mui/types": { "node_modules/@mui/types": {
"version": "7.4.12", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz",
"integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6" "@babel/runtime": "^7.29.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -1965,17 +1966,17 @@
} }
}, },
"node_modules/@mui/utils": { "node_modules/@mui/utils": {
"version": "7.3.11", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.1.tgz",
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", "integrity": "sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.29.2",
"@mui/types": "^7.4.12", "@mui/types": "^9.0.0",
"@types/prop-types": "^15.7.15", "@types/prop-types": "^15.7.15",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-is": "^19.2.3" "react-is": "^19.2.4"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2114,6 +2115,12 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -2129,6 +2136,249 @@
} }
} }
}, },
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2147,6 +2397,91 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -2635,7 +2970,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -3333,6 +3668,18 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -4750,6 +5097,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -6061,6 +6414,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-port-please": { "node_modules/get-port-please": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
@@ -9600,6 +9962,75 @@
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -11414,6 +11845,49 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+3 -2
View File
@@ -26,8 +26,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^9.0.1",
"@mui/material": "^7.3.8", "@mui/material": "^9.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.7",
+3 -9
View File
@@ -1,12 +1,6 @@
/**
* ToolCard 组件 - 工具卡片
*
* 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
* 快照内容展示,具备悬停动画效果。
*/
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { SvgIconProps } from '@mui/material/SvgIcon'; import type { LucideProps } from 'lucide-react';
import type { PaletteColorKey } from '@/config/features'; import type { PaletteColorKey } from '@/config/features';
const PALETTE_COLORS: Record<PaletteColorKey, string> = { const PALETTE_COLORS: Record<PaletteColorKey, string> = {
@@ -23,7 +17,7 @@ interface ToolCardProps {
description?: string; description?: string;
snapshot?: React.ReactNode; snapshot?: React.ReactNode;
colorKey: PaletteColorKey; colorKey: PaletteColorKey;
icon: ComponentType<SvgIconProps>; icon: ComponentType<LucideProps>;
onClick: () => void; onClick: () => void;
} }
@@ -68,7 +62,7 @@ export default function ToolCard({
color: colorCode, color: colorCode,
}} }}
> >
<IconComponent sx={{ fontSize: 20 }} /> <IconComponent size={20} />
</div> </div>
<div> <div>
<span className="font-bold text-sm leading-tight text-gray-900 flex items-center gap-1"> <span className="font-bold text-sm leading-tight text-gray-900 flex items-center gap-1">
+50 -174
View File
@@ -1,18 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { import { Code, Download, Trash2 } from 'lucide-react';
Alert,
alpha,
Box,
Button,
Container,
Stack,
TextField,
Typography,
Paper,
} from '@mui/material';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import DownloadIcon from '@mui/icons-material/Download';
import CodeIcon from '@mui/icons-material/Code';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
@@ -57,18 +44,12 @@ export default function HtmlToMarkdownPage() {
const showOutput = previewMode !== 'markdown'; const showOutput = previewMode !== 'markdown';
return ( return (
<Container maxWidth="xl" sx={{ py: 3 }}> <div className="container mx-auto px-4 py-6 max-w-7xl">
<PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<CodeIcon />} /> <PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<Code size={20} />} />
<Stack spacing={2}> <div className="flex flex-col gap-4">
{/* 工具栏 */} {/* 工具栏 */}
<Stack <div className="flex flex-wrap items-center justify-between gap-3">
direction="row"
justifyContent="space-between"
alignItems="center"
flexWrap="wrap"
gap={1.5}
>
<SwitchButtonGroup <SwitchButtonGroup
value={previewMode} value={previewMode}
options={[ options={[
@@ -80,193 +61,88 @@ export default function HtmlToMarkdownPage() {
size="small" size="small"
/> />
<Stack direction="row" spacing={1}> <div className="flex gap-2">
<Button <button
variant="outlined"
size="small"
startIcon={<DownloadIcon />}
onClick={handleDownload} onClick={handleDownload}
disabled={!result.markdown} disabled={!result.markdown}
sx={{ borderRadius: 2 }} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<Download size={14} />
{t('download')} {t('download')}
</Button> </button>
<Button <button
variant="outlined"
size="small"
startIcon={<DeleteOutlineIcon />}
onClick={handleClear} onClick={handleClear}
sx={{ borderRadius: 2 }} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<Trash2 size={14} />
{t('clear')} {t('clear')}
</Button> </button>
</Stack> </div>
</Stack> </div>
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && (
<Alert severity="error" sx={{ borderRadius: 2 }}> <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error} {error}
</Alert> </div>
)} )}
{/* 主内容区 */} {/* 主内容区 */}
<Box <div
sx={{ className={`grid gap-4 min-h-[500px] ${
display: 'grid', showInput && showOutput ? 'md:grid-cols-2' : 'grid-cols-1'
gridTemplateColumns: { }`}
xs: '1fr',
md: showInput && showOutput ? '1fr 1fr' : '1fr',
},
gap: 2,
minHeight: 500,
}}
> >
{/* HTML 输入区 */} {/* HTML 输入区 */}
{showInput && ( {showInput && (
<Paper <div className="border border-gray-200 rounded-xl overflow-hidden flex flex-col">
elevation={0} <div className="flex items-center justify-between px-4 py-2 bg-blue-50/50 border-b border-gray-200">
sx={{ <span className="text-xs font-bold text-gray-500">{t('inputLabel')}</span>
border: '1px solid', <span className="text-xs text-gray-400">
borderColor: 'divider',
borderRadius: 3,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{t('inputLabel')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{t('charCount', { count: html.length })} {t('charCount', { count: html.length })}
</Typography> </span>
</Box> </div>
<TextField <textarea
multiline
fullWidth
value={html} value={html}
onChange={(e) => setHtml(e.target.value)} onChange={(e) => setHtml(e.target.value)}
placeholder={t('inputPlaceholder')} placeholder={t('inputPlaceholder')}
sx={{ className="flex-1 min-h-[400px] p-4 font-mono text-[0.85rem] leading-relaxed resize-none focus:outline-none"
flex: 1,
'& .MuiOutlinedInput-root': {
borderRadius: 0,
fontFamily: 'monospace',
fontSize: '0.85rem',
lineHeight: 1.6,
alignItems: 'flex-start',
'& fieldset': { border: 'none' },
},
'& .MuiInputBase-input': {
py: 2,
px: 2,
minHeight: 400,
},
}}
/> />
</Paper> </div>
)} )}
{/* Markdown 输出区 */} {/* Markdown 输出区 */}
{showOutput && ( {showOutput && (
<Paper <div className="border border-gray-200 rounded-xl overflow-hidden flex flex-col">
elevation={0} <div className="flex items-center justify-between px-4 py-2 bg-blue-50/50 border-b border-gray-200">
sx={{ <span className="text-xs font-bold text-gray-500">
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{(previewMode as string) === 'markdown' {(previewMode as string) === 'markdown'
? t('markdownOutputLabel') ? t('markdownOutputLabel')
: t('previewLabel')} : t('previewLabel')}
</Typography> </span>
<Stack direction="row" spacing={0.5} alignItems="center"> <div className="flex items-center gap-1">
<Typography variant="caption" sx={{ color: 'text.disabled' }}> <span className="text-xs text-gray-400">
{t('charCount', { count: result.markdownLength })} {t('charCount', { count: result.markdownLength })}
</Typography> </span>
<CopyButton text={result.markdown} size="small" /> <CopyButton text={result.markdown} size="small" />
</Stack> </div>
</Box> </div>
{(previewMode as string) === 'markdown' ? ( {(previewMode as string) === 'markdown' ? (
<TextField <textarea
multiline
fullWidth
value={result.markdown} value={result.markdown}
slotProps={{ input: { readOnly: true } }} readOnly
sx={{ className="flex-1 min-h-[400px] p-4 font-mono text-[0.85rem] leading-relaxed resize-none focus:outline-none bg-gray-50"
flex: 1,
'& .MuiOutlinedInput-root': {
borderRadius: 0,
fontFamily: 'monospace',
fontSize: '0.85rem',
lineHeight: 1.6,
alignItems: 'flex-start',
'& fieldset': { border: 'none' },
},
'& .MuiInputBase-input': {
py: 2,
px: 2,
minHeight: 400,
},
}}
/> />
) : ( ) : (
<Box <div className="flex-1 p-4 min-h-[400px] overflow-auto bg-white font-mono text-[0.85rem] leading-relaxed whitespace-pre-wrap break-words">
sx={{ {result.markdown || <span className="text-gray-400">{t('emptyHint')}</span>}
flex: 1, </div>
p: 2,
minHeight: 400,
overflow: 'auto',
bgcolor: 'background.paper',
fontFamily: 'monospace',
fontSize: '0.85rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{result.markdown || (
<Typography variant="body2" color="text.disabled">
{t('emptyHint')}
</Typography>
)} )}
</Box> </div>
)} )}
</Paper> </div>
)} </div>
</Box> </div>
</Stack>
</Container>
); );
} }
+54 -179
View File
@@ -1,19 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import { Code, Trash2, Printer, Download } from 'lucide-react';
Alert,
alpha,
Box,
Button,
Container,
Stack,
TextField,
Typography,
Paper,
} from '@mui/material';
import CodeIcon from '@mui/icons-material/Code';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PrintIcon from '@mui/icons-material/Print';
import DownloadIcon from '@mui/icons-material/Download';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
@@ -121,7 +107,6 @@ export default function MarkdownToHtmlPage() {
const result = useMemo(() => markdownToHtml(markdown), [markdown]); const result = useMemo(() => markdownToHtml(markdown), [markdown]);
const error = result.hasError ? (result.error ?? null) : null; const error = result.hasError ? (result.error ?? null) : null;
// 更新 iframe 预览内容
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
if (!iframe || !iframe.contentDocument) return; if (!iframe || !iframe.contentDocument) return;
@@ -163,18 +148,12 @@ export default function MarkdownToHtmlPage() {
const showPreview = previewMode !== 'html'; const showPreview = previewMode !== 'html';
return ( return (
<Container maxWidth="xl" sx={{ py: 3 }}> <div className="container mx-auto px-4 py-6 max-w-7xl">
<PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<CodeIcon />} /> <PageHeader title={t('pageTitle')} subtitle={t('pageSubtitle')} icon={<Code size={20} />} />
<Stack spacing={2}> <div className="flex flex-col gap-4">
{/* 工具栏 */} {/* 工具栏 */}
<Stack <div className="flex flex-wrap items-center justify-between gap-3">
direction="row"
justifyContent="space-between"
alignItems="center"
flexWrap="wrap"
gap={1.5}
>
<SwitchButtonGroup <SwitchButtonGroup
value={previewMode} value={previewMode}
options={[ options={[
@@ -186,200 +165,96 @@ export default function MarkdownToHtmlPage() {
size="small" size="small"
/> />
<Stack direction="row" spacing={1}> <div className="flex gap-2">
<Button <button
variant="outlined"
size="small"
startIcon={<DeleteOutlineIcon />}
onClick={handleClear} onClick={handleClear}
sx={{ borderRadius: 2 }} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<Trash2 size={14} />
{t('clear')} {t('clear')}
</Button> </button>
<Button <button
variant="outlined"
size="small"
startIcon={<PrintIcon />}
onClick={handlePrint} onClick={handlePrint}
sx={{ borderRadius: 2 }} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<Printer size={14} />
{t('print')} {t('print')}
</Button> </button>
<Button <button
variant="outlined"
size="small"
startIcon={<DownloadIcon />}
onClick={handleDownload} onClick={handleDownload}
sx={{ borderRadius: 2 }} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
> >
<Download size={14} />
{t('download')} {t('download')}
</Button> </button>
</Stack> </div>
</Stack> </div>
{/* 错误提示 */} {/* 错误提示 */}
{error && ( {error && (
<Alert severity="error" sx={{ borderRadius: 2 }}> <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error} {error}
</Alert> </div>
)} )}
{/* 主内容区 */} {/* 主内容区 */}
<Box <div
sx={{ className={`grid gap-4 min-h-[500px] ${
display: 'grid', showInput && showPreview ? 'md:grid-cols-2' : 'grid-cols-1'
gridTemplateColumns: { }`}
xs: '1fr',
md: showInput && showPreview ? '1fr 1fr' : '1fr',
},
gap: 2,
minHeight: 500,
}}
> >
{/* Markdown 输入区 */} {/* Markdown 输入区 */}
{showInput && ( {showInput && (
<Paper <div className="border border-gray-200 rounded-xl overflow-hidden flex flex-col">
elevation={0} <div className="flex items-center justify-between px-4 py-2 bg-blue-50/50 border-b border-gray-200">
sx={{ <span className="text-xs font-bold text-gray-500">{t('inputLabel')}</span>
border: '1px solid', <span className="text-xs text-gray-400">
borderColor: 'divider',
borderRadius: 3,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{t('inputLabel')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
{t('charCount', { count: markdown.length })} {t('charCount', { count: markdown.length })}
</Typography> </span>
</Box> </div>
<TextField <textarea
multiline
fullWidth
value={markdown} value={markdown}
onChange={(e) => setMarkdown(e.target.value)} onChange={(e) => setMarkdown(e.target.value)}
placeholder={t('inputPlaceholder')} placeholder={t('inputPlaceholder')}
sx={{ className="flex-1 min-h-[400px] p-4 font-mono text-[0.85rem] leading-relaxed resize-none focus:outline-none"
flex: 1,
'& .MuiOutlinedInput-root': {
borderRadius: 0,
fontFamily: 'monospace',
fontSize: '0.85rem',
lineHeight: 1.6,
alignItems: 'flex-start',
'& fieldset': { border: 'none' },
},
'& .MuiInputBase-input': {
py: 2,
px: 2,
minHeight: 400,
},
}}
/> />
</Paper> </div>
)} )}
{/* 预览/输出区 */} {/* 预览/输出区 */}
{showPreview && ( {showPreview && (
<Paper <div className="border border-gray-200 rounded-xl overflow-hidden flex flex-col">
elevation={0} <div className="flex items-center justify-between px-4 py-2 bg-blue-50/50 border-b border-gray-200">
sx={{ <span className="text-xs font-bold text-gray-500">
border: '1px solid',
borderColor: 'divider',
borderRadius: 3,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
px: 2,
py: 1,
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.04),
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')} {(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
</Typography> </span>
<Stack direction="row" spacing={0.5} alignItems="center"> <div className="flex items-center gap-1">
<Typography variant="caption" sx={{ color: 'text.disabled' }}> <span className="text-xs text-gray-400">
{t('charCount', { count: result.htmlLength })} {t('charCount', { count: result.htmlLength })}
</Typography> </span>
<CopyButton text={result.html} size="small" /> <CopyButton text={result.html} size="small" />
</Stack> </div>
</Box> </div>
{(previewMode as string) === 'html' ? ( {(previewMode as string) === 'html' ? (
<TextField <textarea
multiline
fullWidth
value={result.html} value={result.html}
InputProps={{ readOnly: true }} readOnly
sx={{ className="flex-1 min-h-[400px] p-4 font-mono text-[0.8rem] leading-relaxed resize-none focus:outline-none bg-gray-50"
flex: 1,
'& .MuiOutlinedInput-root': {
borderRadius: 0,
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.5,
alignItems: 'flex-start',
'& fieldset': { border: 'none' },
},
'& .MuiInputBase-input': {
py: 2,
px: 2,
minHeight: 400,
},
}}
/> />
) : ( ) : (
<Box <div className="flex-1 p-4 min-h-[400px] overflow-auto bg-white">
sx={{
flex: 1,
p: 2,
minHeight: 400,
overflow: 'auto',
bgcolor: 'background.paper',
}}
>
<iframe <iframe
ref={iframeRef} ref={iframeRef}
title="markdown-preview" title="markdown-preview"
style={{ className="w-full h-full min-h-[380px] border-none bg-transparent"
width: '100%',
height: '100%',
minHeight: 380,
border: 'none',
background: 'transparent',
}}
/> />
</Box> </div>
)} )}
</Paper> </div>
)} )}
</Box> </div>
</Stack> </div>
</Container> </div>
); );
} }