refactor: 精简时间戳功能为简约单页面
- 将3个时间戳组件合并为一个简约页面 - 移除路由、导航栏等不必要组件 - 预填充当前时间/时间戳 - 始终显示结果框 - 清理测试文件和依赖
This commit is contained in:
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换、用户操作录制与回放等。
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
||||
|
||||
## 核心命令
|
||||
|
||||
@@ -52,14 +52,11 @@ 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 # 日期转时间戳组件
|
||||
@@ -70,32 +67,18 @@ npx vitest run components/__tests__/CopyButton.test.tsx
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台脚本(主进程)
|
||||
│ ├── content.ts # 内容脚本(注入到页面)
|
||||
│ ├── offscreen/ # 离屏文档(用于长时间运行任务)
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx # 弹窗主应用
|
||||
│ │ ├── main.tsx # 弹窗入口
|
||||
│ │ └── pages/ # 弹窗页面
|
||||
│ │ ├── RecordeReplayPage.tsx # 录制回放页面
|
||||
│ │ ├── TestPage.tsx # 测试页面
|
||||
│ │ └── TimestampPage.tsx # 时间戳工具页面
|
||||
│ └── options/ # 选项页面(未列出)
|
||||
├── assets/ # 静态资源
|
||||
│ └── popup/ # 扩展弹窗界面
|
||||
│ ├── App.tsx # 弹窗主应用
|
||||
│ ├── main.tsx # 弹窗入口
|
||||
│ └── pages/ # 弹窗页面
|
||||
│ ├── TestPage.tsx # 测试页面
|
||||
│ └── TimestampPage.tsx # 时间戳工具页面
|
||||
├── utils/ # 工具函数
|
||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
||||
│ ├── dayjs.ts # 日期处理工具
|
||||
│ ├── messages.tsx # 消息通信工具
|
||||
│ ├── recordEventsDb.ts # IndexedDB 数据库工具(录制事件存储)
|
||||
│ ├── recordUtils.tsx # 录制工具函数
|
||||
│ ├── tabUtils.ts # 标签页工具
|
||||
│ └── useRecorder.tsx # 录制器 Hook
|
||||
│ └── messages.tsx # 消息通信工具
|
||||
├── types/ # 类型定义
|
||||
│ └── storage.d.ts # 存储相关类型
|
||||
├── public/ # 公共资源
|
||||
├── package.json # 项目依赖和脚本
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── wxt.config.ts # WXT 配置
|
||||
└── web-ext.config.ts # WebExtensions 配置
|
||||
|
||||
```
|
||||
|
||||
### 核心功能实现
|
||||
@@ -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` - 处理长时间运行的任务(如录制)
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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'];
|
||||
@@ -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>
|
||||
<TimestampPage />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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, '.'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user