Enhance form recognition, optimize UI, and unify components (#19)
- **docs**: 完善组件注释、README 目录结构及 AGENTS.md 文档。 - **refactor**: - 提取通用 `PageHeader`、`Button`、`DashboardCard` 及 `ErrorBoundary` 组件。 - 重构消息通信机制,采用 `@webext-core/messaging` 实现类型安全。 - 将全局通知系统重构为 `SnackbarProvider` (后合并至 `GlobalSnackbar`)。 - 迁移样式系统至 MUI 主题,移除冗余 CSS。 - 优化路由配置,支持独立标签页模式及页面懒加载。 - 移除未使用文件、URL 工具及表单映射相关功能。 - **feat**: - 新增配置导出功能(JSON)及状态提示。 - 新增侧边栏状态变化通知机制。 - 新增文本统计及 JWT 解析工具。 - 优化二维码生成与解析逻辑,换用更轻量的 `qrious` 和 `qr-scanner`。 - 增强高亮器功能,支持闪烁效果及 Shadow DOM 穿透。 - **style**: 优化仪表盘响应式网格布局及 UI 细节。 - **fix**: 修复 `useStorageState` 依赖缺失及路由初始化性能问题。 - **test**: 更新单元测试以覆盖新增的工具函数及功能特性。
This commit is contained in:
@@ -1,200 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
Collapse,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent,
|
||||
Checkbox,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { FieldType } from '@/utils/dummyDataGenerator';
|
||||
|
||||
// 字段数据接口
|
||||
interface FieldData {
|
||||
id: string;
|
||||
fieldType: string;
|
||||
label: string | null;
|
||||
placeholder: string;
|
||||
name: string;
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
generatedValue: string;
|
||||
useInvalidData?: boolean;
|
||||
}
|
||||
|
||||
// 字段类型显示名称映射
|
||||
const FIELD_TYPE_NAMES: Record<string, string> = {
|
||||
[FieldType.TEXT]: '文本',
|
||||
[FieldType.EMAIL]: '邮箱',
|
||||
[FieldType.PHONE]: '手机号',
|
||||
[FieldType.NUMBER]: '数字',
|
||||
[FieldType.DATE]: '日期',
|
||||
[FieldType.TEXTarea]: '文本域',
|
||||
[FieldType.RADIO]: '单选框',
|
||||
[FieldType.CHECKBOX]: '复选框',
|
||||
[FieldType.SELECT]: '下拉框',
|
||||
[FieldType.PASSWORD]: '密码',
|
||||
[FieldType.NAME]: '姓名',
|
||||
[FieldType.ID_CARD]: '身份证号',
|
||||
[FieldType.UNKNOWN]: '未知',
|
||||
};
|
||||
|
||||
interface FieldListProps {
|
||||
fields: FieldData[];
|
||||
showFields: boolean;
|
||||
onToggleShowFields: () => void;
|
||||
onFieldTypeChange: (fieldId: string, newType: string) => void;
|
||||
onLocateField: (fieldId: string) => void;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
onToggleFieldSelection: (fieldId: string) => void;
|
||||
onToggleAllFields: () => void;
|
||||
hoveredFieldId: string | null;
|
||||
}
|
||||
|
||||
const FieldList: React.FC<FieldListProps> = ({
|
||||
fields,
|
||||
showFields,
|
||||
onToggleShowFields,
|
||||
onFieldTypeChange,
|
||||
onHoverField,
|
||||
onToggleFieldSelection,
|
||||
onToggleAllFields,
|
||||
hoveredFieldId,
|
||||
}) => {
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
|
||||
onFieldTypeChange(fieldId, event.target.value);
|
||||
};
|
||||
|
||||
const allSelected = fields.every((f) => f.isSelected);
|
||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onToggleShowFields}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
已识别字段 ({fields.length})
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
|
||||
color: selectedCount > 0 ? 'white' : 'text.secondary',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{selectedCount} 已选择
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleAllFields();
|
||||
}}
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
</Box>
|
||||
<Collapse in={showFields}>
|
||||
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
{fields.map((field, index) => (
|
||||
<ListItem
|
||||
key={field.id}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 2,
|
||||
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={() => onHoverField(field.id)}
|
||||
onMouseLeave={() => onHoverField(null)}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={field.isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFieldSelection(field.id);
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
opacity: field.isSelected ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
|
||||
<InputLabel>类型</InputLabel>
|
||||
<Select
|
||||
value={field.fieldType}
|
||||
label="类型"
|
||||
onChange={(e) => handleTypeChange(field.id, e)}
|
||||
>
|
||||
{Object.values(FieldType).map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{FIELD_TYPE_NAMES[type] || type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{field.placeholder && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
占位符: {field.placeholder}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldList;
|
||||
+88
-114
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件
|
||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
||||
*
|
||||
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
|
||||
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
|
||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||
*
|
||||
* @module GlobalSnackbar
|
||||
* @version 1.0.0
|
||||
* @version 1.1.0
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -18,13 +19,23 @@
|
||||
* severity="success"
|
||||
* />
|
||||
*
|
||||
* // 方式二:Hook 方式
|
||||
* // 方式二:Hook 方式 (局部状态)
|
||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||
* showMessage('Hello!', { severity: 'info' });
|
||||
*
|
||||
* // 方式三:Context 方式 (全局状态)
|
||||
* // 在根组件包裹 Provider
|
||||
* <SnackbarProvider>
|
||||
* <App />
|
||||
* </SnackbarProvider>
|
||||
*
|
||||
* // 在子组件中使用
|
||||
* const { showMessage } = useSnackbar();
|
||||
* showMessage('Global Message');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { JSX, useState } from 'react';
|
||||
import React, { JSX, useState, createContext, useContext, type ReactNode } from 'react';
|
||||
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
|
||||
|
||||
/**
|
||||
@@ -37,12 +48,6 @@ import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/m
|
||||
*/
|
||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* 重新导出 SnackbarProvider 组件
|
||||
* @description 提供 Context 方式的全局 Snackbar 功能
|
||||
*/
|
||||
export { SnackbarProvider } from './SnackbarProvider';
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的属性接口
|
||||
* @interface GlobalSnackbarProps
|
||||
@@ -126,23 +131,6 @@ const defaultProps: Required<
|
||||
*
|
||||
* @param {GlobalSnackbarProps} props - 组件属性
|
||||
* @returns {JSX.Element}
|
||||
*
|
||||
* @remarks
|
||||
* - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题
|
||||
* - 默认位置在屏幕底部居中
|
||||
* - 自动设置高 z-index 确保显示在其他内容之上
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 受控模式
|
||||
* const [open, setOpen] = useState(false);
|
||||
* <GlobalSnackbar
|
||||
* message="保存成功"
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* severity="success"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function GlobalSnackbar({
|
||||
message,
|
||||
@@ -153,15 +141,6 @@ export function GlobalSnackbar({
|
||||
showAlert = defaultProps.showAlert,
|
||||
hideIcon = defaultProps.hideIcon,
|
||||
}: GlobalSnackbarProps): JSX.Element {
|
||||
/**
|
||||
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
|
||||
*
|
||||
* @description
|
||||
* Portal 的优势:
|
||||
* - 避免父容器 overflow、z-index 等样式影响
|
||||
* - 确保 Snackbar 始终显示在最顶层
|
||||
* - 避免与其他组件的样式冲突
|
||||
*/
|
||||
return (
|
||||
<Portal>
|
||||
<Snackbar
|
||||
@@ -172,9 +151,7 @@ export function GlobalSnackbar({
|
||||
disableWindowBlurListener
|
||||
sx={{
|
||||
zIndex: 999999,
|
||||
// 确保距离底部的间距,响应式设计适配不同屏幕
|
||||
bottom: { xs: '24px', sm: '24px' },
|
||||
// 固定宽度时使用 transform 实现真正的居中
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
minWidth: '140px',
|
||||
@@ -186,26 +163,19 @@ export function GlobalSnackbar({
|
||||
variant="filled"
|
||||
icon={hideIcon ? false : undefined}
|
||||
sx={{
|
||||
// 胶囊形状,现代化的设计风格
|
||||
borderRadius: '50px',
|
||||
px: 2.5,
|
||||
py: 0.2,
|
||||
minWidth: '140px',
|
||||
// 居中内容
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// 粗体小字
|
||||
fontWeight: 800,
|
||||
fontSize: '0.75rem',
|
||||
// 移除默认渐变背景
|
||||
backgroundImage: 'none',
|
||||
// 添加阴影效果,颜色根据 severity 自动匹配主题色
|
||||
boxShadow: (theme: Theme) =>
|
||||
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
||||
// 图标样式:白色、稍大
|
||||
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
|
||||
// 消息文字样式:白色、适当内边距
|
||||
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
|
||||
}}
|
||||
>
|
||||
@@ -218,97 +188,33 @@ export function GlobalSnackbar({
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState - 消息提示的 Hook 方式
|
||||
* useSnackbarState - 消息提示的状态管理 Hook
|
||||
*
|
||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
||||
*
|
||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
||||
*
|
||||
* @description
|
||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
||||
* - 支持链式调用 showMessage
|
||||
* - 合并初始选项和调用时选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
||||
* severity: 'info',
|
||||
* autoHideDuration: 3000,
|
||||
* });
|
||||
*
|
||||
* const handleSave = () => {
|
||||
* // 业务逻辑...
|
||||
* showMessage('保存成功!', { severity: 'success' });
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button onClick={handleSave}>保存</button>
|
||||
* <GlobalSnackbar {...snackbarProps} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
||||
// Snackbar 显示状态
|
||||
const [open, setOpen] = useState(false);
|
||||
// 当前显示的消息内容
|
||||
const [message, setMessage] = useState('');
|
||||
// 消息配置选项
|
||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
||||
|
||||
/**
|
||||
* 显示消息
|
||||
*
|
||||
* @param {string} newMessage - 要显示的消息文本
|
||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
||||
*
|
||||
* @description
|
||||
* - 合并初始选项和新的调用选项
|
||||
* - 新选项会覆盖初始选项
|
||||
*/
|
||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
||||
setMessage(newMessage);
|
||||
setOptions({ ...initialOptions, ...newOptions });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭消息
|
||||
*
|
||||
* @description
|
||||
* - 直接将 open 状态设置为 false
|
||||
*/
|
||||
const closeMessage = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Snackbar 关闭事件
|
||||
*
|
||||
* @param {React.SyntheticEvent | Event} [_event] - 关闭事件
|
||||
* @param {string} [reason] - 关闭原因:timeout | clickaway | escapeKeyDown
|
||||
*
|
||||
* @description
|
||||
* - 忽略 clickaway 原因(用户点击其他区域),防止误关闭
|
||||
* - 其他情况调用 closeMessage 关闭
|
||||
*/
|
||||
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') return;
|
||||
closeMessage();
|
||||
};
|
||||
|
||||
/**
|
||||
* 传递给 GlobalSnackbar 组件的属性
|
||||
*
|
||||
* @description
|
||||
* - 组合当前状态和选项为完整的组件 props
|
||||
* - onClose 使用 handleClose 包装后的版本
|
||||
*/
|
||||
const snackbarProps: GlobalSnackbarProps = {
|
||||
message,
|
||||
open,
|
||||
@@ -325,8 +231,76 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
|
||||
};
|
||||
}
|
||||
|
||||
// --- Context & Provider ---
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的默认导出
|
||||
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
|
||||
* Snackbar Context 的值类型定义
|
||||
*/
|
||||
interface SnackbarContextValue {
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件的 props 类型
|
||||
*/
|
||||
interface SnackbarProviderProps {
|
||||
children: ReactNode;
|
||||
initialOptions?: SnackbarOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件
|
||||
*
|
||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
||||
*/
|
||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
|
||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
||||
|
||||
return (
|
||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
||||
{children}
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</SnackbarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
||||
*
|
||||
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration)
|
||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
||||
*
|
||||
* @description
|
||||
* 选项合并策略:
|
||||
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
|
||||
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
|
||||
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
|
||||
*/
|
||||
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
|
||||
const context = useContext(SnackbarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
||||
}
|
||||
|
||||
// 包装 showMessage 以支持 Hook 级别的 initialOptions
|
||||
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
|
||||
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
|
||||
// 优先级:callOptions > options
|
||||
const mergedOptions: SnackbarOptions = {
|
||||
...(options || {}),
|
||||
...(callOptions || {}),
|
||||
};
|
||||
context.showMessage(message, mergedOptions);
|
||||
};
|
||||
|
||||
return {
|
||||
...context,
|
||||
showMessage: wrappedShowMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export default GlobalSnackbar;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
|
||||
@@ -31,7 +31,6 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
||||
onQrCodeDetected,
|
||||
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
|
||||
maxFileSize = 5 * 1024 * 1024, // 5MB
|
||||
timeout = 10000, // 10 seconds
|
||||
showPreview = true,
|
||||
showProgress = true,
|
||||
className,
|
||||
@@ -77,7 +76,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
||||
});
|
||||
}, 200);
|
||||
|
||||
const result = await parseQrCodeFromFile(file, timeout);
|
||||
const result = await parseQrCodeFromFile(file);
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
@@ -102,7 +101,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
||||
setTimeout(() => setProgress(0), 500);
|
||||
}
|
||||
},
|
||||
[timeout, showMessage, onQrCodeDetected],
|
||||
[showMessage, onQrCodeDetected],
|
||||
);
|
||||
|
||||
// 处理文件
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, Suspense } from 'react';
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
@@ -15,7 +15,18 @@ export default function RouterContainer() {
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return <div className="app">Loading...</div>;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
@@ -34,7 +45,23 @@ export default function RouterContainer() {
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{Component && <Component />}
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={32} />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{Component && <Component />}
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
|
||||
*
|
||||
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
|
||||
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
|
||||
*
|
||||
* @description
|
||||
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
|
||||
* - 使用 MUI Snackbar 组件实现消息提示
|
||||
* - 支持自定义自动隐藏时长
|
||||
* - 消息会显示在页面底部居中位置
|
||||
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
||||
import type { SnackbarOptions } from './GlobalSnackbar';
|
||||
|
||||
/**
|
||||
* Snackbar Context 的值类型定义
|
||||
* @interface SnackbarContextValue
|
||||
* @property showMessage - 显示消息的方法
|
||||
* @property closeMessage - 关闭消息的方法
|
||||
*/
|
||||
interface SnackbarContextValue {
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Context,用于在组件树中传递 Snackbar 操作方法
|
||||
* @description
|
||||
* - 初始值为 null,表示未包裹在 Provider 中
|
||||
* - 通过 SnackbarProvider 包裹后提供实际值
|
||||
*/
|
||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件的 props 类型
|
||||
* @interface SnackbarProviderProps
|
||||
* @property children - 子组件
|
||||
* @property initialOptions - 初始配置选项
|
||||
*/
|
||||
interface SnackbarProviderProps {
|
||||
children: ReactNode;
|
||||
initialOptions?: SnackbarOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
|
||||
* @interface UseSnackbarOptions
|
||||
* @property severity - 消息严重程度
|
||||
* @property autoHideDuration - 默认自动隐藏时长
|
||||
* @property hideIcon - 是否隐藏图标
|
||||
* @property showAlert - 是否使用 Alert 组件
|
||||
*/
|
||||
export type UseSnackbarOptions = SnackbarOptions;
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件
|
||||
*
|
||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
||||
* 提供 showMessage 方法用于显示各种类型的提示消息。
|
||||
*
|
||||
* @param {SnackbarProviderProps} props - 组件属性
|
||||
* @returns {JSX.Element}
|
||||
*
|
||||
* @remarks
|
||||
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
|
||||
* - 通过 Context.Provider 将操作方法传递给子组件
|
||||
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
|
||||
* <App />
|
||||
* </SnackbarProvider>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 在子组件中使用
|
||||
* const { showMessage } = useSnackbar();
|
||||
* showMessage('操作成功', { severity: 'success' });
|
||||
* ```
|
||||
*/
|
||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
|
||||
/**
|
||||
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
|
||||
*
|
||||
* @description
|
||||
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
|
||||
* - showMessage: 显示消息的方法
|
||||
* - closeMessage: 关闭消息的方法
|
||||
*/
|
||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
||||
|
||||
/**
|
||||
* 通过 Context.Provider 向下传递 snackbar 操作方法
|
||||
*
|
||||
* @description
|
||||
* - 子组件通过 useSnackbar() hook 获取这些方法
|
||||
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
|
||||
*/
|
||||
return (
|
||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
||||
{children}
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</SnackbarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
||||
*
|
||||
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
|
||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
||||
*
|
||||
* @description
|
||||
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
|
||||
* 必须确保组件被 SnackbarProvider 包裹才能使用。
|
||||
*
|
||||
* @remarks
|
||||
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
|
||||
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
|
||||
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { showMessage } = useSnackbar();
|
||||
*
|
||||
* const handleSuccess = () => {
|
||||
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleSuccess}>成功提示</button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
|
||||
const context = useContext(SnackbarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default SnackbarProvider;
|
||||
+69
-30
@@ -1,20 +1,52 @@
|
||||
/**
|
||||
* ToolCard 组件 - 工具卡片
|
||||
*
|
||||
* 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
|
||||
* AI 标识和快照内容展示,具备悬停动画效果。
|
||||
*/
|
||||
import { Box, Typography, Stack } from '@mui/material';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* ToolCard 组件属性接口
|
||||
*/
|
||||
interface ToolCardProps {
|
||||
/** 工具卡片标题 */
|
||||
title: string;
|
||||
/** 工具卡片描述文本(可选) */
|
||||
description?: string;
|
||||
/** 快照内容,用于在卡片底部展示额外信息(可选) */
|
||||
snapshot?: React.ReactNode;
|
||||
/** 主题色代码,用于图标背景和悬停效果 */
|
||||
colorCode: string;
|
||||
/** 工具图标元素 */
|
||||
icon: React.ReactNode;
|
||||
/** 卡片点击事件处理函数 */
|
||||
onClick: () => void;
|
||||
/** 是否显示 AI 标识(可选) */
|
||||
hasAI?: boolean;
|
||||
/** 卡片背景色,默认为 'background.paper' */
|
||||
cardBackgroundColor?: string;
|
||||
}
|
||||
|
||||
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
|
||||
/**
|
||||
* ToolCard 组件
|
||||
*
|
||||
* @param props - ToolCardProps 属性对象
|
||||
* @returns 工具卡片 JSX 元素
|
||||
*/
|
||||
export default function ToolCard({
|
||||
title,
|
||||
description,
|
||||
snapshot,
|
||||
colorCode,
|
||||
icon,
|
||||
onClick,
|
||||
hasAI,
|
||||
cardBackgroundColor = 'background.paper',
|
||||
}: ToolCardProps) {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
@@ -26,60 +58,67 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
// 使用 cubic-bezier 缓动函数实现平滑的过渡动画
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
'&:hover': {
|
||||
// 悬停效果:边框变色、向上位移、添加阴影
|
||||
'&:hover': {
|
||||
borderColor: colorCode,
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
|
||||
// 阴影颜色为主题色的 20% 透明度(十六进制后两位 33 约等于 20%)
|
||||
boxShadow: `0 12px 24px -10px ${colorCode}33`,
|
||||
// 悬停时箭头图标右移并变色
|
||||
'& .arrow-icon': {
|
||||
transform: 'translateX(4px)',
|
||||
color: colorCode
|
||||
}
|
||||
}
|
||||
color: colorCode,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 3,
|
||||
bgcolor: `${colorCode}11`, // 7% opacity
|
||||
color: colorCode
|
||||
// 图标背景色为主题色的 7% 透明度(十六进制后两位 11 约等于 7%)
|
||||
bgcolor: `${colorCode}11`,
|
||||
color: colorCode,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.2,
|
||||
color: 'text.primary',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.5
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
@@ -87,24 +126,24 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
<ArrowForwardIosIcon
|
||||
<ArrowForwardIosIcon
|
||||
className="arrow-icon"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'grey.300',
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'grey.300',
|
||||
mt: 0.5,
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{snapshot && (
|
||||
<Box
|
||||
sx={{
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
pt: 1.5,
|
||||
borderTop: '1px dashed',
|
||||
borderColor: 'grey.100'
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
{snapshot}
|
||||
|
||||
+32
-42
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
@@ -8,47 +7,36 @@ import { useRouter } from '@/providers/RouterProvider';
|
||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||
const { currentPage, goBack } = useRouter();
|
||||
|
||||
const isDetachedMode = useMemo(() => {
|
||||
return new URLSearchParams(window.location.search).get('mode') === 'detached';
|
||||
}, []);
|
||||
|
||||
const handleDetach = () => {
|
||||
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('mode', 'detached');
|
||||
|
||||
chrome.windows.create({
|
||||
url: url.toString(),
|
||||
type: 'panel',
|
||||
width: 420,
|
||||
height: 600
|
||||
});
|
||||
const handleOpenInTab = () => {
|
||||
// 在新标签页中打开扩展页面
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?mode=tab') }).catch(console.error);
|
||||
window.close();
|
||||
};
|
||||
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: { xs: 1.5, sm: 2 },
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
bgcolor: 'background.paper',
|
||||
zIndex: 1100
|
||||
zIndex: 1100,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40 }}>
|
||||
<Box sx={{ width: { xs: 32, sm: 40 } }}>
|
||||
{!isDashboard && (
|
||||
<IconButton
|
||||
size="small"
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={goBack}
|
||||
sx={{
|
||||
sx={{
|
||||
bgcolor: 'grey.50',
|
||||
'&:hover': { bgcolor: 'grey.200' }
|
||||
'&:hover': { bgcolor: 'grey.200' },
|
||||
}}
|
||||
>
|
||||
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
||||
@@ -56,27 +44,29 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary'
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
Testing Tools
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
|
||||
{!isDetachedMode && (
|
||||
<Tooltip title="独立窗口模式">
|
||||
<IconButton size="small" onClick={handleDetach}>
|
||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={0.5}
|
||||
sx={{ width: { xs: 80, sm: 120 }, justifyContent: 'flex-end' }}
|
||||
>
|
||||
<Tooltip title="在标签页打开">
|
||||
<IconButton size="small" onClick={handleOpenInTab}>
|
||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="设置">
|
||||
<IconButton size="small" onClick={onOpenOptions}>
|
||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, TextField, Alert, Stack } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Button from '@/components/Button';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
||||
|
||||
interface UrlEntryFormProps {
|
||||
onAddEntry: (entry: OpenUrlEntry) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [newUrl, setNewUrl] = useState<string>('');
|
||||
|
||||
const showMixedContentWarning =
|
||||
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
if (!url.trim()) return false;
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntry = () => {
|
||||
if (!newName.trim()) {
|
||||
showMessage('请输入名称', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!isValidUrl(newUrl)) {
|
||||
showMessage('请输入有效的 URL', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
|
||||
setNewName('');
|
||||
setNewUrl('');
|
||||
showMessage('添加成功', { severity: 'success' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
mb: 3,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="环境名称"
|
||||
placeholder="例如: 本地文档"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
/>
|
||||
<TextField
|
||||
label="目标 URL"
|
||||
placeholder="例如: http://localhost:8000/docs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
/>
|
||||
|
||||
{showMixedContentWarning && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
}}
|
||||
>
|
||||
混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddEntry}
|
||||
disabled={!newName.trim() || !isValidUrl(newUrl)}
|
||||
fullWidth
|
||||
startIcon={<AddIcon />}
|
||||
sx={{
|
||||
bgcolor: openUrlPageStyles.themeColor,
|
||||
'&:hover': {
|
||||
bgcolor: openUrlPageStyles.primaryDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
添加快捷方式
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryForm;
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
||||
|
||||
interface UrlEntryItemProps {
|
||||
entry: OpenUrlEntry;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
onDelete: (index: number) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
|
||||
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
|
||||
try {
|
||||
// 存储目标 URL
|
||||
await storageUtil.set('openUrl/currentUrl', entry.url);
|
||||
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
|
||||
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
|
||||
|
||||
const [currentTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
const tabId = currentTab.id;
|
||||
if (!tabId) {
|
||||
showMessage('无法获取当前标签页', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
await chrome.sidePanel.setOptions({
|
||||
tabId,
|
||||
path: 'sidepanel.html',
|
||||
enabled: true,
|
||||
});
|
||||
await chrome.sidePanel.open({ windowId: currentTab.windowId });
|
||||
|
||||
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
|
||||
if (window.location.pathname.includes('popup.html')) {
|
||||
window.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open side panel:', error);
|
||||
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
|
||||
chrome.tabs.create({ url: entry.url }).catch(console.error);
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
transition: 'background-color 0.2s',
|
||||
'&:hover': { bgcolor: 'grey.50' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.2,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{entry.url}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="在侧边栏预览">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInSidebar(entry)}
|
||||
sx={{
|
||||
color: openUrlPageStyles.themeColor,
|
||||
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
|
||||
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="新标签页打开">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInNewTab(entry)}
|
||||
sx={{
|
||||
color: 'grey.500',
|
||||
bgcolor: 'grey.100',
|
||||
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleDelete}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryItem;
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Box, List, Typography } from '@mui/material';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import UrlEntryItem from './UrlEntryItem';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
interface UrlEntryListProps {
|
||||
entries: OpenUrlEntry[];
|
||||
onDeleteEntry: (index: number) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed',
|
||||
borderColor: 'grey.200',
|
||||
}}
|
||||
>
|
||||
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ display: 'block', fontWeight: 600 }}
|
||||
>
|
||||
暂无快捷方式,请在上方添加
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
disablePadding
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<UrlEntryItem
|
||||
key={index}
|
||||
entry={entry}
|
||||
index={index}
|
||||
isLast={index === entries.length - 1}
|
||||
onDelete={onDeleteEntry}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryList;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
@@ -14,7 +14,7 @@ import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import qrcode from 'qrcode';
|
||||
import QRious from 'qrious';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
@@ -54,16 +54,16 @@ const UrlToQrCodeSection = ({
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
const dataUrl = await qrcode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: qrCodePageStyles.black,
|
||||
light: qrCodePageStyles.white,
|
||||
},
|
||||
// 使用 QRious 替代 qrcode 库,体积更小
|
||||
const qr = new QRious({
|
||||
value: url,
|
||||
size: 250,
|
||||
level: 'H',
|
||||
foreground: qrCodePageStyles.black,
|
||||
background: qrCodePageStyles.white,
|
||||
});
|
||||
|
||||
setQrCodeDataUrl(dataUrl);
|
||||
setQrCodeDataUrl(qr.toDataURL());
|
||||
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act, renderHook } from '@testing-library/react';
|
||||
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
|
||||
import React from 'react';
|
||||
import {
|
||||
GlobalSnackbar,
|
||||
useSnackbarState,
|
||||
useSnackbar,
|
||||
SnackbarProvider,
|
||||
type GlobalSnackbarProps,
|
||||
} from '../GlobalSnackbar';
|
||||
|
||||
describe('GlobalSnackbar 组件系统', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
@@ -105,4 +112,51 @@ describe('GlobalSnackbar 组件系统', () => {
|
||||
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbar Context Hook 优先级', () => {
|
||||
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SnackbarProvider initialOptions={{ severity: 'error', autoHideDuration: 1000 }}>
|
||||
{children}
|
||||
</SnackbarProvider>
|
||||
);
|
||||
|
||||
// 1. 测试 Hook Options 覆盖 Provider Options
|
||||
const { result: hookResult } = renderHook(() => useSnackbar({ severity: 'warning' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hookResult.current.showMessage('消息 1');
|
||||
});
|
||||
|
||||
// 我们需要通过某种方式检查当前活跃的 Snackbar 属性
|
||||
// 由于 GlobalSnackbar 是在 Provider 内部渲染的,我们可以检查 DOM
|
||||
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
||||
const alert1 = document.querySelector('.MuiAlert-filledWarning');
|
||||
expect(alert1).toBeInTheDocument(); // Hook 配置 (warning) 覆盖了 Provider 配置 (error)
|
||||
|
||||
// 2. 测试 Call Options 覆盖 Hook Options
|
||||
act(() => {
|
||||
hookResult.current.showMessage('消息 2', { severity: 'success' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
||||
const alert2 = document.querySelector('.MuiAlert-filledSuccess');
|
||||
expect(alert2).toBeInTheDocument(); // Call 配置 (success) 覆盖了 Hook 配置 (warning)
|
||||
});
|
||||
|
||||
it('防御性测试: 当 options 为 undefined 时不应崩溃', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SnackbarProvider>{children}</SnackbarProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSnackbar(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
expect(() => result.current.showMessage('测试')).not.toThrow();
|
||||
});
|
||||
expect(screen.getByText('测试')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import RouterContainer from '../RouterContainer';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { SnackbarProvider } from '@/components/SnackbarProvider';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('RouterContainer 组件', () => {
|
||||
it('isLoaded 为 false 时应渲染加载状态', () => {
|
||||
mockRouterValue.isLoaded = false;
|
||||
renderWithProvider(<RouterContainer />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
|
||||
import type { StorageCleanerOptions } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
describe('StorageCleanerConfirm 组件', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import TopBar from '../TopBar';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
|
||||
Reference in New Issue
Block a user