refactor: 精简时间戳功能为简约单页面

- 将3个时间戳组件合并为一个简约页面
- 移除路由、导航栏等不必要组件
- 预填充当前时间/时间戳
- 始终显示结果框
- 清理测试文件和依赖
This commit is contained in:
雨霖铃
2026-03-19 00:18:09 +08:00
parent 9db9481ef4
commit 050621d3dc
19 changed files with 239 additions and 1053 deletions
+26 -100
View File
@@ -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: ['<all_urls>'] // 访问所有网站
@@ -203,7 +130,6 @@ host_permissions: ['<all_urls>'] // 访问所有网站
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
- **离屏文档**: `entrypoints/offscreen/main.tsx` - 处理长时间运行的任务(如录制)
### 浏览器兼容性
-134
View File
@@ -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<ButtonProps, 'onClick' | 'variant'> {
textToCopy: string | number;
buttonText?: ReactNode;
successMessage?: string;
errorMessage?: string;
variant?: ButtonProps['variant'];
}
const CopyButton: FC<CopyButtonProps> = ({
textToCopy,
buttonText = '复制',
successMessage = '复制成功!',
errorMessage = '复制失败,请手动复制。',
variant = 'contained',
...buttonProps
}) => {
const [status, setStatus] = useState<CopyStatus>('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 <CheckIcon sx={{ mr: 1 }} fontSize="small" />;
}
return <ContentCopyIcon sx={{ mr: 1 }} fontSize="small" />;
};
return (
<>
<Button
onClick={handleClick}
disabled={status !== 'idle'}
variant={variant}
{...buttonProps}
sx={{
...buttonProps.sx,
transition: 'background-color 0.3s',
...(status === 'success' && {
bgcolor: 'success.main',
'&:hover': {
bgcolor: 'success.dark',
},
}),
}}
>
{renderButtonIcon()}
{buttonText}
</Button>
<Snackbar
open={openSnackbar}
autoHideDuration={2000}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
{snackbarContent ? (
<Alert
onClose={handleCloseSnackbar}
severity={snackbarContent.severity}
variant="filled"
sx={{ width: '100%' }}
>
{snackbarContent.message}
</Alert>
) : undefined}
</Snackbar>
</>
);
};
export default CopyButton;
-133
View File
@@ -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 (
<Paper elevation={3} sx={{ p: 2, my: 2, borderRadius: 2 }}>
<Stack spacing={2} sx={{ mt: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="输入日期时间"
value={dateValue}
onChange={(e) => {
setDateValue(e.target.value);
if (error) setError('');
}}
error={!!error}
helperText={error || '格式: YYYY/MM/DD HH:mm:ss'}
fullWidth
variant="outlined"
/>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={selectedZone}
label="时区"
onChange={handleZoneChange}
MenuProps={{ disableScrollLock: true }}
>
{TIME_ZONE_LIST.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button variant="contained" size="medium" color="primary" onClick={handleConvert}>
</Button>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="转换结果"
value={result}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
},
}}
/>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={unit}
label="单位"
onChange={handleUnitChange}
MenuProps={{ disableScrollLock: true }}
>
{TIMESTAMP_UNITS.map(({ value, label }) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Stack>
</Paper>
);
}
-61
View File
@@ -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 (
<AppBar
position="static"
color="default"
elevation={0}
sx={{ backgroundColor: 'transparent', borderBottom: 1, borderColor: 'divider' }}
>
<Toolbar sx={{ justifyContent: 'center', position: 'relative' }}>
<Tabs
value={activeTabIndex === -1 ? 0 : activeTabIndex}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="navigation tabs"
sx={{ minHeight: 48 }}
>
{items.map((item) => (
<Tab
key={item.path}
label={item.label}
component={NavLink}
to={item.path}
sx={{
minWidth: 80,
fontSize: '0.875rem',
}}
/>
))}
</Tabs>
</Toolbar>
</AppBar>
);
}
export default Navbar;
-43
View File
@@ -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;
-106
View File
@@ -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 (
<Paper elevation={3} sx={{ p: 2, my: 2, borderRadius: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: 1,
mb: 2,
overflowX: 'auto',
pb: 1,
}}
>
<Typography
variant="h5"
component="span"
sx={{
fontFamily: 'monospace',
fontWeight: 'bold',
color: 'primary.main',
whiteSpace: 'nowrap',
}}
>
{displayTimestamp}
</Typography>
<Typography
variant="h6"
component="span"
sx={{ color: 'text.secondary', whiteSpace: 'nowrap' }}
>
{unitText}
</Typography>
</Box>
<Stack direction="row" spacing={2} justifyContent="center" flexWrap="wrap">
<Button
variant="contained"
color="primary"
onClick={toggleUnit}
aria-label={unitButtonLabel}
title={unitButtonLabel}
>
</Button>
<CopyButton
textToCopy={String(currentTimestamp)}
buttonText="复制"
aria-label="复制当前时间戳到剪贴板"
color="primary"
/>
<Button
variant="contained"
color={isRunningTimestamp ? 'error' : 'primary'}
onClick={toggleTimestamp}
aria-label={toggleButtonLabel}
title={toggleButtonLabel}
>
{toggleButtonText}
</Button>
</Stack>
</Paper>
);
}
-148
View File
@@ -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<HTMLInputElement>) => {
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 (
<Paper elevation={3} sx={{ p: 2, my: 2, borderRadius: 2 }}>
<Stack spacing={2} sx={{ mt: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="输入时间戳"
placeholder="如: 1704067200000"
value={timestampValue}
onChange={handleInputChange}
error={!!error}
helperText={error}
fullWidth
variant="outlined"
/>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={unit}
label="单位"
onChange={handleUnitChange}
MenuProps={{ disableScrollLock: true }}
>
{TIMESTAMP_UNITS.map(({ value, label }) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button variant="contained" size="medium" color="primary" onClick={handleConvert}>
</Button>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField
label="转换结果"
value={timestampResult}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
},
}}
/>
<FormControl fullWidth>
<InputLabel></InputLabel>
<Select
value={selectedZone}
label="时区"
onChange={handleZoneChange}
MenuProps={{ disableScrollLock: true }}
>
{TIME_ZONE_LIST.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Stack>
</Paper>
);
}
-52
View File
@@ -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(<CopyButton textToCopy="test" />);
expect(screen.getByText('复制')).toBeInTheDocument();
});
it('应该正确渲染自定义按钮文本', () => {
render(<CopyButton textToCopy="test" buttonText="Custom Copy" />);
expect(screen.getByText('Custom Copy')).toBeInTheDocument();
});
it('点击按钮时应该复制文本到剪贴板', async () => {
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
writable: true,
});
render(<CopyButton textToCopy="test content" />);
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(<CopyButton textToCopy="" />);
const button = screen.getByText('复制');
fireEvent.click(button);
expect(consoleWarnSpy).toHaveBeenCalledWith('没有提供要复制的文本');
consoleWarnSpy.mockRestore();
});
});
@@ -1,17 +0,0 @@
import { render, screen } from '@testing-library/react';
import { DatetimeToTimestamp } from '../DatetimeToTimestamp';
describe('DatetimeToTimestamp', () => {
it('应该正确渲染组件', () => {
render(<DatetimeToTimestamp />);
expect(screen.getByLabelText('输入日期时间')).toBeInTheDocument();
expect(screen.getByText('转换')).toBeInTheDocument();
expect(screen.getByLabelText('转换结果')).toBeInTheDocument();
});
it('应该初始化为当前日期时间', () => {
render(<DatetimeToTimestamp />);
const input = screen.getByLabelText('输入日期时间') as HTMLInputElement;
expect(input.value).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
-47
View File
@@ -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: <div></div> },
{ path: '/timestamp', label: '时间戳', element: <div></div> },
];
const renderWithRouter = (initialPath = '/', items = mockItems) => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Navbar items={items} />
</MemoryRouter>,
);
};
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(
<MemoryRouter>
<Navbar />
</MemoryRouter>,
);
expect(true).toBe(true);
});
});
@@ -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(
<MemoryRouter initialEntries={[initialPath]}>
<RoutePersistence />
</MemoryRouter>,
);
};
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');
});
});
@@ -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(<TimestampExecution />);
expect(screen.getByText('切换单位')).toBeInTheDocument();
expect(screen.getByText('复制')).toBeInTheDocument();
expect(screen.getByText('停止')).toBeInTheDocument();
});
it('初始状态应该显示毫秒单位', () => {
render(<TimestampExecution />);
expect(screen.getByText('毫秒')).toBeInTheDocument();
});
it('点击切换单位按钮应该切换为秒显示', () => {
render(<TimestampExecution />);
const toggleUnitButton = screen.getByText('切换单位');
fireEvent.click(toggleUnitButton);
expect(screen.getByText('秒')).toBeInTheDocument();
});
it('点击停止按钮应该停止时间戳自动更新', () => {
render(<TimestampExecution />);
const toggleButton = screen.getByText('停止');
fireEvent.click(toggleButton);
expect(screen.getByText('开始')).toBeInTheDocument();
});
});
@@ -1,17 +0,0 @@
import { render, screen } from '@testing-library/react';
import { TimestampToDatetime } from '../TimestampToDatetime';
describe('TimestampToDatetime', () => {
it('应该正确渲染组件', () => {
render(<TimestampToDatetime />);
expect(screen.getByLabelText('输入时间戳')).toBeInTheDocument();
expect(screen.getByText('转换')).toBeInTheDocument();
expect(screen.getByLabelText('转换结果')).toBeInTheDocument();
});
it('应该初始化为当前时间戳', () => {
render(<TimestampToDatetime />);
const input = screen.getByLabelText('输入时间戳') as HTMLInputElement;
expect(input.value).toMatch(/^\d{13}$/);
});
});
-28
View File
@@ -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'];
+3 -19
View File
@@ -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: <TimestampPage /> }];
function App() {
return (
<Router future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<RoutePersistence />
<div className="app">
<Navbar items={navItems} />
<Routes>
{navItems.map((item) => (
<Route key={item.path} path={item.path} element={item.element} />
))}
</Routes>
</div>
</Router>
<div className="app">
<TimestampPage />
</div>
);
}
+208 -14
View File
@@ -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 (
<Container maxWidth="md" sx={{ py: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, width: '100%' }}>
<TimestampExecution />
<TimestampToDatetime />
<DatetimeToTimestamp />
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h5" component="span" sx={{ fontFamily: 'monospace', fontWeight: 'bold', color: 'primary.main' }}>
{Math.floor(now / (unit === 'ms' ? 1 : 1000))}
</Typography>
<Typography variant="body1" component="span" sx={{ ml: 1, color: 'text.secondary' }}>
{unit === 'ms' ? '毫秒' : '秒'}
</Typography>
<Box sx={{ mt: 1 }}>
<Button size="small" onClick={() => setUnit(unit === 'ms' ? 's' : 'ms')} sx={{ mr: 1 }}>
</Button>
<Button size="small" onClick={() => copy(String(Math.floor(now / (unit === 'ms' ? 1 : 1000))))}>
</Button>
</Box>
</Box>
</Container>
);
};
export default TimestampPage;
<Stack direction="row" spacing={1} sx={{ mb: 2, justifyContent: 'center' }}>
<Button
variant={mode === 'ts2dt' ? 'contained' : 'outlined'}
size="small"
onClick={() => {
setMode('ts2dt');
setError('');
setResult('');
}}
>
</Button>
<Button
variant={mode === 'dt2ts' ? 'contained' : 'outlined'}
size="small"
onClick={() => {
setMode('dt2ts');
setError('');
setResult('');
}}
>
</Button>
</Stack>
<Stack spacing={2}>
{mode === 'ts2dt' ? (
<>
<TextField
label="时间戳"
value={tsInput}
onChange={(e) => {
setTsInput(e.target.value);
setError('');
}}
error={!!error}
helperText={error}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select value={unit} label="单位" onChange={(e) => setUnit(e.target.value as 'ms' | 's')}>
<MenuItem value="ms"></MenuItem>
<MenuItem value="s"></MenuItem>
</Select>
</FormControl>
<Button variant="contained" onClick={convertTs2Dt} fullWidth>
</Button>
</>
) : (
<>
<TextField
label="日期时间"
value={dtInput}
onChange={(e) => {
setDtInput(e.target.value);
setError('');
}}
error={!!error}
helperText={error || '格式: YYYY/MM/DD HH:mm:ss'}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select value={unit} label="单位" onChange={(e) => setUnit(e.target.value as 'ms' | 's')}>
<MenuItem value="ms"></MenuItem>
<MenuItem value="s"></MenuItem>
</Select>
</FormControl>
<Button variant="contained" onClick={convertDt2Ts} fullWidth>
</Button>
</>
)}
<FormControl fullWidth size="small">
<InputLabel></InputLabel>
<Select value={zone} label="时区" onChange={(e) => setZone(e.target.value as (typeof ZONES)[number])}>
{ZONES.map((z) => (
<MenuItem key={z} value={z}>
{z}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="结果"
value={result}
fullWidth
size="small"
InputProps={{
readOnly: true,
endAdornment: result ? (
<IconButton size="small" onClick={() => copy(result)}>
<ContentCopyIcon fontSize="small" />
</IconButton>
) : undefined,
}}
/>
</Stack>
<Snackbar open={snack.open} autoHideDuration={1500} onClose={() => setSnack({ ...snack, open: false })}>
<Alert severity="success" variant="filled" sx={{ width: '100%' }}>
{snack.msg}
</Alert>
</Snackbar>
</Paper>
);
}
+2 -16
View File
@@ -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"
}
}
-18
View File
@@ -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"
]
}
-21
View File
@@ -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, '.'),
},
},
});