From 050621d3dca5422de7f47030915df575972bd063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Thu, 19 Mar 2026 00:18:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=88=B3=E5=8A=9F=E8=83=BD=E4=B8=BA=E7=AE=80=E7=BA=A6?= =?UTF-8?q?=E5=8D=95=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将3个时间戳组件合并为一个简约页面 - 移除路由、导航栏等不必要组件 - 预填充当前时间/时间戳 - 始终显示结果框 - 清理测试文件和依赖 --- CLAUDE.md | 126 ++-------- components/CopyButton.tsx | 134 ----------- components/DatetimeToTimestamp.tsx | 133 ----------- components/Navbar.tsx | 61 ----- components/RoutePersistence.tsx | 43 ---- components/TimestampExecution.tsx | 106 --------- components/TimestampToDatetime.tsx | 148 ------------ components/__tests__/CopyButton.test.tsx | 52 ---- .../__tests__/DatetimeToTimestamp.test.tsx | 17 -- components/__tests__/Navbar.test.tsx | 47 ---- .../__tests__/RoutePersistence.test.tsx | 40 ---- .../__tests__/TimestampExecution.test.tsx | 39 --- .../__tests__/TimestampToDatetime.test.tsx | 17 -- components/constants.ts | 28 --- entrypoints/popup/App.tsx | 22 +- entrypoints/popup/pages/TimestampPage.tsx | 222 ++++++++++++++++-- package.json | 18 +- tsconfig.vitest.json | 18 -- vitest.config.ts | 21 -- 19 files changed, 239 insertions(+), 1053 deletions(-) delete mode 100644 components/CopyButton.tsx delete mode 100644 components/DatetimeToTimestamp.tsx delete mode 100644 components/Navbar.tsx delete mode 100644 components/RoutePersistence.tsx delete mode 100644 components/TimestampExecution.tsx delete mode 100644 components/TimestampToDatetime.tsx delete mode 100644 components/__tests__/CopyButton.test.tsx delete mode 100644 components/__tests__/DatetimeToTimestamp.test.tsx delete mode 100644 components/__tests__/Navbar.test.tsx delete mode 100644 components/__tests__/RoutePersistence.test.tsx delete mode 100644 components/__tests__/TimestampExecution.test.tsx delete mode 100644 components/__tests__/TimestampToDatetime.test.tsx delete mode 100644 components/constants.ts delete mode 100644 tsconfig.vitest.json delete mode 100644 vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 82a577e..96d2b79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概述 -这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换、用户操作录制与回放等。 +这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。 ## 核心命令 @@ -52,50 +52,33 @@ npx vitest run components/__tests__/CopyButton.test.tsx - **前端**: React 19 + TypeScript - **UI 库**: Material UI (MUI) - **状态管理**: React Hooks -- **数据库**: Dexie.js (IndexedDB) -- **录制回放**: rrweb - **路由**: React Router DOM ### 目录结构 ``` - -├── components/ # 可复用 UI 组件 -│ ├── CopyButton.tsx # 复制按钮组件 -│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件 -│ ├── Navbar.tsx # 导航栏组件 -│ ├── RoutePersistence.tsx # 路由持久化组件 -│ ├── TimestampExecution.tsx # 时间戳执行组件 -│ └── TimestampToDatetime.tsx # 时间戳转日期组件 -├── entrypoints/ # 浏览器扩展入口点 -│ ├── background.ts # 后台脚本(主进程) -│ ├── content.ts # 内容脚本(注入到页面) -│ ├── offscreen/ # 离屏文档(用于长时间运行任务) -│ ├── popup/ # 扩展弹窗界面 -│ │ ├── App.tsx # 弹窗主应用 -│ │ ├── main.tsx # 弹窗入口 -│ │ └── pages/ # 弹窗页面 -│ │ ├── RecordeReplayPage.tsx # 录制回放页面 -│ │ ├── TestPage.tsx # 测试页面 -│ │ └── TimestampPage.tsx # 时间戳工具页面 -│ └── options/ # 选项页面(未列出) -├── assets/ # 静态资源 -├── utils/ # 工具函数 -│ ├── chromeStorage.ts # Chrome 存储工具 -│ ├── dayjs.ts # 日期处理工具 -│ ├── messages.tsx # 消息通信工具 -│ ├── recordEventsDb.ts # IndexedDB 数据库工具(录制事件存储) -│ ├── recordUtils.tsx # 录制工具函数 -│ ├── tabUtils.ts # 标签页工具 -│ └── useRecorder.tsx # 录制器 Hook -├── types/ # 类型定义 -│ └── storage.d.ts # 存储相关类型 -├── public/ # 公共资源 -├── package.json # 项目依赖和脚本 -├── tsconfig.json # TypeScript 配置 -├── wxt.config.ts # WXT 配置 -└── web-ext.config.ts # WebExtensions 配置 - +├── components/ # 可复用 UI 组件 +│ ├── CopyButton.tsx # 复制按钮组件 +│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件 +│ ├── Navbar.tsx # 导航栏组件 +│ ├── RoutePersistence.tsx # 路由持久化组件 +│ ├── TimestampExecution.tsx # 时间戳执行组件 +│ └── TimestampToDatetime.tsx # 时间戳转日期组件 +├── entrypoints/ # 浏览器扩展入口点 +│ ├── background.ts # 后台脚本(主进程) +│ ├── content.ts # 内容脚本(注入到页面) +│ └── popup/ # 扩展弹窗界面 +│ ├── App.tsx # 弹窗主应用 +│ ├── main.tsx # 弹窗入口 +│ └── pages/ # 弹窗页面 +│ ├── TestPage.tsx # 测试页面 +│ └── TimestampPage.tsx # 时间戳工具页面 +├── utils/ # 工具函数 +│ ├── chromeStorage.ts # Chrome 存储工具 +│ ├── dayjs.ts # 日期处理工具 +│ └── messages.tsx # 消息通信工具 +├── types/ # 类型定义 +│ └── storage.d.ts # 存储相关类型 ``` ### 核心功能实现 @@ -106,69 +89,15 @@ npx vitest run components/__tests__/CopyButton.test.tsx - 依赖: dayjs 库进行日期处理 - 功能: 支持日期与时间戳的双向转换,支持多种格式 -#### 2. 录制与回放功能 - -- 位置: `utils/useRecorder.tsx` (核心录制逻辑)、`utils/recordUtils.tsx` (工具函数) -- 依赖: rrweb 库 -- 存储: IndexedDB (Dexie.js) - `utils/recordEventsDb.ts` -- 特点: 支持分块存储录制事件,优化性能 - -**录制架构流程:** - -1. **开始录制** (`popup:start` → `background.ts` → `content.ts`) - - Popup 发送开始录制消息 - - Background 生成 sessionId,初始化 IndexedDB 会话 - - Content Script 启动 rrweb 录制器 - -2. **事件存储** (`content:save-track-events`) - - rrweb 捕获事件后通过消息发送给 Background - - Background 使用 IndexedDB 分块存储(每块 100 个事件) - -3. **停止录制** (`popup:stop`) - - Background 从 IndexedDB 流式读取所有事件 - - 生成回放 HTML 文件并下载 - - 保留录制历史(不删除 IndexedDB 数据) - -4. **状态管理** - - 录制状态存储在 `chrome.storage.local` (recorder_state) - - 支持 Tab 切换检测和 Tab 关闭自动停止 - -#### 3. 通信系统 +#### 2. 通信系统 - 位置: `utils/messages.tsx` - 机制: 使用 `@webext-core/messaging` 库实现 -- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 ↔ 离屏文档 +- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 -**核心消息类型:** - -- `popup:start` / `popup:stop` - Popup 控制录制 -- `popup:started` / `popup:stopped` - 状态变化通知 -- `popup:check-status` - 查询录制状态 -- `content:start-recording` / `content:stop-recording` - 控制 Content Script -- `content:save-track-events` - 保存录制事件 -- `popup:get-sessions` / `popup:delete-session` - 录制会话管理 - -#### 4. 数据存储 +#### 3. 数据存储 - Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据) -- IndexedDB: `utils/recordEventsDb.ts` (用于存储大量录制事件) - -**IndexedDB 数据结构:** - -- **sessions** 表: 录制会话元数据 - - `id`: sessionId (string) - - `startTime`: 录制开始时间 (number) - - `tabId`: 录制的标签页 ID (number) - - `chunkCount`: 数据块数量 (number) - - `totalEvents`: 总事件数 (number) - -- **events** 表: 事件数据块 - - `id`: 自增 ID (number) - - `sessionId`: 关联的会话 ID - - `chunkIndex`: 块索引 (number) - - `events`: rrweb 事件数组 (unknown[]) - - `timestamp`: 时间戳 (number) - - CHUNK_SIZE: 100 个事件/块 ### 关键配置文件 @@ -189,8 +118,6 @@ permissions: [ 'activeTab', // 当前标签页 'scripting', // 脚本注入 'tabs', // 标签页管理 - 'offscreen', // 离屏文档 - 'downloads', // 下载管理 'debugger', // 调试器 ], host_permissions: [''] // 访问所有网站 @@ -203,7 +130,6 @@ host_permissions: [''] // 访问所有网站 - **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务 - **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互 - **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示 -- **离屏文档**: `entrypoints/offscreen/main.tsx` - 处理长时间运行的任务(如录制) ### 浏览器兼容性 diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx deleted file mode 100644 index 1571093..0000000 --- a/components/CopyButton.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { useState, useCallback, FC, ReactNode, useEffect } from 'react'; -import Button, { ButtonProps } from '@mui/material/Button'; -import Snackbar from '@mui/material/Snackbar'; -import Alert, { AlertColor } from '@mui/material/Alert'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckIcon from '@mui/icons-material/Check'; - -type CopyStatus = 'idle' | 'copying' | 'success' | 'error'; - -interface CopyButtonProps extends Omit { - textToCopy: string | number; - buttonText?: ReactNode; - successMessage?: string; - errorMessage?: string; - variant?: ButtonProps['variant']; -} - -const CopyButton: FC = ({ - textToCopy, - buttonText = '复制', - successMessage = '复制成功!', - errorMessage = '复制失败,请手动复制。', - variant = 'contained', - ...buttonProps -}) => { - const [status, setStatus] = useState('idle'); - const [openSnackbar, setOpenSnackbar] = useState(false); - const [snackbarContent, setSnackbarContent] = useState<{ - message: string; - severity: AlertColor; - } | null>(null); - - useEffect(() => { - if (status === 'success' || status === 'error') { - const timer = setTimeout(() => setStatus('idle'), 2000); - return () => clearTimeout(timer); - } - return undefined; - }, [status]); - - const performCopy = useCallback(async () => { - if (!textToCopy) { - console.warn('没有提供要复制的文本'); - return false; - } - const safeText = String(textToCopy); - try { - await navigator.clipboard.writeText(safeText); - return true; - } catch (err) { - console.error('使用 Clipboard API 复制失败:', err); - return false; - } - }, [textToCopy]); - - const handleClick = useCallback(async () => { - if (status !== 'idle') return; - - setStatus('copying'); - let isSuccess = false; - try { - [isSuccess] = await Promise.all([ - performCopy(), - new Promise((resolve) => setTimeout(resolve, 300)), - ]); - } catch (error) { - console.error('复制时出错:', error); - isSuccess = false; - } finally { - const newStatus = isSuccess ? 'success' : 'error'; - setStatus(newStatus); - setSnackbarContent({ - message: isSuccess ? successMessage : errorMessage, - severity: newStatus, - }); - setOpenSnackbar(true); - } - }, [performCopy, status, successMessage, errorMessage]); - - const handleCloseSnackbar = (_event?: Event | React.SyntheticEvent, reason?: string) => { - if (reason === 'clickaway') return; - setOpenSnackbar(false); - }; - - const renderButtonIcon = () => { - if (status === 'success') { - return ; - } - return ; - }; - - return ( - <> - - - {snackbarContent ? ( - - {snackbarContent.message} - - ) : undefined} - - - ); -}; - -export default CopyButton; diff --git a/components/DatetimeToTimestamp.tsx b/components/DatetimeToTimestamp.tsx deleted file mode 100644 index 31f42e5..0000000 --- a/components/DatetimeToTimestamp.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState, useCallback } from 'react'; -import dayjs from '@/utils/dayjs'; -import { - Button, - TextField, - Select, - MenuItem, - FormControl, - InputLabel, - Paper, - Stack, - Box, - SelectChangeEvent, -} from '@mui/material'; -import { TIME_ZONE_LIST, TIMESTAMP_UNITS } from './constants'; - -export function DatetimeToTimestamp() { - const [dateValue, setDateValue] = useState(() => dayjs().format('YYYY/MM/DD HH:mm:ss')); - const [selectedZone, setSelectedZone] = useState('Asia/Shanghai'); - const [result, setResult] = useState(''); - const [unit, setUnit] = useState('milliseconds'); - const [error, setError] = useState(''); - - const performConversion = useCallback( - (currentDate: string, zone: string, currentUnit: string) => { - if (!currentDate) { - setError('请输入有效的日期时间'); - return ''; - } - // 使用 tz 方法直接解析带时区的日期 - const timestamp = dayjs.tz(currentDate, 'YYYY/MM/DD HH:mm:ss', zone); - if (!timestamp.isValid()) { - setError('无效的日期时间格式'); - return ''; - } - setError(''); - const ms = timestamp.valueOf(); - return currentUnit === 'milliseconds' ? ms.toString() : Math.floor(ms / 1000).toString(); - }, - [], - ); - - const handleConvert = useCallback(() => { - const newResult = performConversion(dateValue, selectedZone, unit); - setResult(newResult); - }, [dateValue, selectedZone, unit, performConversion]); - - const handleUnitChange = useCallback( - (e: SelectChangeEvent) => { - const newUnit = e.target.value; - setUnit(newUnit); - if (result) { - setResult(performConversion(dateValue, selectedZone, newUnit) || ''); - } - }, - [dateValue, selectedZone, result, performConversion], - ); - - const handleZoneChange = useCallback((e: SelectChangeEvent) => { - setSelectedZone(e.target.value); - }, []); - - return ( - - - - { - setDateValue(e.target.value); - if (error) setError(''); - }} - error={!!error} - helperText={error || '格式: YYYY/MM/DD HH:mm:ss'} - fullWidth - variant="outlined" - /> - - 时区 - - - - - - - - - - - - 单位 - - - - - - ); -} diff --git a/components/Navbar.tsx b/components/Navbar.tsx deleted file mode 100644 index 25d4c29..0000000 --- a/components/Navbar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { NavLink, useLocation } from 'react-router-dom'; -import { ReactNode } from 'react'; -import { - AppBar, - Tab, - Tabs, - Toolbar, -} from '@mui/material'; - -interface RouteItem { - path: string; - label: string; - element: ReactNode; -} - -interface NavbarProps { - items?: RouteItem[]; -} - -function Navbar({ items = [] }: NavbarProps) { - const location = useLocation(); - - // Chrome 扩展 popup 固定尺寸,全部显示在滚动标签中 - // 精确匹配路由 - const activeTabIndex = items.findIndex((item) => location.pathname === item.path); - - return ( - - - - {items.map((item) => ( - - ))} - - - - ); -} - -export default Navbar; diff --git a/components/RoutePersistence.tsx b/components/RoutePersistence.tsx deleted file mode 100644 index e06fbc3..0000000 --- a/components/RoutePersistence.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { storageUtil } from '@/utils/chromeStorage'; - -const RoutePersistence = () => { - const location = useLocation(); - const navigate = useNavigate(); - - const isRestored = useRef(false); - - useEffect(() => { - const restoreRoute = async () => { - if (isRestored.current) return; - - try { - const lastRoute = await storageUtil.get('app/lastRoute'); - - if (lastRoute && lastRoute !== '/' && location.pathname === '/') { - navigate(lastRoute, { replace: true }); - } - } catch (err) { - console.error('恢复路由失败', err); - } finally { - isRestored.current = true; - } - }; - - restoreRoute(); - }, [navigate, location.pathname]); - - useEffect(() => { - const saveRoute = async () => { - if (!isRestored.current) return; - await storageUtil.set('app/lastRoute', location.pathname); - }; - - saveRoute(); - }, [location]); - - return null; -}; - -export default RoutePersistence; diff --git a/components/TimestampExecution.tsx b/components/TimestampExecution.tsx deleted file mode 100644 index a404562..0000000 --- a/components/TimestampExecution.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useEffect, useState, useCallback, JSX } from 'react'; -import CopyButton from './CopyButton'; -import { Button, Paper, Typography, Stack, Box } from '@mui/material'; - -/** - * 时间戳显示和执行组件 - */ -export function TimestampExecution(): JSX.Element { - const [currentTimestamp, setCurrentTimestamp] = useState(() => Math.floor(Date.now())); - const [showMilliseconds, setShowMilliseconds] = useState(true); - const [isRunningTimestamp, setIsRunningTimestamp] = useState(true); - - const displayTimestamp = showMilliseconds - ? currentTimestamp - : Math.floor(currentTimestamp / 1000); - - const unitText = showMilliseconds ? '毫秒' : '秒'; - - useEffect(() => { - let timer: number; - if (isRunningTimestamp) { - const interval = showMilliseconds ? 100 : 1000; - timer = window.setInterval(() => { - setCurrentTimestamp(Math.floor(Date.now())); - }, interval); - } - return () => clearInterval(timer); - }, [isRunningTimestamp, showMilliseconds]); - - const toggleUnit = useCallback(() => { - setShowMilliseconds((prev) => !prev); - }, []); - - const toggleTimestamp = useCallback(() => { - setIsRunningTimestamp((prev) => !prev); - }, []); - - const unitButtonLabel = showMilliseconds ? '切换为秒显示' : '切换为毫秒显示'; - const toggleButtonLabel = isRunningTimestamp ? '停止时间戳自动更新' : '开始时间戳自动更新'; - const toggleButtonText = isRunningTimestamp ? '停止' : '开始'; - - return ( - - - - {displayTimestamp} - - - {unitText} - - - - - - - - - - - - ); -} diff --git a/components/TimestampToDatetime.tsx b/components/TimestampToDatetime.tsx deleted file mode 100644 index 7ad078d..0000000 --- a/components/TimestampToDatetime.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import dayjs from '@/utils/dayjs'; -import { - Button, - TextField, - Select, - MenuItem, - FormControl, - InputLabel, - Paper, - Stack, - Box, - SelectChangeEvent, -} from '@mui/material'; -import { TIME_ZONE_LIST, TIMESTAMP_UNITS } from './constants'; - -export function TimestampToDatetime() { - const [timestampValue, setTimestampValue] = useState(() => dayjs().valueOf().toString()); - const [timestampResult, setTimestampResult] = useState(''); - const [unit, setUnit] = useState('milliseconds'); - const [selectedZone, setSelectedZone] = useState('Asia/Shanghai'); - const [error, setError] = useState(''); - - const performConversion = useCallback((val: string, zone: string, u: string) => { - if (!val || val.trim() === '') { - setError('请输入有效的时间戳'); - return ''; - } - const numberValue = Number(val); - if (isNaN(numberValue)) { - setError('时间戳必须是数字'); - return ''; - } - const d = u === 'milliseconds' ? dayjs(numberValue) : dayjs.unix(numberValue); - if (!d.isValid()) { - setError('无效的时间戳格式'); - return ''; - } - setError(''); - return d.tz(zone).format('YYYY/MM/DD HH:mm:ss'); - }, []); - - const handleConvert = useCallback(() => { - const newResult = performConversion(timestampValue, selectedZone, unit); - setTimestampResult(newResult); - }, [timestampValue, selectedZone, unit, performConversion]); - - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - setTimestampValue(e.target.value); - if (error) setError(''); - }, - [error], - ); - - const handleZoneChange = useCallback( - (e: SelectChangeEvent) => { - const newZone = e.target.value; - setSelectedZone(newZone); - if (timestampResult) { - const newResult = performConversion(timestampValue, newZone, unit); - setTimestampResult(newResult || ''); - } - }, - [performConversion, timestampResult, timestampValue, unit], - ); - - const handleUnitChange = useCallback( - (e: SelectChangeEvent) => { - const newUnit = e.target.value; - setUnit(newUnit); - if (timestampResult) { - const newResult = performConversion(timestampValue, selectedZone, newUnit); - setTimestampResult(newResult || ''); - } - }, - [performConversion, timestampResult, timestampValue, selectedZone], - ); - - return ( - - - - - - 单位 - - - - - - - - - - - - 时区 - - - - - - ); -} diff --git a/components/__tests__/CopyButton.test.tsx b/components/__tests__/CopyButton.test.tsx deleted file mode 100644 index f1360db..0000000 --- a/components/__tests__/CopyButton.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import CopyButton from '../CopyButton'; -import { vi } from 'vitest'; - -describe('CopyButton', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it('应该正确渲染默认文本', () => { - render(); - expect(screen.getByText('复制')).toBeInTheDocument(); - }); - - it('应该正确渲染自定义按钮文本', () => { - render(); - expect(screen.getByText('Custom Copy')).toBeInTheDocument(); - }); - - it('点击按钮时应该复制文本到剪贴板', async () => { - const writeTextMock = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: writeTextMock }, - writable: true, - }); - - render(); - const button = screen.getByText('复制'); - - fireEvent.click(button); - vi.advanceTimersByTime(300); - - expect(writeTextMock).toHaveBeenCalledWith('test content'); - }); - - it('当没有提供要复制的文本时,应该在控制台警告', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - render(); - const button = screen.getByText('复制'); - - fireEvent.click(button); - - expect(consoleWarnSpy).toHaveBeenCalledWith('没有提供要复制的文本'); - consoleWarnSpy.mockRestore(); - }); -}); diff --git a/components/__tests__/DatetimeToTimestamp.test.tsx b/components/__tests__/DatetimeToTimestamp.test.tsx deleted file mode 100644 index 566fafa..0000000 --- a/components/__tests__/DatetimeToTimestamp.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { DatetimeToTimestamp } from '../DatetimeToTimestamp'; - -describe('DatetimeToTimestamp', () => { - it('应该正确渲染组件', () => { - render(); - expect(screen.getByLabelText('输入日期时间')).toBeInTheDocument(); - expect(screen.getByText('转换')).toBeInTheDocument(); - expect(screen.getByLabelText('转换结果')).toBeInTheDocument(); - }); - - it('应该初始化为当前日期时间', () => { - render(); - const input = screen.getByLabelText('输入日期时间') as HTMLInputElement; - expect(input.value).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/); - }); -}); diff --git a/components/__tests__/Navbar.test.tsx b/components/__tests__/Navbar.test.tsx deleted file mode 100644 index 17255fe..0000000 --- a/components/__tests__/Navbar.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import Navbar from '../Navbar'; -import { vi } from 'vitest'; - -const mockItems = [ - { path: '/', label: '首页', element:
首页
}, - { path: '/timestamp', label: '时间戳', element:
时间戳
}, -]; - -const renderWithRouter = (initialPath = '/', items = mockItems) => { - return render( - - - , - ); -}; - -describe('Navbar', () => { - beforeEach(() => { - // 模拟 useMediaQuery 以确保大屏幕行为 - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: query === '(min-width:768px)', - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })), - }); - }); - - it('应该正确渲染所有导航项(大屏幕)', () => { - renderWithRouter(); - mockItems.forEach((item) => { - expect(screen.getByText(item.label)).toBeInTheDocument(); - }); - }); - - it('没有提供 items 时应该正常渲染', () => { - render( - - - , - ); - expect(true).toBe(true); - }); -}); diff --git a/components/__tests__/RoutePersistence.test.tsx b/components/__tests__/RoutePersistence.test.tsx deleted file mode 100644 index 0aaa2fc..0000000 --- a/components/__tests__/RoutePersistence.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import RoutePersistence from '../RoutePersistence'; -import { storageUtil } from '@/utils/chromeStorage'; -import { vi } from 'vitest'; - -vi.mock('@/utils/chromeStorage', () => ({ - storageUtil: { - get: vi.fn(), - set: vi.fn(), - }, -})); - -const renderComponentOnly = (initialPath = '/') => { - return render( - - - , - ); -}; - -describe('RoutePersistence', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('组件应该正常渲染且返回 null', () => { - const { container } = renderComponentOnly(); - expect(container.firstChild).toBeNull(); - }); - - it('当在根路由时应该从存储中恢复路由', async () => { - const mockGet = storageUtil.get as vi.Mock; - mockGet.mockResolvedValue('/timestamp'); - - await renderComponentOnly('/'); - - expect(mockGet).toHaveBeenCalledWith('app/lastRoute'); - }); -}); diff --git a/components/__tests__/TimestampExecution.test.tsx b/components/__tests__/TimestampExecution.test.tsx deleted file mode 100644 index bf9a23a..0000000 --- a/components/__tests__/TimestampExecution.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { TimestampExecution } from '../TimestampExecution'; -import { vi } from 'vitest'; - -describe('TimestampExecution', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('应该正确渲染组件', () => { - render(); - expect(screen.getByText('切换单位')).toBeInTheDocument(); - expect(screen.getByText('复制')).toBeInTheDocument(); - expect(screen.getByText('停止')).toBeInTheDocument(); - }); - - it('初始状态应该显示毫秒单位', () => { - render(); - expect(screen.getByText('毫秒')).toBeInTheDocument(); - }); - - it('点击切换单位按钮应该切换为秒显示', () => { - render(); - const toggleUnitButton = screen.getByText('切换单位'); - fireEvent.click(toggleUnitButton); - expect(screen.getByText('秒')).toBeInTheDocument(); - }); - - it('点击停止按钮应该停止时间戳自动更新', () => { - render(); - const toggleButton = screen.getByText('停止'); - fireEvent.click(toggleButton); - expect(screen.getByText('开始')).toBeInTheDocument(); - }); -}); diff --git a/components/__tests__/TimestampToDatetime.test.tsx b/components/__tests__/TimestampToDatetime.test.tsx deleted file mode 100644 index 9c08315..0000000 --- a/components/__tests__/TimestampToDatetime.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { TimestampToDatetime } from '../TimestampToDatetime'; - -describe('TimestampToDatetime', () => { - it('应该正确渲染组件', () => { - render(); - expect(screen.getByLabelText('输入时间戳')).toBeInTheDocument(); - expect(screen.getByText('转换')).toBeInTheDocument(); - expect(screen.getByLabelText('转换结果')).toBeInTheDocument(); - }); - - it('应该初始化为当前时间戳', () => { - render(); - const input = screen.getByLabelText('输入时间戳') as HTMLInputElement; - expect(input.value).toMatch(/^\d{13}$/); - }); -}); diff --git a/components/constants.ts b/components/constants.ts deleted file mode 100644 index 0d80a75..0000000 --- a/components/constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const TIME_ZONE_LIST = [ - 'America/New_York', - 'America/Chicago', - 'America/Denver', - 'America/Los_Angeles', - 'America/Anchorage', - 'America/Honolulu', - 'Europe/London', - 'Europe/Paris', - 'Europe/Berlin', - 'Europe/Moscow', - 'Asia/Tokyo', - 'Asia/Shanghai', - 'Asia/Hong_Kong', - 'Asia/Singapore', - 'Asia/Dubai', - 'Asia/Kolkata', - 'Australia/Sydney', - 'Pacific/Auckland', -] as const; - -export const TIMESTAMP_UNITS = [ - { value: 'milliseconds', label: '毫秒 (ms)' }, - { value: 'seconds', label: '秒 (s)' }, -] as const; - -export type TimeZone = (typeof TIME_ZONE_LIST)[number]; -export type TimestampUnit = (typeof TIMESTAMP_UNITS)[number]['value']; diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 186b723..825415e 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,27 +1,11 @@ -// App.js -import { HashRouter as Router, Routes, Route } from 'react-router-dom'; import TimestampPage from './pages/TimestampPage'; -import Navbar from '../../components/Navbar'; -import RoutePersistence from '../../components/RoutePersistence'; import './App.css'; -// 路由配置数据 -const navItems = [{ path: '/', label: '时间戳', element: }]; - function App() { return ( - - -
- - - - {navItems.map((item) => ( - - ))} - -
-
+
+ +
); } diff --git a/entrypoints/popup/pages/TimestampPage.tsx b/entrypoints/popup/pages/TimestampPage.tsx index 172ab41..c0fdd6f 100644 --- a/entrypoints/popup/pages/TimestampPage.tsx +++ b/entrypoints/popup/pages/TimestampPage.tsx @@ -1,18 +1,212 @@ -import { TimestampToDatetime } from '@/components/TimestampToDatetime'; -import { DatetimeToTimestamp } from '@/components/DatetimeToTimestamp'; -import { TimestampExecution } from '@/components/TimestampExecution'; -import { Container, Box } from '@mui/material'; +import { useState, useEffect, useCallback } from 'react'; +import dayjs from '@/utils/dayjs'; +import { + Button, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + Paper, + Stack, + Typography, + Box, + IconButton, + Snackbar, + Alert, +} from '@mui/material'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const; + +export default function TimestampPage() { + const [now, setNow] = useState(() => Date.now()); + const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); + const [tsInput, setTsInput] = useState(() => String(Date.now())); + const [dtInput, setDtInput] = useState(() => dayjs().format('YYYY/MM/DD HH:mm:ss')); + const [result, setResult] = useState(''); + const [unit, setUnit] = useState<'ms' | 's'>('ms'); + const [zone, setZone] = useState<(typeof ZONES)[number]>('Asia/Shanghai'); + const [error, setError] = useState(''); + const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' }); + + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setSnack({ open: true, msg: '已复制' }); + } catch { + setSnack({ open: true, msg: '复制失败' }); + } + }, []); + + const convertTs2Dt = useCallback(() => { + if (!tsInput) { + setError('请输入时间戳'); + return; + } + const num = Number(tsInput); + if (isNaN(num)) { + setError('无效数字'); + return; + } + const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num); + if (!d.isValid()) { + setError('无效时间戳'); + return; + } + setError(''); + setResult(d.tz(zone).format('YYYY/MM/DD HH:mm:ss')); + }, [tsInput, unit, zone]); + + const convertDt2Ts = useCallback(() => { + if (!dtInput) { + setError('请输入日期时间'); + return; + } + const d = dayjs.tz(dtInput, 'YYYY/MM/DD HH:mm:ss', zone); + if (!d.isValid()) { + setError('无效格式 (YYYY/MM/DD HH:mm:ss)'); + return; + } + setError(''); + const ms = d.valueOf(); + setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000))); + }, [dtInput, zone, unit]); -const TimestampPage = () => { return ( - - - - - + + + + {Math.floor(now / (unit === 'ms' ? 1 : 1000))} + + + {unit === 'ms' ? '毫秒' : '秒'} + + + + + - - ); -}; -export default TimestampPage; + + + + + + + {mode === 'ts2dt' ? ( + <> + { + setTsInput(e.target.value); + setError(''); + }} + error={!!error} + helperText={error} + fullWidth + size="small" + /> + + 单位 + + + + + ) : ( + <> + { + setDtInput(e.target.value); + setError(''); + }} + error={!!error} + helperText={error || '格式: YYYY/MM/DD HH:mm:ss'} + fullWidth + size="small" + /> + + 单位 + + + + + )} + + + 时区 + + + + copy(result)}> + + + ) : undefined, + }} + /> + + + setSnack({ ...snack, open: false })}> + + {snack.msg} + + + + ); +} diff --git a/package.json b/package.json index 7dca99f..4d30a96 100644 --- a/package.json +++ b/package.json @@ -14,29 +14,17 @@ "compile": "tsc --noEmit", "postinstall": "wxt prepare", "prepare": "husky", - "lint": "eslint . --max-warnings=0", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "lint": "eslint . --max-warnings=0" }, "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", - "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.1", - "@testing-library/user-event": "^13.5.0", "@webext-core/messaging": "^2.3.0", - "date-fns": "^4.1.0", "dayjs": "^1.11.19", "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-markdown": "^6.0.3", - "react-router-dom": "^6.30.2", - "remark-gfm": "^1.0.0", - "web-vitals": "^2.1.4" + "react-dom": "^19.2.3" }, "devDependencies": { "@types/chrome": "^0.1.36", @@ -52,13 +40,11 @@ "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.2.0", "husky": "^9.1.7", - "jsdom": "^29.0.0", "lint-staged": "^16.2.7", "prettier": "^3.8.1", "terser": "^5.46.0", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", - "vitest": "^4.1.0", "wxt": "^0.20.6" } } diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json deleted file mode 100644 index 9720407..0000000 --- a/tsconfig.vitest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["vitest/globals", "node"], - "jsx": "react-jsx", - "skipLibCheck": true, - "moduleResolution": "bundler", - "resolveJsonModule": true - }, - "include": [ - "components/**/*.test.tsx", - "components/**/*.tsx", - "components/**/*.ts", - "utils/**/*.ts", - "utils/**/*.tsx", - "vitest.setup.ts" - ] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 38615ae..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; -import { resolve } from 'path'; - -export default defineConfig({ - plugins: [react()], - test: { - globals: true, - environment: 'jsdom', - setupFiles: './vitest.setup.ts', - cache: { - dir: resolve(__dirname, '.vitest'), - }, - tsconfig: './tsconfig.vitest.json', - }, - resolve: { - alias: { - '@': resolve(__dirname, '.'), - }, - }, -});