diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2982e62..ae16e93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,13 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: Lint + # 💡 1. 提速核心:前置基建节点(Infrastructure Initialization) + # 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用! + setup: + name: Prepare Dependencies runs-on: ubuntu-latest + outputs: + cache-key: ${{ steps.cache-info.outputs.key }} steps: - name: Checkout uses: actions/checkout@v4 @@ -24,17 +28,53 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' + + # 建立基于 package-lock.json 唯一哈希的缓存大闸 + - name: Cache Node Modules + id: cache-nodemodules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-v22- - name: Install dependencies + if: steps.cache-nodemodules.outputs.cache-hit != 'true' run: npm ci + - name: Output Cache Key + id: cache-info + run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT + + # 💡 2. 静态语法质检节点(依赖前置节点完成) + lint: + name: Lint + runs-on: ubuntu-latest + needs: setup + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Restore Node Modules Instantantly + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} + - name: Run ESLint run: npm run lint + # 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查) typecheck: name: TypeScript Check runs-on: ubuntu-latest + needs: setup steps: - name: Checkout uses: actions/checkout@v4 @@ -43,17 +83,24 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Restore Node Modules Instantantly + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} + + - name: Generate WXT types + run: npx wxt prepare - name: Run TypeScript type check run: npm run typecheck + # 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件) test: name: Unit Tests runs-on: ubuntu-latest + needs: setup steps: - name: Checkout uses: actions/checkout@v4 @@ -62,21 +109,30 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Restore Node Modules Instantantly + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} + + - name: Generate WXT types + run: npx wxt prepare - name: Run tests run: npm run test + # 💡 5. 多端分布式最终编译节点(Production Matrix Compliance) build: name: Build (${{ matrix.browser }}) runs-on: ubuntu-latest - needs: [lint, typecheck, test] + # 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译 + needs: [ lint, typecheck, test ] strategy: matrix: - browser: [chrome] + # 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵, + # 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满! + browser: [ chrome, firefox ] fail-fast: false steps: - name: Checkout @@ -86,11 +142,21 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - - name: Install dependencies - run: npm ci + - name: Restore Node Modules Instantantly + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} - - name: Build (Chrome) - if: matrix.browser == 'chrome' - run: npm run build + # 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令 + - name: Generate WXT types + run: npx wxt prepare + + - name: Build Extension (${{ matrix.browser }}) + run: | + if npm run | grep -q "build:${{ matrix.browser }}"; then + npm run build:${{ matrix.browser }} + else + npm run build -- --browser ${{ matrix.browser }} + fi \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 373a1f3..8707676 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,9 @@ permissions: contents: write jobs: - # ── Phase 1: 全量 CI 检查 ──────────────────────────────────────────── - lint: - name: Lint + # ── Phase 1: 依赖统一前置基础架构(Infrastructure Stage) ─────────────────── + setup: + name: Prepare Dependencies runs-on: ubuntu-latest steps: - name: Checkout @@ -21,90 +21,133 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' + + - name: Cache Node Modules + id: cache-nodemodules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-release-v22- - name: Install dependencies + if: steps.cache-nodemodules.outputs.cache-hit != 'true' run: npm ci + # ── Phase 2: 全量生产级断言检查(秒级瞬时恢复缓存,安全闭环) ────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + needs: setup + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Restore Node Modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} - name: Run ESLint run: npm run lint typecheck: name: TypeScript Check runs-on: ubuntu-latest + needs: setup steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - + - name: Restore Node Modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} - name: Run TypeScript type check run: npm run compile test: name: Unit Tests runs-on: ubuntu-latest + needs: setup steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' - - - name: Install dependencies - run: npm ci - + - name: Restore Node Modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} - name: Run tests run: npm run test - # ── Phase 2: 打包 & 发布 ───────────────────────────────────────────── - release: - name: Package & Release + # ── Phase 3: 多端分布式高精打包(Compile & Upload Artifacts) ─────────────── + build-extension: + name: Package Extension runs-on: ubuntu-latest - needs: [lint, typecheck, test] + needs: [ lint, typecheck, test ] steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '22' - cache: 'npm' + - name: Restore Node Modules + uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} - - name: Install dependencies - run: npm ci - - - name: Package Chrome extension - run: npm run zip - - - name: Package Firefox extension - run: npm run zip:firefox - - - name: Find zip artifacts - id: find_zips + # 执行 WXT 高阶打包压缩指令 + - name: Build and Zip Extension run: | - CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1) - FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1) - echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT" - echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT" - echo "Found Chrome zip: $CHROME_ZIP" - echo "Found Firefox zip: $FIREFOX_ZIP" + npm run zip + npm run zip:firefox + + # 💡 核心自愈补丁:显式将 .output 下打包出的真实生产绝对路径文件, + # 稳固地上存至 GitHub 的常驻产物箱中进行安全物理隔离,防范后期发布网络崩溃导致产物蒸发! + - name: Upload Extension Artifacts + uses: actions/upload-artifact@v4 + with: + name: extension-zips + path: | + .output/*.zip + retention-days: 7 + + # ── Phase 4: 独立中央签发发布(Atomic Release Publisher) ─────────────────── + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: build-extension + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 💡 独立下载打包完好的绝对产物包 + - name: Download Extension Artifacts + uses: actions/download-artifact@v4 + with: + name: extension-zips + path: release-artifacts - name: Extract version from tag id: version run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + # 💡 最终无风险原子级发布大礼包 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -113,6 +156,6 @@ jobs: draft: false prerelease: ${{ contains(github.ref_name, '-') }} generate_release_notes: true + # 百分之百精准指向被下载下来的、毫无路径污染风险的 Zip 包实体 files: | - ${{ steps.find_zips.outputs.chrome_zip }} - ${{ steps.find_zips.outputs.firefox_zip }} + release-artifacts/*.zip \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..e2c49ef --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/Button.tsx b/components/Button.tsx deleted file mode 100644 index 1b42d61..0000000 --- a/components/Button.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material'; - -/** - * 按钮属性类型 - * 继承自 MUI ButtonProps,支持所有 MUI Button 的属性 - */ -export type ButtonProps = MuiButtonProps; - -/** - * Button - 自定义按钮组件 - * - * 基于 MUI Button 的二次封装,提供统一的项目风格: - * - 禁用阴影和涟漪效果 - * - 圆角设计 (borderRadius: 4) - * - 固定高度和字体大小 - * - hover 时轻微上浮效果 - * - 支持 sx 数组合并 - * - * @example - * ```tsx - * - * ``` - * - * @param sx - 自定义样式,支持数组或单个样式对象 - * @param props - 其他 MUI Button 属性 - * @returns 按钮组件 - */ -export function Button({ sx = [], ...props }: ButtonProps) { - return ( - - ); -} - -export default Button; diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx index 2e77700..77c89b9 100644 --- a/components/CopyButton.tsx +++ b/components/CopyButton.tsx @@ -1,45 +1,25 @@ import React, { useEffect, useRef, useState } from 'react'; -import { IconButton, Tooltip } from '@mui/material'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckIcon from '@mui/icons-material/Check'; +import { Check, Copy } from 'lucide-react'; import { copyTextToClipboard } from '@/utils/clipboard'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; +import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数 +import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast -/** - * 复制按钮组件属性 - * @param text 要复制的文本 - * @param tooltip 提示信息 - * @param size 按钮大小 - * @param color 按钮颜色 - * @param style 自定义样式 - * @param showMessage 消息提示函数,用于显示复制成功或失败的消息 - */ -interface CopyButtonProps { +// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等 +interface CopyButtonProps extends React.ButtonHTMLAttributes { text: string; tooltip?: string; size?: 'small' | 'medium' | 'large'; - color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string; - style?: React.CSSProperties; - showMessage?: (message: string, options?: SnackbarOptions) => void; + // 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决 + variant?: 'default' | 'secondary' | 'ghost' | 'outline'; } -/** - * 复制按钮组件 - * @param text 要复制的文本 - * @param tooltip 提示信息 - * @param size 按钮大小 - * @param color 按钮颜色 - * @param style 自定义样式 - * @param showMessage 消息提示函数,用于显示复制成功或失败的消息 - * @returns 复制按钮组件 - */ export const CopyButton: React.FC = ({ text, tooltip = '复制', size = 'small', - color = 'primary', - style, - showMessage, + variant = 'ghost', + className, + ...props }) => { const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); @@ -50,50 +30,63 @@ export const CopyButton: React.FC = ({ }; }, []); - const handleCopy = async () => { - if (text) { - const success = await copyTextToClipboard(text); - if (success) { - showMessage?.('复制成功', { severity: 'success' }); - setCopied(true); - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), 1500); - } else { - showMessage?.('复制失败', { severity: 'error' }); - } + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件 + + if (!text) { + toast.error('无内容可复制'); + return; + } + + const success = await copyTextToClipboard(text); + if (success) { + toast.success('复制成功'); + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); } else { - showMessage?.('无内容可复制', { severity: 'error' }); + toast.error('复制失败'); } }; + // 4. 将控制尺寸的类名标准化 + const sizeClasses = { + small: 'h-8 w-8 text-xs', + medium: 'h-10 w-10 text-sm', + large: 'h-12 w-12 text-base', + }; + + // 5. 映射 shadcn 的底层通用 Variant 类名 + const variantClasses = { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + }; + return ( - - - `0 2px 8px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.05)'}`, - '&:hover': { - bgcolor: copied - ? 'success.main' - : !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color) - ? color - : `${color}.main`, - color: 'background.paper', - }, - }} - > - {copied ? ( - - ) : ( - - )} - - + ); }; diff --git a/components/DecodeResultPaper.tsx b/components/DecodeResultPaper.tsx index a668307..617ffb5 100644 --- a/components/DecodeResultPaper.tsx +++ b/components/DecodeResultPaper.tsx @@ -2,13 +2,13 @@ * DecodeResultPaper * * FileMode 与 ImageMode 通用的 decode 结果展示组件。 - * 提取了二者 decode 输出区完全一致的 Paper 结构: + * 提取了二者 decode 输出区完全一致的结构: * 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮 * * FileMode 直接使用,ImageMode 通过 children 传入图片预览。 */ -import { alpha, Button, Paper, Stack, TextField, Typography } from '@mui/material'; -import DownloadIcon from '@mui/icons-material/Download'; +import { Download } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { formatFileSize } from '@/utils/base64Converter'; import { useTranslation } from 'react-i18next'; @@ -41,59 +41,46 @@ export default function DecodeResultPaper({ const { t } = useTranslation('base64Converter'); return ( - alpha(theme.palette.info.main, 0.04), - border: '1px solid', - borderColor: (theme) => alpha(theme.palette.info.main, 0.15), - }} - > +
{/* 标题 */} - - {title} - + {title} {/* 可选预览内容(ImageMode 的图片) */} {children} {/* 文件信息 */} - - +
+ {t('inferredMimeType')}: {mimeType} - - + + {t('decodedSize')}: {formatFileSize(blobSize)} - - + +
{/* 文件名输入 */} - onFileNameChange(e.target.value)} - sx={{ mb: 1.5 }} - /> +
+ + 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" + /> +
{/* 下载按钮 */} - +
); } diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index c319a50..259dc8d 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -1,8 +1,6 @@ import { Component, ErrorInfo, ReactNode } from 'react'; -import { Box, Button, Container, Paper, Typography } from '@mui/material'; -import type { Theme } from '@mui/material/styles'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import RefreshIcon from '@mui/icons-material/Refresh'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; interface Props { children: ReactNode; @@ -43,63 +41,30 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { return ( - - - - - 糟糕,出了点问题 - - +
+
+ +

糟糕,出了点问题

+

应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。 - +

{this.state.error && ( - - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.100', - borderRadius: 2, - textAlign: 'left', - maxHeight: '200px', - overflow: 'auto', - }} - > - +
+
                   {this.state.error.toString()}
-                
-              
+                
+
)} - - +
+
); } diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index 1622054..f663106 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -38,12 +38,14 @@ import { JSX, useState, + useRef, createContext, useContext, + useEffect, type ReactNode, type SyntheticEvent, } from 'react'; -import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material'; +import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react'; /** * Snackbar 消息严重程度类型 @@ -80,9 +82,9 @@ export interface GlobalSnackbarProps { /** 是否隐藏 Alert 图标,默认 false */ hideIcon?: boolean; /** 自定义样式,透传给外层 Snackbar 组件 */ - sx?: SxProps; + sx?: React.CSSProperties; /** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */ - alertSx?: SxProps; + alertSx?: React.CSSProperties; } /** @@ -130,11 +132,20 @@ const defaultProps: Required< 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 组件 * * 全局消息提示的展示组件,支持受控和非受控两种使用模式。 - * 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。 * * @param {GlobalSnackbarProps} props - 组件属性 * @returns {JSX.Element} @@ -145,55 +156,42 @@ export function GlobalSnackbar({ onClose, severity = defaultProps.severity, autoHideDuration = defaultProps.autoHideDuration, - anchorOrigin = defaultProps.anchorOrigin, showAlert = defaultProps.showAlert, hideIcon = defaultProps.hideIcon, -}: GlobalSnackbarProps): JSX.Element { +}: GlobalSnackbarProps): JSX.Element | null { + const timerRef = useRef | 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 ( - - - {showAlert ? ( - - `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, - '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem' }, - '& .MuiAlert-message': { padding: '6px 0' }, - }} - > - {message} - - ) : ( -
{message}
- )} -
-
+
+ {showAlert ? ( +
+ {!hideIcon && } + {message} +
+ ) : ( +
{message}
+ )} +
); } diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx index c1144af..6aba860 100644 --- a/components/ImageUploader.tsx +++ b/components/ImageUploader.tsx @@ -1,8 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import { Box, IconButton, Typography } from '@mui/material'; -import ImageIcon from '@mui/icons-material/Image'; -import ClearIcon from '@mui/icons-material/Clear'; -import { qrCodePageStyles } from '@/config/pageTheme'; +import { Image, X } from 'lucide-react'; import { useSnackbar } from '@/components/GlobalSnackbar'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; @@ -115,9 +112,14 @@ const ImageUploader = ({ }, [showMessage, handleFileChange, t]); return ( - - + ); }; diff --git a/components/PageErrorBoundary.tsx b/components/PageErrorBoundary.tsx index 4e6c592..6bb511e 100644 --- a/components/PageErrorBoundary.tsx +++ b/components/PageErrorBoundary.tsx @@ -1,8 +1,6 @@ import { Component, ErrorInfo, ReactNode } from 'react'; -import { Box, Button, Paper, Typography } from '@mui/material'; -import type { Theme } from '@mui/material/styles'; -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; -import RefreshIcon from '@mui/icons-material/Refresh'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; interface Props { children: ReactNode; @@ -16,7 +14,7 @@ interface State { /** * 页面级错误边界组件:捕获子组件树中的 JavaScript 错误 - * 与全局 ErrorBoundary 的区别:使用轻量内嵌卡片 UI,提供重试按钮 + * 完美适配 shadcn/ui 语义化主题与暗黑模式 */ export class PageErrorBoundary extends Component { state: State = { @@ -45,75 +43,48 @@ export class PageErrorBoundary extends Component { render() { if (this.state.hasError) { return ( - - - - - 该页面加载失败 - - - 页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。 - +
+ {/* + 1. 适配暗黑模式的容器设计: + 不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5, + 并在黑夜模式下会自动转为深红底色,绝不刺眼。 + */} +
+ {/* 2. 状态符号改用标准的 text-destructive 语义色 */} +
+ +
+ +

该功能运行异常

+

+ 该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。 +

+ + {/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */} {this.state.error && ( - - theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.100', - borderRadius: 2, - textAlign: 'left', - maxHeight: '160px', - overflow: 'auto', - }} - > - - {this.state.error.toString()} - - +
+
+                  {this.state.error.stack || this.state.error.toString()}
+                
+
)} + + {/* + 4. 严谨调用 shadcn 原子 Button: + 去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。 + 它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。 + */} - - +
+
); } diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx index b08050d..3bd9fac 100644 --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -1,14 +1,15 @@ -import { alpha, Box, Stack, SxProps, Theme, Typography, useTheme } from '@mui/material'; import { ReactNode, useMemo } from 'react'; import { getEntryPointType } from '@/config/features'; +import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具 -/** - * PageHeader 组件属性接口 - */ -export interface PageHeaderProps { +export interface PageHeaderProps extends React.HTMLAttributes { /** 要显示的图标组件 */ icon: ReactNode; - /** 图标的颜色,默认使用主题 primary.main 色 */ + /** + * 图标的颜色,支持: + * 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐 + * 2. 原生颜色值 (如 '#3b82f6') + */ iconColor?: string; /** 主标题文本 */ title: string; @@ -16,100 +17,88 @@ export interface PageHeaderProps { subtitle?: string; /** 在标题右侧显示的徽章/标签组件(可选) */ badge?: ReactNode; - /** 图标容器的自定义样式 */ - iconSx?: SxProps; - /** 标题文本的自定义样式 */ - titleSx?: SxProps; - /** 副标题文本的自定义样式 */ - subtitleSx?: SxProps; - /** 整个组件的自定义样式 */ - sx?: SxProps; + /** 覆盖图标容器的类名 */ + iconClassName?: string; + /** 覆盖主标题的类名 */ + titleClassName?: string; + /** 覆盖副标题的类名 */ + subtitleClassName?: string; } -/** - * PageHeader - 通用页面标题栏组件 - * - * 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能 - * - * @example - * ```tsx - * } - * iconColor="#1976d2" - * title="时间戳转换" - * subtitle="Unix 毫秒数转换与格式化" - * /> - * ``` - * - * @example - * ```tsx - * } - * iconColor={storageCleanerPageStyles.warningColor} - * title="存储清理" - * subtitle={domain} - * badge={已占用 {size}} - * /> - * ``` - */ export default function PageHeader({ icon, - iconColor, + iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6" title, subtitle, badge, - iconSx, - titleSx, - subtitleSx, - sx, + iconClassName, + titleClassName, + subtitleClassName, + className, + ...props }: PageHeaderProps) { - const theme = useTheme(); - const resolvedIconColor = iconColor ?? theme.palette.primary.main; const entryPointType = useMemo(() => getEntryPointType(), []); + // 扩展环境判断:如果是 popup 形式则不渲染头部 if (entryPointType === 'popup') { return null; } + // 判断传入的是否是 Hex/RGB 等原生颜色值 + const isRawColor = + iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl'); + return ( - +
{/* 图标容器 */} - - {icon} - + {/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */} +
{icon}
+
+ {/* 标题区域 */} - +
{/* 标题行(含徽章) */} - - +

{title} - - {badge} - - {/* 副标题 */} +

+ {badge &&
{badge}
} +
+ + {/* 副标题 - 使用 p 标签(block)保证换行 */} {subtitle && ( - {subtitle} - +

)} -
-
+ + ); } diff --git a/components/PageSkeleton.tsx b/components/PageSkeleton.tsx index 6f6b44a..2814850 100644 --- a/components/PageSkeleton.tsx +++ b/components/PageSkeleton.tsx @@ -4,9 +4,6 @@ * 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡 * 避免白屏闪烁,减少布局偏移 */ -import { Box, Skeleton, Stack, useTheme } from '@mui/material'; -import { alpha } from '@mui/material'; - interface PageSkeletonProps { /** 骨架屏类型 */ variant?: 'dashboard' | 'tool'; @@ -16,30 +13,19 @@ interface PageSkeletonProps { * 仪表盘卡片骨架屏 */ function DashboardCardSkeleton() { - const theme = useTheme(); - const borderColor = alpha(theme.palette.divider, 0.5); - return ( - - - - - - - - - - - - +
+
+
+
+
+
+
+
+
+
+
+
); } @@ -48,24 +34,24 @@ function DashboardCardSkeleton() { */ function ToolPageSkeleton() { return ( - +
{/* 标题区域 */} - +
{/* 输入区域 */} - +
{/* 控制栏 */} - - - - - - +
+
+
+
+
+
{/* 结果区域 */} - - +
+
); } @@ -81,22 +67,11 @@ export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProp } return ( - +
{Array.from({ length: 6 }).map((_, index) => ( ))} - +
); } diff --git a/components/QrCodePreview.tsx b/components/QrCodePreview.tsx index 424397e..5640f03 100644 --- a/components/QrCodePreview.tsx +++ b/components/QrCodePreview.tsx @@ -1,10 +1,10 @@ -import { Box, Button, Typography } from '@mui/material'; -import DownloadIcon from '@mui/icons-material/Download'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { qrCodePageStyles } from '@/config/pageTheme'; +import React from 'react'; +import { Copy, Download } from 'lucide-react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 -interface QrCodePreviewProps { +// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件 +interface QrCodePreviewProps extends React.HTMLAttributes { /** 二维码 Data URL */ qrCodeDataUrl: string; /** 下载回调 */ @@ -13,43 +13,76 @@ interface QrCodePreviewProps { onCopy: () => void; } -const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => { +const QrCodePreview = ({ + qrCodeDataUrl, + onDownload, + onCopy, + className, + ...props +}: QrCodePreviewProps) => { const { t } = useLazyTranslation('qrCode'); + // 空状态下的虚线骨架屏 if (!qrCodeDataUrl) { return ( - - - {t('qrCode:qrCodeWillShow')} - - +
+

{t('qrCode:qrCodeWillShow')}

+
); } return ( - - - QR Code - - - + + {/* 复制按钮:使用标准的主要行动按钮风格 (Default) */} + - - - + + {t('qrCode:copyQrButton')} + +
+
+
); }; diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index d7a71be..fbcb3ce 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,13 +1,15 @@ -import { Box } from '@mui/material'; import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; import { Suspense, useMemo } from 'react'; 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(); + // 2. 稳定的动态动画类名映射 const animationClass = useMemo(() => { return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; }, [currentPage]); @@ -16,31 +18,48 @@ export default function RouterContainer() { return getEntryPointType(); }, []); + // 骨架屏加载状态守卫 if (!isLoaded) { return ; } + // 3. 严格的路由查找与类型安全的组件分发 const currentFeature = FEATURES.find((f) => f.key === currentPage); - const Component = currentFeature ? currentFeature.components[entryPointType] : null; + const MatchedComponent = currentFeature?.components?.[entryPointType]; return ( - } > - {Component && } + + {/* + 4. 路由防御拦截: + 如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件, + 渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。 + */} + {MatchedComponent ? ( + + ) : ( +
+
+ +
+

页面未找到

+

+ 该功能在当前运行环境({entryPointType})下不可用或已被移除。 +

+
+ )} +
-
+
); } diff --git a/components/SwitchButtonGroup.tsx b/components/SwitchButtonGroup.tsx index 66df814..cf4c8ee 100644 --- a/components/SwitchButtonGroup.tsx +++ b/components/SwitchButtonGroup.tsx @@ -1,71 +1,75 @@ -import { ToggleButton, ToggleButtonGroup, type SxProps, type Theme } from '@mui/material'; +import React from 'react'; +import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 export interface SwitchOption { value: T; label: React.ReactNode; } -export interface SwitchButtonGroupProps { +// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制 +export interface SwitchButtonGroupProps extends Omit< + React.HTMLAttributes, + 'onChange' +> { value: T; options: SwitchOption[]; onChange: (value: T) => void; - sx?: SxProps; size?: 'small' | 'medium' | 'large'; - buttonSx?: SxProps; + buttonClassName?: string; // 替换原有的 buttonSx } export default function SwitchButtonGroup({ value, options, onChange, - sx, - size, - buttonSx, + size = 'medium', + className, + buttonClassName, + ...props }: SwitchButtonGroupProps) { + // 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', + }; + + const containerPadding = size === 'large' ? 'p-1' : 'p-1'; + return ( - v && onChange(v)} - sx={{ - width: '100%', - mb: 2, - borderRadius: 4, - bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), - border: '1px solid', - borderColor: 'divider', - p: 0.6, - '& .MuiToggleButtonGroup-grouped': { - flex: 1, - border: 'none', - borderRadius: 3.5, - mx: 0.3, - fontWeight: 800, - color: 'text.secondary', - transition: 'color 0.3s', - '&:not(:first-of-type)': { - borderLeft: 'none', - marginLeft: 0.6, - }, - '&.Mui-selected': { - bgcolor: 'background.paper', - color: 'primary.main', - boxShadow: '0 4px 12px rgba(0,0,0,0.05)', - }, - }, - ...sx, - }} +
- {options.map((option) => ( - - {option.label} - - ))} - + {options.map((option) => { + const isSelected = value === option.value; + + return ( + + ); + })} +
); } diff --git a/components/TextInputArea.tsx b/components/TextInputArea.tsx index d11ce5e..1cd441c 100644 --- a/components/TextInputArea.tsx +++ b/components/TextInputArea.tsx @@ -1,213 +1,88 @@ -/** - * TextInputArea - 多行文本输入组件 - * - * 提供功能丰富的多行文本输入体验,支持受控/非受控模式、验证规则、 - * 字符计数、工具栏操作、复制/清空等交互能力。 - * - * @module TextInputArea - * - * @example - * ```tsx - * // 基础用法 - * - * - * // 受控模式 - * - * - * // 带验证规则 - * v.length >= 3, message: '至少3个字符' }]} - * validateTrigger="onBlur" - * /> - * - * // 带操作按钮 - * - * ``` - */ - -import { useRef, useState, useCallback, forwardRef, RefObject } from 'react'; -import { - Box, - Button, - IconButton, - TextField, - Tooltip, - Typography, - alpha, - type SxProps, -} from '@mui/material'; -import type { Theme } from '@mui/material/styles'; -import CloseIcon from '@mui/icons-material/Close'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import { Copy, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast -/** 文本验证规则 */ export type ValidateRule = { - /** 验证函数,返回 true 表示通过 */ 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 { - /** 受控模式下的当前值 */ +export interface TextInputAreaProps extends Omit< + React.TextareaHTMLAttributes, + 'onChange' +> { value?: string; - /** 非受控模式下的初始值,组件挂载时有效 */ defaultValue?: string; - /** 值变化回调 */ + + /** 值变化回调,返回最新的字符串内容 */ onChange?: (value: string) => void; - /** 占位文本 */ - placeholder?: string; - /** 是否禁用 */ - disabled?: boolean; - /** 是否只读 */ - readOnly?: boolean; - /** 是否自动聚焦 */ - autoFocus?: boolean; - /** 最小行数(autoResize 为 true 时生效) */ minRows?: number; - /** 最大行数(autoResize 为 true 时生效) */ maxRows?: number; - /** 最大字符数限制 */ - maxLength?: number; - /** 外层容器类名 */ - className?: string; - /** 外层容器样式 */ - style?: React.CSSProperties; - /** 外层容器 sx */ - sx?: SxProps; - - /** 是否显示字符计数 */ showCount?: boolean; - /** 是否显示清空按钮,默认 true */ showClear?: boolean; - /** 是否允许复制内容 */ allowCopy?: boolean; - /** 是否启用自动调整高度,默认 true */ - autoResize?: boolean; - - /** 验证规则列表 */ rules?: ValidateRule[]; - /** 验证触发时机:失焦(onBlur) / 输入时(onChange) / 操作前(onAction),默认 onAction */ validateTrigger?: 'onBlur' | 'onChange' | 'onAction'; - - /** 工具栏操作按钮列表 */ actions?: ToolbarAction[]; - /** 顶部栏左侧额外内容 */ topExtra?: React.ReactNode; - - /** 顶部栏标题 */ title?: string; - - /** 消息提示回调,用于展示 Toast 通知 */ - showMessage?: (message: string, options?: SnackbarOptions) => void; - - /** 外部错误消息,由父组件控制,优先于内部验证错误 */ externalError?: string; - - /** 清空按钮点击后的额外回调 */ onClear?: () => void; } -/** ActionButton 内部组件的属性 */ -interface ActionButtonProps { - action: ToolbarAction; - value: string; - globalDisabled: boolean; - variant?: 'text' | 'contained'; - onAction: (action: ToolbarAction) => void; - size?: 'small' | 'medium'; - compact?: boolean; -} - -/** - * 工具栏操作按钮 - 根据 action.type 自动应用样式 - * - * - primary:填充主色背景 - * - danger:红色文字 + 悬停红色背景 - * - default(默认):灰色文字 + 悬停灰色背景 - */ +// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐 function ActionButton({ action, value, globalDisabled, - variant = 'text', onAction, - size = 'small', - compact, -}: ActionButtonProps) { +}: { + action: ToolbarAction; + value: string; + globalDisabled: boolean; + onAction: (action: ToolbarAction) => void; +}) { const isBtnDisabled = - typeof action.disabled === 'function' ? action.disabled(value) : action.disabled || !value; + typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false); - const typeStyles: Record = {}; - - if (action.type === 'primary') { - if (variant !== 'contained') { - typeStyles.bgcolor = 'primary.main'; - typeStyles.color = 'primary.contrastText'; - typeStyles['&:hover'] = { bgcolor: 'primary.dark' }; - } - } else if (action.type === 'danger') { - typeStyles.color = 'error.main'; - typeStyles['&:hover'] = { - bgcolor: (theme: Theme) => alpha(theme.palette.error.main, 0.08), - }; - } else { - typeStyles.color = 'text.secondary'; - typeStyles['&:hover'] = { - bgcolor: (theme: Theme) => alpha(theme.palette.grey[500], 0.1), - }; - } + 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 ( - + ); } -/** - * TextInputArea 组件 - * - * 多行文本输入组件,支持受控/非受控双模式、验证规则、工具栏操作等。 - * 使用 forwardRef 暴露底层 textarea DOM 节点。 - */ const TextInputArea = forwardRef((props, ref) => { const { value: controlledValue, @@ -220,41 +95,54 @@ const TextInputArea = forwardRef((props minRows = 4, maxRows = 12, maxLength, - className = '', - style, - sx: containerSx, + className, showCount = false, showClear = true, allowCopy = false, - autoResize = true, rules = [], validateTrigger = 'onAction', actions = [], topExtra, title, - showMessage, externalError, onClear, + ...restProps } = props; - const textareaRef = useRef(null); + const internalRef = useRef(null); const [internalValue, setInternalValue] = useState(defaultValue); const [error, setError] = useState(''); const { t } = useTranslation('common'); const placeholder = placeholderProp ?? t('textInputArea.placeholder'); - /** 通过 value prop 是否存在来判断是否为受控模式 */ const isControlled = controlledValue !== undefined; const value = isControlled ? controlledValue : internalValue; - - /** 外部错误优先级高于内部验证错误 */ const displayError = externalError ?? error; - /** - * 执行所有验证规则 - * @param trigger - 触发验证的事件类型,用于匹配 validateTrigger - */ + // 双向合并 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; @@ -270,13 +158,12 @@ const TextInputArea = forwardRef((props [rules, validateTrigger], ); - /** 输入变化处理:更新值、清空错误、按需触发验证 */ const handleChange = (e: React.ChangeEvent) => { const newVal = e.target.value; if (maxLength && newVal.length > maxLength) { const msg = t('charCount', { count: maxLength }); setError(msg); - showMessage?.(msg, { severity: 'warning' }); + toast.warning(msg); return; } @@ -287,43 +174,36 @@ const TextInputArea = forwardRef((props if (validateTrigger === 'onChange') validate(newVal, 'onChange'); }; - /** 失焦时按需触发验证 */ const handleBlur = () => { if (validateTrigger === 'onBlur') validate(value, 'onBlur'); }; - /** 清空输入内容并重新聚焦 */ const handleClear = useCallback(() => { if (!isControlled) setInternalValue(''); onChange?.(''); setError(''); - textareaRef.current?.focus(); - showMessage?.(t('textInputArea.cleared'), { severity: 'success' }); + internalRef.current?.focus(); + toast.success('已清空内容'); onClear?.(); - }, [isControlled, onChange, showMessage, t, onClear]); + }, [isControlled, onChange, onClear]); - /** 复制当前内容到剪贴板 */ const handleCopy = useCallback(async () => { try { await navigator.clipboard.writeText(value); - showMessage?.(t('messages.copySuccess'), { severity: 'success' }); + toast.success('复制成功'); } catch { - setError(t('messages.copyError')); - showMessage?.(t('messages.copyError'), { severity: 'error' }); + setError('复制失败'); + toast.error('复制失败'); } - }, [value, showMessage, t]); + }, [value]); - /** 执行工具栏操作:检查禁用状态、验证、调用 onClick */ 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; - } + if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return; action.onClick(value, { clear: handleClear, @@ -333,46 +213,20 @@ const TextInputArea = forwardRef((props [value, disabled, validate, validateTrigger, handleClear], ); - /** 合并内部 ref 和外部传入的 forwardRef */ - const handleInputRef = useCallback( - (node: HTMLTextAreaElement | null) => { - textareaRef.current = node; - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - (ref as RefObject).current = node; - } - }, - [ref], - ); - 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 ( - +
{hasTopBar && ( - - - {title && ( - - {title} - - )} +
+
+ {title && {title}} {topExtra} - - - +
+
{topActions.map((action) => ( ((props value={value} globalDisabled={disabled} onAction={handleAction} - compact /> ))} {showCount && ( - + {value.length} {maxLength ? ` / ${maxLength}` : ''} - + )} - - +
+
)} - - +