refactor: reorganize directory structure into src/
Move all source code directories into src/ for cleaner project structure: - pages/, components/, utils/, config/, providers/, types/, lib/, assets/, entrypoints/ → src/ - Use WXT srcDir config to resolve @/ alias to src/ - Update tsconfig, vitest, eslint, tailwind configs - Remove scattered README.md files from subdirectories - Update documentation (AGENTS.md, CODING_STANDARDS.md, README.md) - Fix pre-existing lint error in RouterProvider.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
text,
|
||||
tooltip,
|
||||
variant = 'ghost',
|
||||
size = 'icon',
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useI18n('common');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!text) {
|
||||
toast.error(t('messages.copyEmpty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyTextToClipboard(text);
|
||||
if (success) {
|
||||
toast.success(t('messages.copySuccess'));
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error(t('messages.copyError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={tooltip ?? t('buttons.copy')}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
copied &&
|
||||
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* DecodeResultPaper
|
||||
*
|
||||
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||
* 提取了二者 decode 输出区完全一致的结构:
|
||||
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||
*
|
||||
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||
*/
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatFileSize } from '@/utils/base64Converter';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface DecodeResultPaperProps {
|
||||
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||
title: string;
|
||||
/** 解码后推断的 MIME 类型 */
|
||||
mimeType: string;
|
||||
/** 解码后 Blob 的大小(字节) */
|
||||
blobSize: number;
|
||||
/** 当前文件名 */
|
||||
fileName: string;
|
||||
/** 文件名变更回调 */
|
||||
onFileNameChange: (name: string) => void;
|
||||
/** 下载按钮点击回调 */
|
||||
onDownload: () => void;
|
||||
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DecodeResultPaper({
|
||||
title,
|
||||
mimeType,
|
||||
blobSize,
|
||||
fileName,
|
||||
onFileNameChange,
|
||||
onDownload,
|
||||
children,
|
||||
}: DecodeResultPaperProps) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
{/* 标题 */}
|
||||
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||
|
||||
{/* 可选预览内容(ImageMode 的图片) */}
|
||||
{children}
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('inferredMimeType')}: {mimeType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('decodedSize')}: {formatFileSize(blobSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名输入 */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('decodedFileName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => onFileNameChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onDownload}
|
||||
disabled={!fileName.trim()}
|
||||
className="w-full rounded-lg font-bold"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.state.hasError && prevProps.children !== this.props.children) {
|
||||
this.setState({ hasError: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
|
||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-extrabold text-destructive mb-2">
|
||||
{getMessage('errorBoundary_title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{getMessage('errorBoundary_description')}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={this.handleReset}
|
||||
className="rounded-lg font-bold shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{getMessage('errorBoundary_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export { ErrorBoundary };
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
||||
*
|
||||
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||
*
|
||||
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
|
||||
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
|
||||
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
|
||||
*
|
||||
* @module GlobalSnackbar
|
||||
* @version 1.1.0
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 方式一:受控组件
|
||||
* <GlobalSnackbar
|
||||
* message="操作成功"
|
||||
* open={isOpen}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* severity="success"
|
||||
* />
|
||||
*
|
||||
* // 方式二:Hook 方式 (局部状态)
|
||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||
* showMessage('Hello!', { severity: 'info' });
|
||||
*
|
||||
* // 方式三:Context 方式 (全局状态)
|
||||
* // 在根组件包裹 Provider
|
||||
* <SnackbarProvider>
|
||||
* <App />
|
||||
* </SnackbarProvider>
|
||||
*
|
||||
* // 在子组件中使用
|
||||
* const { showMessage } = useSnackbar();
|
||||
* showMessage('Global Message');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
JSX,
|
||||
useState,
|
||||
useRef,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
} from 'react';
|
||||
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Snackbar 消息严重程度类型
|
||||
* @description 决定 Alert 组件的颜色和图标
|
||||
* - success: 绿色,成功提示
|
||||
* - info: 蓝色,信息提示
|
||||
* - warning: 橙色,警告提示
|
||||
* - error: 红色,错误提示
|
||||
*/
|
||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的属性接口
|
||||
* @interface GlobalSnackbarProps
|
||||
*/
|
||||
export interface GlobalSnackbarProps {
|
||||
/** 消息内容,要显示的提示文本 */
|
||||
message: string;
|
||||
/** 是否显示 Snackbar */
|
||||
open: boolean;
|
||||
/** 关闭回调函数 */
|
||||
onClose: () => void;
|
||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
||||
autoHideDuration?: number;
|
||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
||||
anchorOrigin?: {
|
||||
vertical: 'top' | 'bottom';
|
||||
horizontal: 'left' | 'center' | 'right';
|
||||
};
|
||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
||||
showAlert?: boolean;
|
||||
/** 是否隐藏 Alert 图标,默认 false */
|
||||
hideIcon?: boolean;
|
||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||
sx?: React.CSSProperties;
|
||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||
alertSx?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* showMessage 方法的选项配置
|
||||
* @interface SnackbarOptions
|
||||
*/
|
||||
export interface SnackbarOptions {
|
||||
/** 消息级别:success | info | warning | error */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
||||
autoHideDuration?: number;
|
||||
/** 是否隐藏 Alert 图标 */
|
||||
hideIcon?: boolean;
|
||||
/** 是否使用 Alert 组件包裹 */
|
||||
showAlert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState Hook 的返回值类型
|
||||
* @interface UseSnackbarStateResult
|
||||
*/
|
||||
export interface UseSnackbarStateResult {
|
||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
||||
snackbarProps: GlobalSnackbarProps;
|
||||
/** 显示消息的方法 */
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
/** 关闭消息的方法 */
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的默认属性配置
|
||||
* @description 提供类型安全的默认值选择
|
||||
*/
|
||||
const defaultProps: Required<
|
||||
Pick<
|
||||
GlobalSnackbarProps,
|
||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
||||
>
|
||||
> = {
|
||||
severity: 'info',
|
||||
autoHideDuration: 2000,
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||
showAlert: true,
|
||||
hideIcon: false,
|
||||
};
|
||||
|
||||
const severityConfig: Record<
|
||||
SnackbarSeverity,
|
||||
{ icon: React.ElementType; bgClass: string; textClass: string }
|
||||
> = {
|
||||
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
|
||||
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
|
||||
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
|
||||
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件
|
||||
*
|
||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||
*
|
||||
* @param {GlobalSnackbarProps} props - 组件属性
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export function GlobalSnackbar({
|
||||
message,
|
||||
open,
|
||||
onClose,
|
||||
severity = defaultProps.severity,
|
||||
autoHideDuration = defaultProps.autoHideDuration,
|
||||
showAlert = defaultProps.showAlert,
|
||||
hideIcon = defaultProps.hideIcon,
|
||||
}: GlobalSnackbarProps): JSX.Element | null {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && autoHideDuration > 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, autoHideDuration);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [open, autoHideDuration, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const config = severityConfig[severity];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
{showAlert ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className="text-xs font-bold">{message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') return;
|
||||
closeMessage();
|
||||
};
|
||||
|
||||
/**
|
||||
* 传递给 GlobalSnackbar 组件的属性
|
||||
*
|
||||
* @description
|
||||
* - 组合当前状态和选项为完整的组件 props
|
||||
* - onClose 使用 handleClose 包装后的版本
|
||||
*/
|
||||
const snackbarProps: GlobalSnackbarProps = {
|
||||
message,
|
||||
open,
|
||||
onClose: handleClose,
|
||||
severity: options.severity,
|
||||
autoHideDuration: options.autoHideDuration,
|
||||
hideIcon: options.hideIcon,
|
||||
};
|
||||
|
||||
return {
|
||||
snackbarProps,
|
||||
showMessage,
|
||||
closeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Context & Provider ---
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Image, X } from 'lucide-react';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件变更回调 */
|
||||
onFileChange: (file: File) => void;
|
||||
/** 清除文件回调 */
|
||||
onClearFile: () => void;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 预览 URL 变更回调 */
|
||||
onPreviewUrlChange: (url: string) => void;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
/** 拖拽状态变更回调 */
|
||||
onDraggingChange: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ImageUploader = ({
|
||||
selectedFile,
|
||||
onFileChange,
|
||||
onClearFile,
|
||||
previewUrl,
|
||||
onPreviewUrlChange,
|
||||
dragging,
|
||||
onDraggingChange,
|
||||
}: ImageUploaderProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
onFileChange(file);
|
||||
onPreviewUrlChange(URL.createObjectURL(file));
|
||||
},
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
onClearFile();
|
||||
showMessage(t('qrCode:imageCleared'), {
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
}, [previewUrl, onClearFile, showMessage, t]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDraggingChange(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), {
|
||||
severity: 'error',
|
||||
autoHideDuration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [showMessage, handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||
dragging
|
||||
? 'border-green-600 bg-green-50'
|
||||
: selectedFile
|
||||
? 'border-green-600 bg-green-50/50'
|
||||
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||
{selectedFile ? (
|
||||
<div className="text-center w-full relative">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ClearIcon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
data-testid="ImageIcon"
|
||||
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||
/>
|
||||
<span className="block text-sm text-muted-foreground mb-1">
|
||||
{t('qrCode:clickToUpload')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('qrCode:supportFormats')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class PageErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error in page:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||
{getMessage('pageErrorBoundary_title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-5">
|
||||
{getMessage('pageErrorBoundary_description')}
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.stack || this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="font-medium shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{getMessage('errorBoundary_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export { PageErrorBoundary };
|
||||
export default PageErrorBoundary;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PageSkeleton 组件 - 页面加载骨架屏
|
||||
*
|
||||
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
|
||||
* 避免白屏闪烁,减少布局偏移
|
||||
*/
|
||||
interface PageSkeletonProps {
|
||||
/** 骨架屏类型 */
|
||||
variant?: 'dashboard' | 'tool';
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘卡片骨架屏
|
||||
*/
|
||||
function DashboardCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-5 h-[100px]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
|
||||
<div>
|
||||
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
|
||||
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具页面骨架屏
|
||||
*/
|
||||
function ToolPageSkeleton() {
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* 标题区域 */}
|
||||
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="flex-1" />
|
||||
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载骨架屏
|
||||
*
|
||||
* @param props - PageSkeletonProps
|
||||
* @returns 骨架屏 JSX 元素
|
||||
*/
|
||||
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||
if (variant === 'tool') {
|
||||
return <ToolPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<DashboardCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageSkeleton.displayName = 'PageSkeleton';
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 下载回调 */
|
||||
onDownload: () => void;
|
||||
/** 复制回调 */
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
const QrCodePreview = ({
|
||||
qrCodeDataUrl,
|
||||
onDownload,
|
||||
onCopy,
|
||||
className,
|
||||
...props
|
||||
}: QrCodePreviewProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
|
||||
// 空状态下的虚线骨架屏
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-xs">
|
||||
{/*
|
||||
2. 二维码容器适配:
|
||||
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||
*/}
|
||||
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1">
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" onClick={onCopy} className="flex-1">
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodePreview;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
const { t } = useI18n('common');
|
||||
|
||||
// 2. 稳定的动态动画类名映射
|
||||
const animationClass = useMemo(() => {
|
||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
}, [currentPage]);
|
||||
|
||||
const entryPointType = getEntryPointType();
|
||||
|
||||
// 骨架屏加载状态守卫
|
||||
if (!isLoaded) {
|
||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
// 3. 严格的路由查找与类型安全的组件分发
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
|
||||
className={cn(
|
||||
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
|
||||
animationClass,
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||
>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{/*
|
||||
4. 路由防御拦截:
|
||||
如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件,
|
||||
渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。
|
||||
*/}
|
||||
{MatchedComponent ? (
|
||||
<MatchedComponent />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
{t('router.notFoundDescription', { entryPointType })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
|
||||
export interface SwitchOption<T extends string | number = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
|
||||
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
'onChange'
|
||||
> {
|
||||
value: T;
|
||||
options: SwitchOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
buttonClassName?: string; // 替换原有的 buttonSx
|
||||
}
|
||||
|
||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
size = 'medium',
|
||||
className,
|
||||
buttonClassName,
|
||||
...props
|
||||
}: SwitchButtonGroupProps<T>) {
|
||||
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
|
||||
const sizeClasses = {
|
||||
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
|
||||
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
sizeClasses[size],
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
|
||||
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
|
||||
export type ValidateRule = {
|
||||
validator: (value: string) => boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolbarAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
type?: 'primary' | 'default' | 'danger';
|
||||
disabled?: boolean | ((value: string) => boolean);
|
||||
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||
};
|
||||
|
||||
export interface TextInputAreaProps extends Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'onChange'
|
||||
> {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
|
||||
/** 值变化回调,返回最新的字符串内容 */
|
||||
onChange?: (value: string) => void;
|
||||
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
showCount?: boolean;
|
||||
showClear?: boolean;
|
||||
allowCopy?: boolean;
|
||||
rules?: ValidateRule[];
|
||||
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||
actions?: ToolbarAction[];
|
||||
topExtra?: React.ReactNode;
|
||||
title?: string;
|
||||
externalError?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||
function ActionButton({
|
||||
action,
|
||||
value,
|
||||
globalDisabled,
|
||||
onAction,
|
||||
}: {
|
||||
action: ToolbarAction;
|
||||
value: string;
|
||||
globalDisabled: boolean;
|
||||
onAction: (action: ToolbarAction) => void;
|
||||
}) {
|
||||
const isBtnDisabled =
|
||||
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
default:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(action)}
|
||||
disabled={isBtnDisabled || globalDisabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
variantClasses[action.type || 'default'],
|
||||
)}
|
||||
>
|
||||
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||
const {
|
||||
value: controlledValue,
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
placeholder: placeholderProp,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
autoFocus = false,
|
||||
minRows = 4,
|
||||
maxRows = 12,
|
||||
maxLength,
|
||||
className,
|
||||
showCount = false,
|
||||
showClear = true,
|
||||
allowCopy = false,
|
||||
rules = [],
|
||||
validateTrigger = 'onAction',
|
||||
actions = [],
|
||||
topExtra,
|
||||
title,
|
||||
externalError,
|
||||
onClear,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const { t } = useI18n('common');
|
||||
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
const displayError = externalError ?? error;
|
||||
|
||||
// 双向合并 ref 指针
|
||||
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||
|
||||
// 1. 高性能的动态高度自适应计算
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textArea = internalRef.current;
|
||||
if (!textArea) return;
|
||||
|
||||
// 重置高度计算
|
||||
textArea.style.height = 'auto';
|
||||
|
||||
const computedMin = minRows * 24; // 每行粗略按 24px 计算
|
||||
const computedMax = maxRows * 24;
|
||||
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
||||
|
||||
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
// 当数值改变时自适应扩展
|
||||
React.useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
const validate = useCallback(
|
||||
(val: string, trigger?: string): boolean => {
|
||||
if (validateTrigger !== trigger && trigger) return true;
|
||||
for (const rule of rules) {
|
||||
if (!rule.validator(val)) {
|
||||
setError(rule.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError('');
|
||||
return true;
|
||||
},
|
||||
[rules, validateTrigger],
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newVal = e.target.value;
|
||||
if (maxLength && newVal.length > maxLength) {
|
||||
const msg = t('charCount', { count: maxLength });
|
||||
setError(msg);
|
||||
toast.warning(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isControlled) setInternalValue(newVal);
|
||||
onChange?.(newVal);
|
||||
|
||||
if (error) setError('');
|
||||
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||
};
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!isControlled) setInternalValue('');
|
||||
onChange?.('');
|
||||
setError('');
|
||||
internalRef.current?.focus();
|
||||
toast.success(t('textInputArea.cleared'));
|
||||
onClear?.();
|
||||
}, [isControlled, onChange, onClear, t]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: ToolbarAction) => {
|
||||
const isDisabled =
|
||||
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||
if (isDisabled || disabled) return;
|
||||
|
||||
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||
|
||||
action.onClick(value, {
|
||||
clear: handleClear,
|
||||
setError,
|
||||
});
|
||||
},
|
||||
[value, disabled, validate, validateTrigger, handleClear],
|
||||
);
|
||||
|
||||
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||
{hasTopBar && (
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||
{topExtra}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{topActions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
action={action}
|
||||
value={value}
|
||||
globalDisabled={disabled}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
{showCount && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{value.length}
|
||||
{maxLength ? ` / ${maxLength}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
||||
displayError &&
|
||||
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={internalRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
{hasBottomBar && (
|
||||
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
||||
{/* 左侧自定义动作 */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{bottomActions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
action={action}
|
||||
value={value}
|
||||
globalDisabled={disabled}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧系统按钮组 */}
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
{allowCopy && value && (
|
||||
<CopyButton
|
||||
text={value}
|
||||
tooltip={t('textInputArea.copyContent')}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-1"
|
||||
/>
|
||||
)}
|
||||
{showClear && value && !disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
aria-label={t('textInputArea.clear')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{displayError && (
|
||||
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{displayError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInputArea.displayName = 'TextInputArea';
|
||||
|
||||
export default TextInputArea;
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
History,
|
||||
Monitor,
|
||||
Moon,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
|
||||
|
||||
// 常量配置抽取(无需写在全局变量或 styles 对象里)
|
||||
const SEARCH_HISTORY_LIMIT = 10;
|
||||
const SEARCH_HISTORY_DISPLAY = 5;
|
||||
|
||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||
const { currentPage, goBack, navigateTo } = useRouter();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { t } = useI18n(['common', 'features']);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleOpenInTab = async () => {
|
||||
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||
window.close();
|
||||
};
|
||||
|
||||
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 从 Chrome Storage 异步初始化历史记录
|
||||
useEffect(() => {
|
||||
storageUtil
|
||||
.get('app/searchHistory', [])
|
||||
.then((history) => {
|
||||
if (history) setSearchHistory(history);
|
||||
})
|
||||
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||
}, []);
|
||||
|
||||
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
|
||||
const searchResults = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return FEATURES.filter((f) => {
|
||||
if (f.key === 'dashboard') return false;
|
||||
return (
|
||||
t(f.labelKey).toLowerCase().includes(query) ||
|
||||
t(f.descriptionKey).toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [searchQuery, t]);
|
||||
|
||||
const displayedHistory = useMemo(() => {
|
||||
if (searchQuery.trim()) return [];
|
||||
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
|
||||
}, [searchHistory, searchQuery]);
|
||||
|
||||
// 新增/持久化历史记录
|
||||
const saveToHistory = async (query: string) => {
|
||||
if (!query.trim()) return;
|
||||
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
|
||||
0,
|
||||
SEARCH_HISTORY_LIMIT,
|
||||
);
|
||||
setSearchHistory(nextHistory);
|
||||
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||
navigateTo(feature.key);
|
||||
saveToHistory(t(feature.labelKey));
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||
setMode(nextMap[mode]);
|
||||
};
|
||||
|
||||
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||
|
||||
// 4. 健壮的键盘导航交互
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||
if (searchQuery.trim()) {
|
||||
handleSelectFeature(searchResults[selectedIndex]);
|
||||
} else {
|
||||
const selectedQuery = displayedHistory[selectedIndex];
|
||||
if (selectedQuery) {
|
||||
setSearchQuery(selectedQuery);
|
||||
setSelectedIndex(-1);
|
||||
const matched = FEATURES.find(
|
||||
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
|
||||
);
|
||||
if (matched) handleSelectFeature(matched);
|
||||
}
|
||||
}
|
||||
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||
handleSelectFeature(searchResults[0]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowResults(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||
{/* 左侧:返回按钮区 */}
|
||||
<div className="flex w-10 items-center justify-start">
|
||||
{!isDashboard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label={t('common_buttons_back')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:搜索容器 */}
|
||||
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('common_buttons_search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setShowResults(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setShowResults(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t('common_buttons_search')}
|
||||
className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
aria-label={t('common:buttons.clearSearch')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 动态联想结果卡片 */}
|
||||
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
<ul role="listbox" className="p-1">
|
||||
{searchQuery.trim() ? (
|
||||
searchResults.length > 0 ? (
|
||||
searchResults.map((feature, index) => (
|
||||
<li
|
||||
key={feature.key}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => handleSelectFeature(feature)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
|
||||
{feature.icon && <feature.icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{t(feature.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t(feature.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t('common:buttons.noResults')}
|
||||
</li>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
|
||||
{t('common:buttons.recentSearch')}
|
||||
</div>
|
||||
{displayedHistory.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作区 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
|
||||
<ThemeIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={onOpenOptions} title={t('common_buttons_settings')}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
|
||||
describe('DecodeResultPaper 组件', () => {
|
||||
const defaultProps = {
|
||||
title: 'decodedFileOutput',
|
||||
mimeType: 'image/png',
|
||||
blobSize: 1024,
|
||||
fileName: 'decoded.png',
|
||||
onFileNameChange: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 MIME 类型信息', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应通过 formatFileSize 渲染文件大小', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染文件名输入框', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染下载按钮', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 children 内容', () => {
|
||||
render(
|
||||
<DecodeResultPaper {...defaultProps}>
|
||||
<div data-testid="preview">预览内容</div>
|
||||
</DecodeResultPaper>,
|
||||
);
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('修改文件名时应调用 onFileNameChange', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||
});
|
||||
|
||||
it('点击下载按钮时应调用 onDownload', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按钮状态', () => {
|
||||
it('文件名为空时下载按钮应禁用', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('文件名不为空时下载按钮应启用', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
|
||||
// 用于触发错误的测试子组件
|
||||
function ThrowError({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 正常渲染的子组件
|
||||
function NormalComponent({ text }: { text: string }) {
|
||||
return <div data-testid="normal-content">{text}</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
// 抑制测试中故意抛出的错误日志
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('正常渲染子组件', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="正常内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||
});
|
||||
|
||||
it('子组件抛出错误时显示错误 UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="测试错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('children 变化时重置错误状态', async () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="初始错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 切换到正常子组件
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="恢复后的内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('相同的 children 不重置错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 用相同的 children rerender
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误 UI 包含刷新按钮', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="按钮测试" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
|
||||
refreshButton.click();
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {
|
||||
GlobalSnackbar,
|
||||
type GlobalSnackbarProps,
|
||||
SnackbarProvider,
|
||||
useSnackbar,
|
||||
useSnackbarState,
|
||||
} from '@/components/GlobalSnackbar';
|
||||
|
||||
describe('GlobalSnackbar 组件系统', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const defaultProps: GlobalSnackbarProps = {
|
||||
message: '测试消息',
|
||||
open: true,
|
||||
onClose: mockOnClose,
|
||||
};
|
||||
|
||||
describe('GlobalSnackbar UI 渲染', () => {
|
||||
it('应渲染消息内容', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} />);
|
||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||
// 验证是否包含消息文本
|
||||
const alertElement = screen.getByText('测试消息');
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
// 验证父元素有正确的样式类
|
||||
const parent = alertElement.parentElement;
|
||||
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
||||
});
|
||||
|
||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||
// 图标使用 lucide-react 的 svg 元素
|
||||
const icon = document.querySelector('svg');
|
||||
expect(icon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应根据 severity 应用不同的样式', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||
const message = screen.getByText('测试消息');
|
||||
const parent = message.parentElement;
|
||||
expect(parent).toHaveClass('bg-red-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbarState Hook 逻辑', () => {
|
||||
it('应返回初始状态', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
expect(result.current.snackbarProps.message).toBe('');
|
||||
});
|
||||
|
||||
it('showMessage 应更新状态', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
act(() => {
|
||||
result.current.showMessage('新消息');
|
||||
});
|
||||
|
||||
expect(result.current.snackbarProps.open).toBe(true);
|
||||
expect(result.current.snackbarProps.message).toBe('新消息');
|
||||
});
|
||||
|
||||
it('closeMessage 应关闭消息', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
act(() => {
|
||||
result.current.showMessage('消息');
|
||||
});
|
||||
act(() => {
|
||||
result.current.closeMessage();
|
||||
});
|
||||
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbar Context Hook 优先级', () => {
|
||||
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
|
||||
|
||||
// 1. 测试 Hook Options 覆盖 Provider Options
|
||||
act(() => {
|
||||
result.current.showMessage('消息 1');
|
||||
});
|
||||
|
||||
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
||||
|
||||
// 2. 测试 Call Options 覆盖 Hook Options
|
||||
act(() => {
|
||||
result.current.showMessage('消息 2', { severity: 'error' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
|
||||
// 配置多端一致性常驻桩(WXT 规范)
|
||||
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||
|
||||
// 模拟 URL API
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||
|
||||
// 模拟 showMessage
|
||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||
useSnackbar: () => ({
|
||||
showMessage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ImageUploader 组件', () => {
|
||||
const mockOnFileChange = vi.fn();
|
||||
const mockOnClearFile = vi.fn();
|
||||
const mockOnPreviewUrlChange = vi.fn();
|
||||
const mockOnDraggingChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: null,
|
||||
onFileChange: mockOnFileChange,
|
||||
onClearFile: mockOnClearFile,
|
||||
previewUrl: '',
|
||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||
dragging: false,
|
||||
onDraggingChange: mockOnDraggingChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当没有选中文件时应显示上传提示', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
// 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
|
||||
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/格式/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示文件预览', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
// 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
|
||||
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示预览图片', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||
});
|
||||
|
||||
it('当选中文件时应显示清除按钮', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应包含隐藏的文件输入框', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件选择交互', () => {
|
||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||
});
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('拖拽交互', () => {
|
||||
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragOver(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true });
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent(dropzone, dropEvent);
|
||||
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清除文件功能', () => {
|
||||
it('点击清除按钮时应调用 onClearFile', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('粘贴功能', () => {
|
||||
it('监听粘贴事件', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
|
||||
it('组件卸载时应移除粘贴事件监听', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('拖拽状态时应应用拖拽样式', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有文件时应应用有文件样式', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const { container } = render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
|
||||
|
||||
// 用于触发错误的测试子组件
|
||||
function ThrowError({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 正常渲染的子组件
|
||||
function NormalComponent({ text }: { text: string }) {
|
||||
return <div data-testid="normal-content">{text}</div>;
|
||||
}
|
||||
|
||||
describe('PageErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
// 抑制测试中故意抛出的错误日志
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('正常渲染子组件', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<NormalComponent text="正常内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||
});
|
||||
|
||||
it('子组件抛出错误时显示错误卡片 UI', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="测试错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击重试按钮后恢复', async () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="可恢复错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 将子组件替换为正常组件,然后点击重试
|
||||
rerender(
|
||||
<PageErrorBoundary>
|
||||
<NormalComponent text="恢复后的内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||
retryButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resetKey 变化时自动重置错误状态', async () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<ThrowError message="页面 A 错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 切换 resetKey,同时提供正常子组件
|
||||
rerender(
|
||||
<PageErrorBoundary resetKey="page-b">
|
||||
<NormalComponent text="页面 B 内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resetKey 不变时保持错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<ThrowError message="初始错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 仅 children 变化,resetKey 不变,错误应保持
|
||||
rerender(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<NormalComponent text="新内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误 UI 包含重试按钮', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="按钮测试" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误信息以 monospace 格式显示', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="格式化测试" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const errorText = screen.getByText(/格式化测试/);
|
||||
expect(errorText).toBeInTheDocument();
|
||||
expect(errorText.tagName.toLowerCase()).toBe('pre');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
|
||||
describe('PageSkeleton 组件', () => {
|
||||
describe('渲染测试', () => {
|
||||
it('默认应渲染 dashboard 骨架屏', () => {
|
||||
const { container } = render(<PageSkeleton />);
|
||||
|
||||
// dashboard 骨架屏包含 6 个卡片
|
||||
const cards = container.querySelectorAll('.rounded-xl');
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
|
||||
const cards = container.querySelectorAll('.rounded-xl');
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it('variant 为 tool 时应渲染工具页面骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('布局结构测试', () => {
|
||||
it('dashboard 骨架屏应使用 grid 布局', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
const gridContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(gridContainer).toHaveClass('grid');
|
||||
});
|
||||
|
||||
it('tool 骨架屏应有内边距', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
const toolContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(toolContainer).toHaveClass('p-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('骨架屏元素测试', () => {
|
||||
it('dashboard 骨架屏应包含圆角和边框样式', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 获取第一个卡片容器
|
||||
const card = container.querySelector('.rounded-xl.border');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tool 骨架屏应包含动画脉冲效果', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import QrCodePreview from '@/components/QrCodePreview';
|
||||
|
||||
describe('QrCodePreview 组件', () => {
|
||||
const mockOnDownload = vi.fn();
|
||||
const mockOnCopy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
// 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
|
||||
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||
const testDataUrl = 'data:image/png;base64,test123';
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={testDataUrl}
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', testDataUrl);
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
// 💡 修复点 3:切换为正则,无缝过检
|
||||
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/下载二维码/));
|
||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/复制二维码/));
|
||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('RouterContainer 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||
mockRouterValue.isLoaded = false;
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
// 骨架屏使用 animate-pulse 类
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('动画类测试', () => {
|
||||
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-dashboard');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('路由处理测试', () => {
|
||||
it('currentPage 变化时应更新', () => {
|
||||
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
rerender(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('页面级错误隔离', () => {
|
||||
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
// 验证 RouterContainer 的 Box 结构存在
|
||||
const routerBox = container.querySelector('.page-transition-dashboard');
|
||||
expect(routerBox).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
|
||||
import type { StorageCleanerOptions } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
|
||||
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||
|
||||
describe('StorageCleanerConfirm 组件', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnConfirm = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultOptions: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
};
|
||||
|
||||
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
|
||||
return render(
|
||||
<StorageCleanerConfirm
|
||||
open={true}
|
||||
onClose={mockOnClose}
|
||||
onConfirm={mockOnConfirm}
|
||||
options={defaultOptions}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('open 为 true 时应渲染对话框', () => {
|
||||
renderComponent();
|
||||
// 💡 修复点 3:拥抱模糊正则断言。
|
||||
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
|
||||
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应显示警告信息', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(/不可撤销/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应将选中的选项显示为标签', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应显示取消和确认按钮', () => {
|
||||
renderComponent();
|
||||
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
|
||||
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击取消时应调用 onClose', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('点击确认时应调用 onConfirm', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('选项过滤测试', () => {
|
||||
it('应仅显示选中的选项', () => {
|
||||
const partialOptions: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: true,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: partialOptions });
|
||||
|
||||
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应处理空选项', () => {
|
||||
const emptyOptions: StorageCleanerOptions = {
|
||||
localStorage: false,
|
||||
sessionStorage: false,
|
||||
indexedDB: false,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: emptyOptions });
|
||||
|
||||
const chips = screen.queryAllByRole('button');
|
||||
expect(chips.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('对话框行为测试', () => {
|
||||
it('open 为 false 时不应渲染', () => {
|
||||
renderComponent({ open: false });
|
||||
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应使用不同选项渲染', () => {
|
||||
const customOptions: StorageCleanerOptions = {
|
||||
localStorage: false,
|
||||
sessionStorage: true,
|
||||
indexedDB: false,
|
||||
cookies: true,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: customOptions });
|
||||
|
||||
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
|
||||
describe('SwitchButtonGroup 组件', () => {
|
||||
const options = [
|
||||
{ value: 'a', label: '选项A' },
|
||||
{ value: 'b', label: '选项B' },
|
||||
];
|
||||
|
||||
it('应渲染所有选项按钮', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应高亮当前选中的按钮', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const buttonA = screen.getByRole('button', { name: /选项A/i });
|
||||
const buttonB = screen.getByRole('button', { name: /选项B/i });
|
||||
|
||||
// 选中的按钮有 bg-background text-foreground shadow-sm 类
|
||||
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||
// 未选中的按钮有 hover:bg-background/50 类
|
||||
expect(buttonB).toHaveClass('hover:bg-background/50');
|
||||
});
|
||||
|
||||
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
expect(handleChange).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('点击已选中按钮时不应触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
|
||||
// 新组件每次点击都会触发 onChange
|
||||
expect(handleChange).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('应支持通过 className 自定义样式', () => {
|
||||
const { container } = render(
|
||||
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
|
||||
);
|
||||
|
||||
const group = container.firstChild;
|
||||
expect(group).toHaveClass('custom-group');
|
||||
});
|
||||
|
||||
it('应支持 size 属性', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('应支持 buttonSx 自定义按钮样式', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应支持 ReactNode 类型的 label', () => {
|
||||
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label">自定义</span> }];
|
||||
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认按钮样式应禁止文字换行', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toHaveClass('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('buttonSx 传入时应覆盖默认换行样式', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('number 类型支持', () => {
|
||||
const numberOptions = [
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 4, label: '4' },
|
||||
];
|
||||
|
||||
it('应支持 number 类型的 value 渲染', () => {
|
||||
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应高亮 number 类型的当前选中项', () => {
|
||||
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
|
||||
|
||||
const button2 = screen.getByRole('button', { name: /^2$/i });
|
||||
const button4 = screen.getByRole('button', { name: /^4$/i });
|
||||
|
||||
expect(button2).toHaveClass('hover:bg-background/50');
|
||||
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||
});
|
||||
|
||||
it('点击 number 选项时应传回 number 值', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
expect(handleChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
|
||||
describe('TextInputArea 组件', () => {
|
||||
describe('基础渲染', () => {
|
||||
it('应渲染 placeholder', () => {
|
||||
render(<TextInputArea placeholder="请输入文本..." />);
|
||||
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染传入的 value', () => {
|
||||
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveValue('测试内容');
|
||||
});
|
||||
|
||||
it('默认显示清空按钮', () => {
|
||||
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无内容时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('readOnly 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showClear=false 时不显示清空按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('受控模式', () => {
|
||||
it('输入时触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮触发 onChange("")', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('非受控模式', () => {
|
||||
it('defaultValue 应显示初始值', () => {
|
||||
render(<TextInputArea defaultValue="初始值" />);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||
});
|
||||
|
||||
it('输入后应更新内部值', () => {
|
||||
render(<TextInputArea defaultValue="" />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(textarea).toHaveValue('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮应清空内容', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowCopy 复制功能', () => {
|
||||
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy=false 时不显示复制按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('复制时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
|
||||
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockRejectedValue(new Error('失败'));
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showCount 字符计数', () => {
|
||||
it('显示当前字符数', () => {
|
||||
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空内容时显示 0', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('设置 maxLength 时显示计数上限', () => {
|
||||
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength', () => {
|
||||
it('超出 maxLength 的输入应被截断', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('未超出 maxLength 的输入应正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('验证规则', () => {
|
||||
it('onChange 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onBlur 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onBlur"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.blur(textarea);
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('验证通过不应显示错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||
|
||||
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||
const handleAction = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onAction"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
actions={[
|
||||
{
|
||||
key: 'test',
|
||||
label: '执行',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('执行'));
|
||||
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('操作栏 actions', () => {
|
||||
it('应渲染顶部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染底部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击操作按钮触发 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('操作'));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(
|
||||
'内容',
|
||||
expect.objectContaining({
|
||||
clear: expect.any(Function),
|
||||
setError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled 为 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('primary 类型按钮应使用 contained 样式', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const btn = screen.getByText('主要');
|
||||
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('应渲染 title', () => {
|
||||
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('不设置 title 时不渲染标题', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled 和 readOnly', () => {
|
||||
it('disabled 时输入框应禁用', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('readOnly 时输入框应只读', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFocus', () => {
|
||||
it('autoFocus 应自动聚焦', () => {
|
||||
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('复制功能', () => {
|
||||
it('复制成功时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear 回调', () => {
|
||||
it('点击清空按钮时应调用 onClear', () => {
|
||||
const handleClear = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('externalError 外部错误', () => {
|
||||
it('设置 externalError 时应显示错误状态', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||
|
||||
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 为空时应隐藏错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
|
||||
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||
|
||||
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 优先级高于内部验证错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 清除后应显示内部验证错误', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoResize', () => {
|
||||
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('autoResize=false 时设置固定 rows', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式集成', () => {
|
||||
it('应透传 className', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
describe('ToolCard 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题和描述', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="测试工具"
|
||||
description="这是一个测试工具"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无描述时仅渲染标题', () => {
|
||||
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('提供快照内容时应渲染快照', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('未提供快照时不渲染快照区域', () => {
|
||||
const { container } = render(
|
||||
<ToolCard
|
||||
title="无快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onClick={() => {}}
|
||||
onNavigate={function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击时应调用 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可点击/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('按 Enter 键时应调用 onClick', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||
await act(async () => {
|
||||
button.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
});
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('应应用自定义颜色代码', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||
|
||||
// matchMedia must be mocked before ThemeModeProvider is imported
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('TopBar 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeModeProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</ThemeModeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByLabelText('返回')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染设置按钮', () => {
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByLabelText('设置')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击设置按钮时应调用 onOpenOptions', () => {
|
||||
const handleOpenOptions = vi.fn();
|
||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('设置'));
|
||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击返回按钮时应调用 goBack', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('返回'));
|
||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
Reference in New Issue
Block a user