Merge pull request #2 from LingandRX/develop

Develop
This commit is contained in:
LingandRX
2026-02-27 16:14:59 +08:00
committed by GitHub
31 changed files with 2241 additions and 1846 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["WebSearch"]
}
}
-1
View File
@@ -12,7 +12,6 @@ node_modules
stats.html stats.html
stats-*.json stats-*.json
.wxt .wxt
web-ext.config.ts
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+95 -38
View File
@@ -1,70 +1,127 @@
# Getting Started with Create React App # Testing Tools Browser Extension
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 这是一个基于 WXT 框架的浏览器扩展项目,提供了多种实用的测试工具,包括时间戳转换、用户操作录制与回放等功能。
## Available Scripts ## 项目概述
In the project directory, you can run: Testing Tools 是一个功能丰富的浏览器扩展,旨在帮助开发者和测试人员更高效地进行网页测试工作。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
### `npm start` ## 功能特性
Runs the app in the development mode.\ ### 1. 时间戳转换工具
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\ - 日期与时间戳之间的双向转换
You may also see any lint errors in the console. - 支持多种日期格式
- 快速复制转换结果
### `npm test` ### 2. 录制与回放功能
Launches the test runner in the interactive watch mode.\ - 基于 rrweb 库的用户操作录制
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - 完整的会话回放功能
- 支持复杂交互场景的重现
### `npm run build` ### 3. 测试工具页面
Builds the app for production to the `build` folder.\ - 提供多种实用的测试功能
It correctly bundles React in production mode and optimizes the build for the best performance. - 集成测试库支持
The build is minified and the filenames include the hashes.\ ## 技术栈
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - **框架**: WXT (Web Extension Toolkit)
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI
- **状态管理**: React Hooks
- **数据库**: Dexie.js (IndexedDB 包装器)
- **录制回放**: rrweb
- **路由**: React Router DOM
### `npm run eject` ## 项目结构
**Note: this is a one-way operation. Once you `eject`, you can't go back!** ```
├── components/ # 可复用的 UI 组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面
│ ├── options/ # 选项页面
│ ├── offscreen/ # 离屏文档
│ ├── background.ts # 后台脚本
│ └── content.ts # 内容脚本
├── assets/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json # 项目依赖和脚本
└── README.md # 项目说明文档
```
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. ## 开发环境要求
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. - Node.js >= 18
- npm 或 yarn
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. ## 安装与运行
## Learn More ### 1. 安装依赖
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). ```bash
npm install
```
To learn React, check out the [React documentation](https://reactjs.org/). ### 2. 开发模式
### Code Splitting ```bash
# Chrome 浏览器
npm run dev
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) # Firefox 浏览器
npm run dev:firefox
```
### Analyzing the Bundle Size ### 3. 构建生产版本
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) ```bash
# Chrome 浏览器
npm run build
### Making a Progressive Web App # Firefox 浏览器
npm run build:firefox
```
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) ### 4. 打包分发
### Advanced Configuration ```bash
# Chrome 浏览器
npm run zip
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) # Firefox 浏览器
npm run zip:firefox
```
### Deployment ## 权限说明
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 扩展请求以下权限:
### `npm run build` fails to minify - `storage``unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入
- `activeTab`, `scripting`, `tabs` - 当前标签页控制
- `offscreen` - 离屏文档处理
- `downloads` - 下载管理
- `<all_urls>` - 访问所有网站内容
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) ## 主要依赖
- `react`, `react-dom` - 前端框架
- `@mui/material` - UI 组件库
- `rrweb`, `rrweb-player` - 录制回放功能
- `dexie`, `dexie-react-hooks` - 数据库操作
- `react-router-dom` - 路由管理
- `@testing-library/*` - 测试工具
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
此项目为私有项目 (private: true),仅供内部使用。
+134
View File
@@ -0,0 +1,134 @@
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;
+157
View File
@@ -0,0 +1,157 @@
import { useState, useCallback } from 'react';
import dayjs from '@/utils/dayjs';
import {
Button,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Paper,
Stack,
Box,
SelectChangeEvent,
} from '@mui/material';
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',
];
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒 (ms)' },
{ value: 'seconds', label: '秒 (s)' },
];
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 '';
}
const timestamp = dayjs.tz(currentDate, 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>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { NavLink, useLocation } from 'react-router-dom';
import { useState, ReactNode, MouseEvent } from 'react';
import {
AppBar,
Box,
IconButton,
Menu,
MenuItem,
Tab,
Tabs,
Toolbar,
useMediaQuery,
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
interface RouteItem {
path: string;
label: string;
element: ReactNode;
}
interface NavbarProps {
items?: RouteItem[];
}
function Navbar({ items = [] }: NavbarProps) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const isMenuOpen = Boolean(anchorEl);
const location = useLocation();
// Replicating the screen size logic from original component
const isLargeScreen = useMediaQuery('(min-width:768px)');
const isMediumScreen = useMediaQuery('(min-width:480px)');
let visibleItemsCount: number;
if (isLargeScreen) {
visibleItemsCount = items.length;
} else if (isMediumScreen) {
visibleItemsCount = Math.min(3, items.length);
} else {
// Small screen
visibleItemsCount = Math.min(2, items.length);
}
const visibleNavItems = items.slice(0, visibleItemsCount);
const collapsedNavItems = items.slice(visibleItemsCount);
const handleMenuOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
// Find the current active tab index for the Tabs value
// Using startsWith to handle nested routes correctly.
const activeTabIndex = visibleNavItems.findIndex((item) =>
location.pathname.startsWith(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 ? false : activeTabIndex}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
aria-label="navigation tabs"
>
{visibleNavItems.map((item) => (
<Tab key={item.path} label={item.label} component={NavLink} to={item.path} />
))}
</Tabs>
{collapsedNavItems.length > 0 && (
<Box sx={{ position: 'absolute', right: 8 }}>
<IconButton color="inherit" aria-label="open menu" edge="end" onClick={handleMenuOpen}>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
slotProps={{
paper: {
style: {
maxHeight: 48 * 4.5,
width: '20ch',
},
},
}}
>
{collapsedNavItems.map((item) => (
<MenuItem
key={item.path}
component={NavLink}
to={item.path}
onClick={handleMenuClose}
selected={location.pathname.startsWith(item.path)}
>
{item.label}
</MenuItem>
))}
</Menu>
</Box>
)}
</Toolbar>
</AppBar>
);
}
export default Navbar;
@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { storage } from '@/utils/storage'; import { storage } from '@/utils/chromeStorage';
const RoutePersistence = () => { const RoutePersistence = () => {
const location = useLocation(); const location = useLocation();
@@ -26,7 +26,7 @@ const RoutePersistence = () => {
} }
}; };
restoreRoute(); restoreRoute().then(() => console.info('恢复路由成功'));
}, [location, navigate]); }, [location, navigate]);
useEffect(() => { useEffect(() => {
@@ -36,7 +36,7 @@ const RoutePersistence = () => {
console.log('保存路由', location.pathname); console.log('保存路由', location.pathname);
}; };
saveRoute(); saveRoute().then(() => console.info('保存路由成功'));
}, [location]); }, [location]);
return null; return null;
@@ -1,121 +1,106 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, JSX } from 'react';
import CopyButton from './CopyButton'; import CopyButton from './CopyButton';
import { Button, Paper, Typography, Stack, Box } from '@mui/material';
/** /**
* *
*
* @component
* @example
* ```jsx
* <TimestampExecution />
* ```
*
* @returns {JSX.Element}
*/ */
export function TimestampExecution() { export function TimestampExecution(): JSX.Element {
/** @type {[number, function]} 当前时间戳(毫秒)和更新函数 */
const [currentTimestamp, setCurrentTimestamp] = useState(() => Math.floor(Date.now())); const [currentTimestamp, setCurrentTimestamp] = useState(() => Math.floor(Date.now()));
/** @type {[boolean, function]} 是否显示毫秒(true=毫秒,false=秒) */
const [showMilliseconds, setShowMilliseconds] = useState(true); const [showMilliseconds, setShowMilliseconds] = useState(true);
/** @type {[boolean, function]} 时间戳是否正在自动更新 */
const [isRunningTimestamp, setIsRunningTimestamp] = useState(true); const [isRunningTimestamp, setIsRunningTimestamp] = useState(true);
/**
*
* @type {number}
*/
const displayTimestamp = showMilliseconds const displayTimestamp = showMilliseconds
? currentTimestamp ? currentTimestamp
: Math.floor(currentTimestamp / 1000); : Math.floor(currentTimestamp / 1000);
const unitText = showMilliseconds ? '毫秒' : '秒'; const unitText = showMilliseconds ? '毫秒' : '秒';
/**
*
* isRunningTimestamp showMilliseconds
*/
useEffect(() => { useEffect(() => {
let timer: number; let timer: number;
if (isRunningTimestamp) { if (isRunningTimestamp) {
const interval = showMilliseconds ? 100 : 1000; const interval = showMilliseconds ? 100 : 1000;
timer = window.setInterval(() => { timer = window.setInterval(() => {
setCurrentTimestamp(Math.floor(Date.now())); setCurrentTimestamp(Math.floor(Date.now()));
}, interval); }, interval);
} }
// 清理函数
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isRunningTimestamp, showMilliseconds]); }, [isRunningTimestamp, showMilliseconds]);
/**
* /
*/
const toggleUnit = useCallback(() => { const toggleUnit = useCallback(() => {
setShowMilliseconds((prev) => !prev); setShowMilliseconds((prev) => !prev);
}, []); }, []);
/**
* /
*/
const toggleTimestamp = useCallback(() => { const toggleTimestamp = useCallback(() => {
setIsRunningTimestamp((prev) => !prev); setIsRunningTimestamp((prev) => !prev);
}, []); }, []);
/**
*
* @type {string}
*/
const unitButtonLabel = showMilliseconds ? '切换为秒显示' : '切换为毫秒显示'; const unitButtonLabel = showMilliseconds ? '切换为秒显示' : '切换为毫秒显示';
/**
* /
* @type {string}
*/
const toggleButtonLabel = isRunningTimestamp ? '停止时间戳自动更新' : '开始时间戳自动更新'; const toggleButtonLabel = isRunningTimestamp ? '停止时间戳自动更新' : '开始时间戳自动更新';
/**
* /
* @type {string}
*/
const toggleButtonText = isRunningTimestamp ? '停止' : '开始'; const toggleButtonText = isRunningTimestamp ? '停止' : '开始';
return ( return (
<div className="timestamp-container"> <Paper elevation={3} sx={{ p: 2, my: 2, borderRadius: 2 }}>
<div className="timestamp-display"> <Box
<span className="timestamp-value">{displayTimestamp}</span> sx={{
<span className="timestamp-unit">{unitText}</span> display: 'flex',
</div> 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>
<div className="timestamp-controls"> <Stack direction="row" spacing={2} justifyContent="center" flexWrap="wrap">
<button <Button
type="button" variant="contained"
className="action-btn" color="primary"
onClick={toggleUnit} onClick={toggleUnit}
aria-label={unitButtonLabel} aria-label={unitButtonLabel}
title={unitButtonLabel} title={unitButtonLabel}
> >
</button> </Button>
<CopyButton <CopyButton
text={String(currentTimestamp)} textToCopy={String(currentTimestamp)}
buttonText="复制时间戳" buttonText="复制"
aria-label="复制当前时间戳到剪贴板" aria-label="复制当前时间戳到剪贴板"
color="primary"
/> />
<button <Button
type="button" variant="contained"
className={`${isRunningTimestamp ? 'stop-btn' : 'action-btn'}`} color={isRunningTimestamp ? 'error' : 'primary'}
onClick={toggleTimestamp} onClick={toggleTimestamp}
aria-label={toggleButtonLabel} aria-label={toggleButtonLabel}
title={toggleButtonLabel} title={toggleButtonLabel}
> >
{toggleButtonText} {toggleButtonText}
</button> </Button>
</div> </Stack>
</div> </Paper>
); );
} }
+178
View File
@@ -0,0 +1,178 @@
import React, { useState, useCallback } from 'react';
import dayjs from 'dayjs';
import {
Button,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Paper,
Stack,
Box,
SelectChangeEvent,
} from '@mui/material';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
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',
];
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒 (ms)' },
{ value: 'seconds', label: '秒 (s)' },
];
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>
);
}
+201 -69
View File
@@ -1,17 +1,59 @@
import '../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { downloadHtmlInBackground } from '@/utils/recordUtils.tsx'; import { downloadHtmlInBackground } from '@/utils/recordUtils.tsx';
import { sendMessage, onMessage } from '@/utils/messages';
import { getActiveTabId } from '@/utils/tabUtils';
import {
initRecordingSession,
appendEvents,
streamAllEvents,
deleteRecordingSession,
generateSessionId,
} from '@/utils/recordEventsDb';
const events: unknown[] = []; interface RecorderState {
isRecording: boolean;
recordingTabId: number | undefined;
sessionId: string | undefined;
startTime: number | undefined;
}
const STORAGE_KEY = 'recorder_state';
// 从 storage 恢复录制状态
async function loadRecorderState(): Promise<RecorderState> {
try {
const result = await browser.storage.local.get(STORAGE_KEY);
return (result[STORAGE_KEY] as RecorderState) || {
isRecording: false,
recordingTabId: undefined,
sessionId: undefined,
startTime: undefined,
};
} catch (err) {
console.error('[background] Failed to load recorder state:', err);
return { isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined };
}
}
// 保存录制状态到 storage
async function saveRecorderState(state: Partial<RecorderState>) {
try {
const currentState = await loadRecorderState();
await browser.storage.local.set({
[STORAGE_KEY]: { ...currentState, ...state },
});
} catch (err) {
console.error('[background] Failed to save recorder state:', err);
}
}
export default defineBackground(() => { export default defineBackground(() => {
// 监听扩展安装或更新事件 // 监听扩展安装或更新事件
browser.runtime.onInstalled.addListener(async ({ reason }) => { browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') { if (reason === 'install') {
// 第一次安装扩展时触发
console.log('Extension installed for the first time'); console.log('Extension installed for the first time');
} else if (reason === 'update') { } else if (reason === 'update') {
// 扩展更新时触发
console.log('Extension updated to a new version'); console.log('Extension updated to a new version');
} }
@@ -21,7 +63,6 @@ export default defineBackground(() => {
// 过滤不合法或受限制的 URL // 过滤不合法或受限制的 URL
const targetTabs = tabs.filter((tab) => { const targetTabs = tabs.filter((tab) => {
if (!tab.id || !tab.url) return false; if (!tab.id || !tab.url) return false;
// 过滤掉浏览器内部页面和不支持注入的协议
const restrictedProtocols = [ const restrictedProtocols = [
'chrome:', 'chrome:',
'chrome-extension:', 'chrome-extension:',
@@ -49,78 +90,169 @@ export default defineBackground(() => {
console.log( console.log(
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`, `Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
); );
// 扩展安装/更新时重置录制状态
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
}); });
// 监听来自 popup script 的消息 // 监听 Tab 切换 - 如果正在录制且切换到其他 Tab,发出警告
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { chrome.tabs.onActivated.addListener(async (activeInfo) => {
if (msg.type === messages.popup.checkStatus) { const state = await loadRecorderState();
console.log('[bg] checkStatus received'); if (state.isRecording && state.recordingTabId !== activeInfo.tabId) {
sendToActiveTab({ type: messages.content.checkStatus }) console.warn('[background] 录制中切换到其他 Tab,当前 Tab:', activeInfo.tabId, '录制 Tab:', state.recordingTabId);
.then((res) => { // 可选:发送通知给 popup 更新 UI 提示用户
sendResponse(res); await sendMessage('popup:tab-changed', { currentTabId: activeInfo.tabId, recordingTabId: state.recordingTabId });
})
.catch(() => {
console.error('Failed to check status in content script');
sendResponse({ ok: false });
});
return true;
}
if (msg.type === messages.popup.from.start) {
console.log('[bg] startRecording received');
sendToActiveTab({ type: messages.content.to.startRecording })
.then(async () => {
await chrome.runtime.sendMessage({ type: messages.popup.to.started });
sendResponse({ ok: true });
})
.catch(() => {
console.error('Failed to start recording in content script');
sendResponse({ ok: false });
});
}
if (msg.type === messages.popup.from.stop) {
console.log('[bg] stopRecording received');
sendToActiveTab({ type: messages.content.to.stopRecording })
.then(async () => {
await chrome.runtime.sendMessage({ type: messages.popup.to.stopped });
downloadHtmlInBackground(events);
sendResponse({ ok: true });
})
.catch(() => {
sendResponse({ ok: false });
});
}
return true;
});
// 监听来自 content script 的消息
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === messages.content.from.saveTrackeEvents) {
console.log('[bg] saveTrackeEvents received:', msg.payload);
events.push(msg.payload);
sendResponse({ ok: true });
} }
}); });
async function sendToActiveTab(message: { chrome.tabs.onUpdated.addListener((tabId) => {
type: string; console.log('加载完成的 Tab ID:', tabId);
data?: unknown;
activeTabId?: number;
[key: string]: unknown;
}) {
const activeTabs = await chrome.tabs.query({
active: true,
currentWindow: true,
}); });
const activeTab = activeTabs[0];
const sendTo = message.activeTabId || activeTab.id; // 监听 Tab 关闭 - 如果关闭的是录制中的 Tab,自动停止录制
chrome.tabs.onRemoved.addListener(async (tabId) => {
const state = await loadRecorderState();
if (state.isRecording && state.recordingTabId === tabId) {
console.warn('[background] 录制中的 Tab 已关闭,自动停止录制');
// 这里可以调用停止逻辑或通知 popup
await sendMessage('popup:stopped', undefined);
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
}
});
onMessage('popup:check-status', async () => {
console.log('[background] popup:check-status');
const state = await loadRecorderState();
// 如果正在录制,额外检查 content script 的实际状态
if (state.isRecording && state.recordingTabId) {
try { try {
return await chrome.tabs.sendMessage(sendTo!, message); const result = await sendMessage('content:check-status', undefined, state.recordingTabId);
} catch (error) { console.log('[background] content check-status result:', result);
console.error('Error sending message to active tab:', error); // 如果 content script 返回 false,说明状态不同步,需要重置
throw error; if (!result) {
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
return { active: false, startTime: -1 };
} }
} catch (err) {
console.error('[background-err] check-status failed:', err);
// Content script 可能已被移除(如页面刷新),重置状态
await saveRecorderState({ isRecording: false, recordingTabId: undefined, sessionId: undefined, startTime: undefined });
return { active: false, startTime: -1 };
}
}
return { active: state.isRecording, startTime: state.startTime ?? -1 };
});
onMessage('popup:start', async () => {
console.log('[bg] startRecording received');
try {
// 检查是否已经在录制
const currentState = await loadRecorderState();
if (currentState.isRecording) {
console.warn('[bg] 已经在录制中,无法开始新的录制');
return { ok: false, error: 'Already recording' };
}
const tabId = await getActiveTabId();
if (!tabId) {
return { ok: false, error: 'No active tab' };
}
const response = await sendMessage('content:start-recording', undefined, tabId);
if (response.ok) {
// 生成新的会话 ID 并初始化 IndexedDB 会话
const sessionId = generateSessionId();
console.log('[bg] 开始录制,sessionId:', sessionId, 'tabId:', tabId);
await initRecordingSession(sessionId, tabId);
// 保存录制状态到 storage
await saveRecorderState({
isRecording: true,
recordingTabId: tabId,
sessionId,
startTime: Date.now(),
});
console.log('[bg] 录制状态已保存');
await sendMessage('popup:started', undefined);
return { ok: true };
}
return { ok: false, error: response.error };
} catch (error) {
console.error('Failed to start recording in content script', error);
return { ok: false, error: String(error) };
} }
}); });
onMessage('popup:stop', async () => {
console.log('[bg] stopRecording received');
try {
const state = await loadRecorderState();
if (!state.isRecording) {
console.warn('[bg] 当前不在录制状态');
return { ok: false, error: 'Not recording' };
}
const currentTabId = await getActiveTabId();
// Tab 一致性检查 - 允许一定程度的灵活性
if (state.recordingTabId !== currentTabId) {
console.warn(
`[bg] Tab 不一致:录制 Tab=${state.recordingTabId}, 当前 Tab=${currentTabId}`,
);
// 仍然尝试在录制 Tab 上停止(如果该 Tab 还存在)
// 如果需要在当前 Tab 停止,可以移除这个条件判断
}
const targetTabId = state.recordingTabId ?? currentTabId;
const response = await sendMessage('content:stop-recording', undefined, targetTabId);
if (response.ok) {
// 从 IndexedDB 流式读取所有事件并下载回放文件
if (state.sessionId) {const allEvents: unknown[] = [];
await streamAllEvents(state.sessionId, (events) => {
allEvents.push(...events);
});
downloadHtmlInBackground(allEvents);
// 删除会话数据
await deleteRecordingSession(state.sessionId);
}
// 清空状态
await saveRecorderState({
isRecording: false,
recordingTabId: undefined,
sessionId: undefined,
startTime: undefined,
});
await sendMessage('popup:stopped', undefined);
return { ok: true };
}
return { ok: false, error: 'Failed to stop recording in content script' };
} catch (error: unknown) {
console.error('Failed to stop recording in content script', error);
return { ok: false, error: String(error) };
}
});
onMessage('content:save-track-events', async (event) => {
const state = await loadRecorderState();
console.log('[bg] 收到事件,当前状态:', state);
if (!state.isRecording) {
console.warn('[bg] 收到事件但当前不在录制状态,丢弃事件');
return false;
}
if (!state.sessionId) {
console.error('[bg] 没有 sessionId,无法存储事件');
return false;
}
// 使用 IndexedDB 分块存储事件
await appendEvents(state.sessionId, [event]);
return true;
});
});
+15 -36
View File
@@ -1,11 +1,6 @@
import '../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { createRecorder } from '@/utils/useRecorder'; import { createRecorder } from '@/utils/useRecorder';
import { onMessage } from '@/utils/messages.tsx';
interface IResponse {
ok: boolean;
error?: string;
data?: unknown;
}
export default defineContentScript({ export default defineContentScript({
// matches: ['*://*.google.com/*'], // matches: ['*://*.google.com/*'],
@@ -15,52 +10,36 @@ export default defineContentScript({
const recorder = createRecorder(); const recorder = createRecorder();
let isRecording = false; let isRecording = false;
// 监听来自 background script 的消息 onMessage('content:check-status', () => {
chrome.runtime.onMessage.addListener( console.log(`[content]${isRecording}`);
(msg: { type: string }, _sender, sendResponse: (response: IResponse) => void) => { return isRecording;
const handleAsyncMessage = async () => { });
onMessage('content:start-recording', async () => {
try { try {
switch (msg.type) { const started = await recorder.startRecord();
// 在这里处理异步消息类型
case messages.content.checkStatus:
return { ok: true, data: { isRecording } };
case messages.content.to.startRecording: {
const started = await recorder.startRecord(messages.content.from.saveTrackeEvents);
if (started) { if (started) {
isRecording = true; isRecording = true;
return { ok: true }; return { ok: true };
} else { } else {
// 如果启动失败,返回错误信息
return { ok: false, error: 'Failed to start recorder' }; return { ok: false, error: 'Failed to start recorder' };
} }
} catch (error) {
console.error('Error handling start recording:', error);
return { ok: false };
} }
});
case messages.content.to.stopRecording: onMessage('content:stop-recording', () => {
// 停止录制的处理逻辑 try {
console.log('[content] stopRecording received'); console.log('[content] stopRecording received');
recorder.stopRecord(); recorder.stopRecord();
isRecording = false; isRecording = false;
return { ok: true }; return { ok: true };
default:
return { ok: false, error: 'Unknown message type' };
}
} catch (error) { } catch (error) {
console.error('Error handling message:', error); console.error('Error handling stop recording:', error);
return { ok: false }; return { ok: false };
} }
};
handleAsyncMessage().then((response) => {
if (sendResponse) {
sendResponse(response);
}
}); });
return true;
},
);
}, },
}); });
+1 -947
View File
@@ -32,920 +32,12 @@ body {
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
width: 100%; width: 380px;
min-width: 320px; min-width: 320px;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.nav {
height: 56px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
padding: 0 20px;
margin-bottom: 10px;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.nav-content {
width: 100%;
display: flex;
align-items: center;
}
.nav-list {
list-style: none;
display: flex;
gap: 16px;
padding: 0;
margin: 0;
align-items: center;
flex-wrap: wrap;
transition: all 0.3s ease;
}
.nav-link {
text-decoration: none;
color: #495057;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
white-space: nowrap;
position: relative;
border: 1px solid transparent;
}
.nav-link:hover {
background-color: #f8f9fa;
color: #212529;
border-color: #dee2e6;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.nav-link.active {
color: #4361ee;
font-weight: 600;
background: linear-gradient(135deg, rgba(67, 97, 238, 0.1) 0%, rgba(67, 97, 238, 0.05) 100%);
border: 1px solid rgba(67, 97, 238, 0.2);
box-shadow: 0 2px 8px rgba(67, 97, 238, 0.15);
}
.nav-link.active::before {
content: '';
position: absolute;
bottom: -2px;
left: 16px;
right: 16px;
height: 3px;
background: linear-gradient(90deg, #4361ee, #3a56d4);
border-radius: 3px;
}
/* 折叠菜单相关样式 */
.nav-collapse-item {
position: relative;
display: flex;
align-items: center;
}
.nav-collapse-content {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid transparent;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
min-width: 140px;
margin-top: 12px;
flex-direction: column;
gap: 0;
opacity: 0;
visibility: hidden;
transform: translateY(-12px) scale(0.95);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: none;
z-index: 1000;
overflow: hidden;
}
.nav-collapse-item:hover .nav-collapse-content,
.nav-collapse-content.show {
display: flex;
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
border-color: #e9ecef;
}
.nav-collapse-content .nav-link {
padding: 12px 20px;
border-bottom: 1px solid #f8f9fa;
width: 100%;
text-align: left;
box-sizing: border-box;
border-radius: 0;
border: none;
font-size: 14px;
font-weight: 500;
color: #495057;
transition: all 0.2s ease;
}
.nav-collapse-content .nav-link:last-child {
border-bottom: none;
}
.nav-collapse-content .nav-link:hover {
background: linear-gradient(90deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.04));
color: #4361ee;
padding-left: 24px;
}
.nav-collapse-content .nav-link.active {
background: linear-gradient(90deg, rgba(67, 97, 238, 0.15), rgba(67, 97, 238, 0.08));
color: #4361ee;
font-weight: 600;
border: none;
box-shadow: none;
}
.nav-collapse-content .nav-link.active::before {
display: none;
}
.nav-toggle {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1px solid #dee2e6;
border-radius: 10px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #495057;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.nav-toggle::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(67, 97, 238, 0.1), rgba(67, 97, 238, 0.05));
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
.nav-toggle:hover {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: #4361ee;
color: #4361ee;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15);
}
.nav-toggle:hover::before {
opacity: 1;
}
.nav-toggle-icon {
font-size: 18px;
line-height: 1;
position: relative;
z-index: 2;
transition: transform 0.3s ease;
}
.nav-toggle:hover .nav-toggle-icon {
transform: scale(1.1);
}
.nav-collapse-count {
font-size: 12px;
font-weight: 600;
color: #4361ee;
background: rgba(67, 97, 238, 0.1);
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
position: relative;
z-index: 2;
}
/* 响应式设计 */
@media (max-width: 767px) {
.nav-list {
gap: 12px;
flex-wrap: nowrap;
}
/* 当菜单展开时,允许换行并显示所有可见项 */
.nav-list.open {
flex-wrap: wrap;
overflow: visible;
}
.nav-link {
padding: 6px 10px;
font-size: 13px;
}
.nav-collapse-content {
position: fixed;
top: 48px;
left: 0;
right: 0;
margin-top: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
opacity: 1; /* 覆盖默认的opacity: 0 */
visibility: visible; /* 覆盖默认的visibility: hidden */
transform: none; /* 覆盖默认的transform */
display: block; /* 覆盖默认的display: none,使用block而不是flex */
/* 移除边框,避免横线显示 */
border-style: none;
}
.nav-collapse-content.show {
max-height: 300px;
}
.nav-collapse-content .nav-link {
padding: 12px 16px;
font-size: 14px;
}
.nav-toggle {
padding: 8px 16px;
margin-left: auto;
}
}
@media (max-width: 479px) {
.nav {
padding: 0 12px;
z-index: 1000; /* 确保导航栏在展开菜单上方 */
height: 52px;
}
.nav-list {
width: 100%;
align-items: center;
}
/* 当菜单展开时,允许换行并显示所有可见项 */
.nav-list.open {
flex-wrap: wrap;
overflow: visible;
}
/* 可见的导航项应该始终显示 */
.nav-list > li:not(.nav-collapse-item) {
display: flex;
align-items: center;
}
.nav-list > li:not(.nav-collapse-item) .nav-link {
display: inline-block;
padding: 8px 12px;
font-size: 13px;
white-space: nowrap;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
}
/* 折叠菜单内容 - 只在打开时显示 */
.nav-collapse-content {
position: fixed;
top: 52px;
left: 0;
right: 0;
background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 999;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
border: 0;
border-radius: 0 0 12px 12px;
}
.nav-collapse-content.show {
max-height: calc(100% - 52px);
overflow-y: auto;
}
.nav-collapse-content .nav-link {
display: block;
width: 100%;
text-align: center;
border-bottom: 1px solid #f8f9fa;
padding: 14px 16px;
font-size: 14px;
font-weight: 500;
color: #495057;
transition: all 0.2s ease;
}
.nav-collapse-content .nav-link:hover {
background: linear-gradient(90deg, rgba(67, 97, 238, 0.08), rgba(67, 97, 238, 0.04));
color: #4361ee;
}
.nav-collapse-content .nav-link.active {
background: linear-gradient(90deg, rgba(67, 97, 238, 0.15), rgba(67, 97, 238, 0.08));
color: #4361ee;
font-weight: 600;
}
.nav-collapse-content .nav-link:last-child {
border-bottom: none;
}
.nav-toggle {
padding: 8px 12px;
font-size: 13px;
min-width: 44px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.nav-toggle:hover {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: #4361ee;
color: #4361ee;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15);
}
.nav-collapse-count {
display: none;
}
}
/* 隐藏滚动条样式 */
/* WebKit浏览器 (Chrome, Safari, Edge) */
body::-webkit-scrollbar,
.app::-webkit-scrollbar,
.nav-collapse-content.show::-webkit-scrollbar,
.timestamp-display::-webkit-scrollbar {
display: none;
}
/* Firefox */
body,
.app,
.nav-collapse-content.show,
.timestamp-display {
scrollbar-width: none;
}
/* IE/Edge 10+ */
body,
.app,
.nav-collapse-content.show,
.timestamp-display {
-ms-overflow-style: none;
}
button {
background: #4caf50;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
transition:
transform 0.08s ease,
box-shadow 0.2s ease,
background-color 0.25s ease;
}
button:hover {
background: #3d8b40;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
button:active {
background: #4caf50;
transform: scale(0.96) translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
/* 超小屏幕适配 - 修复边框遮挡问题 */
@media (max-width: 399px) {
.nav {
position: relative;
border-bottom: none; /* 移除导航栏底部边框 */
height: 48px;
padding: 0 8px;
}
.nav::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, #e9ecef, transparent);
}
/* 当菜单展开时,允许换行并显示所有可见项 */
.nav-list.open {
flex-wrap: wrap;
overflow: visible;
}
/* 调整可见导航项的宽度,适应更小的屏幕 */
.nav-list > li:not(.nav-collapse-item) .nav-link {
max-width: 60px;
padding: 6px 8px;
font-size: 12px;
border: 1px solid transparent;
}
.nav-list > li:not(.nav-collapse-item) .nav-link.active {
background: linear-gradient(135deg, rgba(67, 97, 238, 0.1) 0%, rgba(67, 97, 238, 0.05) 100%);
border: 1px solid rgba(67, 97, 238, 0.2);
box-shadow: 0 1px 4px rgba(67, 97, 238, 0.15);
}
.nav-collapse-content {
top: 48px;
}
.nav-collapse-content.show {
max-height: calc(100% - 48px);
}
.nav-toggle {
padding: 6px 10px;
font-size: 12px;
min-width: 36px;
height: 28px;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.nav-toggle:hover {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: #4361ee;
color: #4361ee;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(67, 97, 238, 0.15);
}
.nav-toggle-icon {
font-size: 12px;
}
}
/* 中等屏幕适配 */
@media (min-width: 480px) and (max-width: 767px) {
.nav-list {
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-link {
flex: 0 0 auto;
}
.nav-collapse-item {
margin-left: auto;
}
/* 确保.nav-list.open不会影响中等屏幕的布局 */
.nav-list.open {
flex-wrap: wrap;
position: static;
background: none;
border: none;
box-shadow: none;
max-height: none;
overflow: visible;
}
}
.page {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.user-list {
list-style: none;
padding: 0;
}
.user-list li {
padding: 8px;
border-bottom: 1px solid #eee;
}
.btn {
display: inline-block;
margin-top: 20px;
padding: 10px 20px;
background: #2196f3;
color: white;
text-decoration: none;
border-radius: 8px;
}
.btn:hover {
background: #0b7dda;
}
.actions {
margin: 20px 0;
}
.action-btn {
margin-right: 10px;
padding: 8px 16px;
background: #4caf50;
color: white;
border-radius: 8px;
cursor: pointer;
border: 1px solid #d9d9d9;
box-shadow: 0 2px #00000004;
}
.stop-btn {
margin-right: 10px;
padding: 8px 16px;
background: #f32152;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.stop-btn:hover {
background: #ff0000;
}
input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 20px;
box-sizing: border-box;
font-size: 14px;
}
select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
margin-bottom: 20px;
box-sizing: border-box;
font-size: 14px;
}
/* 时间戳组件样式 */
.timestamp-container {
padding: 20px;
border: var(--border);
border-radius: var(--radius);
background: var(--bg-color);
margin: 20px 0;
}
.timestamp-container:hover {
box-shadow: 4px 4px 12px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
/* 时间戳显示区域 */
.timestamp-display {
display: flex;
align-items: center; /* 垂直居中 */
justify-content: center; /* 横向居中 */
gap: 10px;
border-radius: var(--radius);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
.timestamp-value {
font-size: 2.5rem;
font-weight: 600;
color: var(--text-color);
font-family: 'Courier New', monospace;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
white-space: nowrap;
flex-shrink: 0;
}
.timestamp-unit {
font-size: 1rem;
color: var(--text-color);
font-weight: 400;
}
.timestamp-controls {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
/* 确保 CopyButton 也使用相同的样式 */
.timestamp-controls .action-btn,
.timestamp-controls .stop-btn {
min-width: 120px;
height: 44px;
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
font-size: 0.95rem;
}
.timestamp-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.8);
border-radius: 8px;
font-size: 0.9rem;
border: 1px solid #e0e0e0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.status-indicator.running {
color: #27ae60;
}
.status-indicator.stopped {
color: #e74c3c;
}
.unit-indicator {
color: #7f8c8d;
font-weight: 500;
}
/* 响应式设计 */
@media (max-width: 480px) {
.timestamp-container {
padding: 15px;
}
.timestamp-display {
padding: 12px;
align-items: center; /* 垂直居中 */
justify-content: center; /* 横向居中 */
}
.timestamp-value {
font-size: 1.8rem;
line-height: 1.1;
}
.timestamp-unit {
font-size: 1rem;
flex-shrink: 0;
}
.timestamp-controls {
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
gap: 8px;
}
.timestamp-controls .action-btn,
.timestamp-controls .stop-btn {
width: auto;
flex: 1;
min-width: auto;
max-width: none;
height: 44px;
padding: 8px 12px;
font-size: 0.85rem;
}
.timestamp-status {
flex-direction: column;
gap: 8px;
text-align: center;
}
}
/* 400px以下屏幕适配 - 防止按钮文本换行 */
@media (max-width: 400px) {
.timestamp-controls {
gap: 4px;
}
.timestamp-controls .action-btn,
.timestamp-controls .stop-btn {
padding: 6px 4px;
font-size: 0.75rem;
height: 38px;
min-width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* 超小屏幕适配 */
@media (max-width: 360px) {
.timestamp-value {
font-size: 1.5rem;
}
.timestamp-display {
padding: 10px;
align-items: center; /* 垂直居中 */
justify-content: center; /* 横向居中 */
}
.timestamp-controls {
gap: 3px;
}
.timestamp-controls .action-btn,
.timestamp-controls .stop-btn {
padding: 5px 3px;
font-size: 0.7rem;
height: 36px;
}
}
/* 日期时间转换器样式 */
.datetime-converter {
padding: 20px;
border: var(--border);
border-radius: 8px;
background: var(--bg-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin: 20px 0;
}
.datetime-converter:hover {
box-shadow: 4px 4px 12px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.converter-title {
color: #2c3e50;
font-size: 1.5rem;
margin-bottom: 20px;
text-align: center;
font-weight: 600;
}
.converter-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-group,
.result-group {
display: flex;
gap: 12px;
align-items: center;
}
.datetime-input,
.result-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 400;
background: white;
transition: border-color 0.2s ease;
width: auto !important;
margin-bottom: 0 !important;
}
.datetime-input:focus,
.result-input:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.timezone-select,
.unit-select {
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 0.95rem;
background: white;
min-width: 180px;
cursor: pointer;
transition: border-color 0.2s ease;
width: auto !important;
margin-bottom: 0 !important;
}
.timezone-select:focus,
.unit-select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.action-group {
display: flex;
justify-content: center;
}
.converter-btn {
padding: 10px 24px;
border-radius: 8px;
font-weight: 500;
font-size: 1rem;
min-width: 120px;
}
.error-message {
padding: 10px 14px;
background: #ffeaea;
border: 1px solid #ffcccc;
border-radius: 8px;
color: #d32f2f;
font-size: 0.9rem;
text-align: center;
animation: fadeIn 0.3s ease;
}
.result-info {
padding: 12px 16px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
border: 1px solid #e0e0e0;
font-size: 0.95rem;
text-align: center;
}
.result-label {
font-weight: 600;
color: #2c3e50;
}
.result-value {
font-family: 'Courier New', monospace;
font-weight: 700;
color: #3498db;
margin: 0 4px;
}
.result-unit {
color: #7f8c8d;
font-weight: 500;
}
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@@ -956,41 +48,3 @@ select {
transform: translateY(0); transform: translateY(0);
} }
} }
/* 响应式设计 */
@media (max-width: 768px) {
.input-group,
.result-group {
flex-direction: column;
align-items: stretch;
}
.timezone-select,
.unit-select {
min-width: auto;
width: 100% !important;
}
.datetime-converter {
padding: 16px;
}
}
@media (max-width: 480px) {
.converter-title {
font-size: 1.3rem;
}
.datetime-input,
.result-input,
.timezone-select,
.unit-select {
padding: 8px 12px;
font-size: 0.9rem;
}
.converter-btn {
padding: 10px 20px;
font-size: 0.95rem;
}
}
+4 -3
View File
@@ -2,14 +2,15 @@
import { HashRouter as Router, Routes, Route } from 'react-router-dom'; import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import TimestampPage from './pages/TimestampPage'; import TimestampPage from './pages/TimestampPage';
import RecordeReplayPage from './pages/RecordeReplayPage'; import RecordeReplayPage from './pages/RecordeReplayPage';
import Navbar from './components/Navbar'; import TestPage from './pages/TestPage';
import RoutePersistence from './components/RoutePersistence'; import Navbar from '../../components/Navbar';
import RoutePersistence from '../../components/RoutePersistence';
import './App.css'; import './App.css';
// 路由配置数据 // 路由配置数据
const navItems = [ const navItems = [
{ path: '/test', label: '测试页面', element: <TestPage /> },
{ path: '/', label: '时间戳', element: <TimestampPage /> }, { path: '/', label: '时间戳', element: <TimestampPage /> },
// {path: '/dzmy', label: '电子木鱼', element: <ElectronicWoodenFishPage/>},
{ path: '/recorde-replay', label: '录制与回放', element: <RecordeReplayPage /> }, { path: '/recorde-replay', label: '录制与回放', element: <RecordeReplayPage /> },
]; ];
@@ -1,91 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
const CopyButton = ({
text = '要复制的文本',
buttonText = '复制文本',
className = 'action-btn',
successMessage = '复制成功!',
errorMessage = '复制失败,请手动复制。',
copyingMessage = '复制中...',
}) => {
const [status, setStatus] = useState('idle'); // 'idle' | 'copying' | 'success' | 'error'
useEffect(() => {
let timer: number;
if (status === 'success' || status === 'error') {
timer = window.setTimeout(() => {
setStatus('idle');
}, 2000);
}
return () => clearTimeout(timer);
}, [status]);
const performCopy = useCallback(async () => {
if (!text) {
console.warn('没有提供要复制的文本');
return false;
}
const safeText = String(text);
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(safeText);
return true;
} catch (err) {
console.error('使用 Clipboard API 复制失败:', err);
return false;
}
}
return false;
}, [text]);
const handleClick = useCallback(async () => {
setStatus('copying');
try {
const isSuccess = await performCopy();
setStatus(isSuccess ? 'success' : 'error');
} catch (error) {
console.error('复制时出错:', error);
setStatus('error');
}
}, [performCopy]);
// 根据状态计算当前显示的文本
const currentText =
status === 'success'
? successMessage
: status === 'error'
? errorMessage
: status === 'copying'
? copyingMessage
: buttonText;
// 动态样式:只在非默认状态下覆盖颜色,平时让 className 控制
const getStyle = () => {
if (status === 'success') {
return { backgroundColor: '#4CAF50', color: 'white' };
}
if (status === 'error') {
return { backgroundColor: '#f44336', color: 'white' };
}
return {};
};
return (
<button
onClick={handleClick}
className={`${className} ${status} copy-button`}
style={getStyle()}
disabled={status === 'success' || status === 'copying'}
>
{currentText}
</button>
);
};
export default CopyButton;
@@ -1,185 +0,0 @@
import { useState, useCallback } from 'react';
import dayjs from '@/utils/dayjs';
/**
* 日期时间转时间戳组件
*
* 功能特性:
* 1. 将日期时间字符串转换为时间戳
* 2. 支持多种时区选择
* 3. 支持毫秒和秒单位切换
* 4. 提供输入验证和错误提示
* 5. 实时单位转换
*
* @component
* @example
* ```jsx
* <DatetimeToTimestamp />
* ```
*
* @returns {JSX.Element} 日期时间转时间戳组件
*/
// 常用时区列表
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',
];
// 时间戳单位选项
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒(ms)' },
{ value: 'seconds', label: '秒(s)' },
];
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) => {
try {
if (!currentDate) {
setError('请输入有效的日期时间');
return '';
}
const timestamp = dayjs.tz(currentDate, zone);
if (!timestamp.isValid()) {
setError('无效的日期时间格式');
return '';
}
setError('');
const ms = timestamp.valueOf();
return currentUnit === TIMESTAMP_UNITS[0].value
? ms.toString()
: Math.floor(ms / 1000).toString();
} catch (err) {
console.error('转换错误:', err);
return '';
}
},
[],
);
const handleConvert = useCallback(() => {
const newResult = performConversion(dateValue, selectedZone, unit);
setResult(newResult);
}, [dateValue, selectedZone, unit, performConversion]);
/**
* 处理时间戳单位变化
*/
const handleUnitChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const newUnit = e.target.value;
setUnit(newUnit);
// 如果已有结果,重新计算
if (result) {
setResult(performConversion(dateValue, selectedZone, newUnit) || '');
}
},
[dateValue, selectedZone, result, performConversion],
);
return (
<div className="datetime-converter">
<h2 className="converter-title"></h2>
<div className="converter-form">
<div className="input-group">
<input
type="text"
placeholder="输入日期时间 (如: 2024-01-01 12:00:00)"
value={dateValue}
className="datetime-input"
onChange={(e) => {
setDateValue(e.target.value);
if (error) setError('');
}}
aria-label="输入要转换的日期时间"
title="支持格式: YYYY-MM-DD HH:mm:ss"
/>
<select
value={selectedZone}
className="timezone-select"
onChange={(e) => setSelectedZone(e.target.value)}
aria-label="选择时区"
>
{TIME_ZONE_LIST.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</select>
</div>
{error && (
<div className="error-message" role="alert">
{error}
</div>
)}
<div className="action-group">
<button
className="converter-btn action-btn"
onClick={handleConvert}
aria-label="转换日期时间为时间戳"
>
</button>
</div>
<div className="result-group">
<input
type="text"
placeholder="转换结果"
value={result}
className="result-input"
readOnly
aria-label="转换结果"
/>
<select
value={unit}
className="unit-select"
onChange={handleUnitChange}
aria-label="选择时间戳单位"
>
{TIMESTAMP_UNITS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
</div>
);
}
-97
View File
@@ -1,97 +0,0 @@
import { NavLink } from 'react-router-dom';
import { useState, useEffect, ReactNode } from 'react';
interface RouteItem {
path: string;
label: string;
element: ReactNode;
}
interface NavbarProps {
items?: RouteItem[];
}
function Navbar({ items = [] }: NavbarProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [visibleItems, setVisibleItems] = useState(items.length);
// 检测屏幕尺寸变化
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
setIsMobile(width < 768);
// 根据屏幕宽度决定显示多少个导航项
if (width >= 768) {
setVisibleItems(items.length); // 大屏幕显示所有
} else if (width >= 480) {
// 中等屏幕:如果导航项超过3个,显示3个,否则显示全部
setVisibleItems(Math.min(3, items.length));
} else {
// 小屏幕:如果导航项超过2个,显示2个,否则显示全部
setVisibleItems(Math.min(2, items.length));
}
};
handleResize(); // 初始调用
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [items.length]); // 添加依赖,如果 items 长度变化也需要重新计算
// 计算哪些导航项应该显示,哪些应该折叠
const visibleNavItems = items.slice(0, visibleItems);
const collapsedNavItems = items.slice(visibleItems);
return (
<nav className="nav">
<div className="nav-content">
<ul className={`nav-list ${isMenuOpen ? 'open' : ''}`}>
{/* 显示的导航项 */}
{visibleNavItems.map((item) => (
<li key={item?.path}>
<NavLink
to={item?.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
onClick={() => isMobile && setIsMenuOpen(false)}
>
{item?.label}
</NavLink>
</li>
))}
{/* 折叠区域逻辑 */}
{collapsedNavItems.length > 0 && (
<li className="nav-collapse-item">
<div className={`nav-collapse-content ${isMenuOpen ? 'show' : ''}`}>
{isMenuOpen &&
collapsedNavItems.map((item) => (
<NavLink
key={item?.path}
to={item?.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
onClick={() => setIsMenuOpen(false)}
>
{item?.label}
</NavLink>
))}
</div>
<button
className="nav-toggle"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? '收起菜单' : '展开菜单'}
>
<span className="nav-toggle-icon">{isMenuOpen ? '×' : '☰'}</span>
{collapsedNavItems.length > 0 && !isMenuOpen && (
<span className="nav-collapse-count">+{collapsedNavItems.length}</span>
)}
</button>
</li>
)}
</ul>
</div>
</nav>
);
}
export default Navbar;
@@ -1,183 +0,0 @@
import { useState, useCallback } from 'react';
/**
* 时间戳转日期时间组件
*
* 功能特性:
* 1. 将时间戳转换为日期时间字符串
* 2. 支持多种时区选择
* 3. 支持毫秒和秒单位切换
* 4. 提供输入验证和错误提示
* ```
*
* @returns {JSX.Element} 时间戳转日期时间组件
*/
// 常用时区列表
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',
];
// 时间戳单位选项
const TIMESTAMP_UNITS = [
{ value: 'milliseconds', label: '毫秒(ms)' },
{ value: 'seconds', label: '秒(s)' },
];
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(
(timestampValue: string, selectedZone: string, unit: string) => {
if (!timestampValue || timestampValue.trim() === '') {
setError('请输入有效的日期时间');
return '';
}
try {
const numberValue = Number(timestampValue);
if (isNaN(numberValue)) {
setError('时间戳必须是数字');
return '';
}
const d = unit === TIMESTAMP_UNITS[0].value ? dayjs(numberValue) : dayjs.unix(numberValue);
if (!d.isValid()) {
setError('无效的时间戳格式');
return '';
}
const dateTime = d.tz(selectedZone).format('YYYY/MM/DD HH:mm:ss');
setError('');
return dateTime;
} catch (err) {
console.error('转换错误:', err);
return '';
}
},
[],
);
const handleConvert = useCallback(() => {
const newResult = performConversion(timestampValue, selectedZone, unit);
setTimestampResult(newResult);
}, [timestampValue, selectedZone, unit, performConversion]);
/**
* 处理时间戳输入变化
*/
const handleInputChange = useCallback((e) => {
setTimestampValue(e.target.value);
setError(''); // 清除错误信息
}, []);
/**
* 处理时区选择变化
*/
const handleZoneChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const newZone = e.target.value;
setSelectedZone(newZone);
}, []);
/**
* 处理时间戳单位变化
*/
const handleUnitChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const newUnit = e.target.value;
setUnit(newUnit);
}, []);
return (
<div className="datetime-converter">
<h2 className="converter-title"></h2>
<div className="converter-form">
<div className="input-group">
<input
type="number"
placeholder="输入时间戳 (如: 1704067200000)"
value={timestampValue}
className="datetime-input"
onChange={handleInputChange}
aria-label="输入要转换的时间戳"
title="支持毫秒或秒为单位的时间戳"
/>
<select
value={unit}
className="unit-select"
onChange={handleUnitChange}
aria-label="选择时间戳单位"
>
{TIMESTAMP_UNITS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{error && (
<div className="error-message" role="alert">
{error}
</div>
)}
<div className="action-group">
<button
className="converter-btn action-btn"
onClick={handleConvert}
aria-label="转换时间戳为日期时间"
>
</button>
</div>
<div className="result-group">
<input
type="text"
placeholder="转换结果"
value={timestampResult}
className="result-input"
readOnly
aria-label="转换结果"
/>
<select
value={selectedZone}
className="timezone-select"
onChange={handleZoneChange}
aria-label="选择时区"
>
{TIME_ZONE_LIST.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
</select>
</div>
</div>
</div>
);
}
+37 -27
View File
@@ -1,33 +1,29 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { AppState } from '../types'; import { AppState } from '../types';
import { messages } from '@/utils/messages'; import { sendMessage, onMessage } from '@/utils/messages';
import { Button, Container, Stack } from '@mui/material';
const RecordeReplayPage = () => { const RecordeReplayPage = () => {
const [status, setStatus] = useState<AppState>(AppState.READ); const [status, setStatus] = useState<AppState>(AppState.READ);
const isRecording = useMemo(() => status === AppState.RECORDING, [status]); const isRecording = useMemo(() => status === AppState.RECORDING, [status]);
useEffect(() => { useEffect(() => {
const handleMessage = (msg: { type: string }) => { const unlistenStarted = onMessage('popup:started', () => {
if (msg.type === messages.popup.ready) {
setStatus(AppState.READ);
}
if (msg.type === messages.popup.to.started) {
console.log('[popup] Received started message'); console.log('[popup] Received started message');
setStatus(AppState.RECORDING); setStatus(AppState.RECORDING);
} });
if (msg.type === messages.popup.to.stopped) { const unlistenStopped = onMessage('popup:stopped', () => {
setStatus(AppState.READ); setStatus(AppState.READ);
} });
};
browser.runtime.onMessage.addListener(handleMessage); const unlistenReady = onMessage('popup:ready', () => {
setStatus(AppState.READ);
});
browser.runtime sendMessage('popup:check-status', undefined)
.sendMessage({ type: messages.popup.checkStatus })
.then((res) => { .then((res) => {
if (res?.data?.isRecording) { if (res?.active) {
setStatus(AppState.RECORDING); setStatus(AppState.RECORDING);
} else { } else {
setStatus(AppState.READ); setStatus(AppState.READ);
@@ -37,30 +33,44 @@ const RecordeReplayPage = () => {
console.error(err); console.error(err);
}); });
return () => browser.runtime.onMessage.removeListener(handleMessage); return () => {
unlistenStarted();
unlistenStopped();
unlistenReady();
};
}, []); }, []);
const toggleRecording = async () => { const toggleRecording = async () => {
try { try {
const actionType = isRecording ? messages.popup.from.stop : messages.popup.from.start; if (isRecording) {
await browser.runtime.sendMessage({ type: actionType }); const result = await sendMessage('popup:stop', undefined);
if (!result?.ok) {
return;
}
} else {
const result = await sendMessage('popup:start', undefined);
if (!result?.ok) {
return;
}
}
} catch (error) { } catch (error) {
console.error('Error toggling recording:', error); console.error('Error toggling recording:', error);
setStatus(AppState.READ);
} }
}; };
return ( return (
<div style={{ padding: '20px' }}> <Container maxWidth={false} sx={{ py: 2.5 }}>
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}> <Stack direction="row" spacing={1.25} sx={{ mb: 2.5 }}>
<button <Button
className={`action-btn ${isRecording ? 'stop-btn' : 'start-btn'}`} variant="contained"
size="medium"
color={isRecording ? 'error' : 'primary'}
onClick={toggleRecording} onClick={toggleRecording}
> >
{isRecording ? '停止录制' : '开始录制'} {isRecording ? '停止录制' : '开始录制'}
</button> </Button>
</div> </Stack>
</div> </Container>
); );
}; };
+86
View File
@@ -0,0 +1,86 @@
import { sendMessage } from '@/utils/messages';
import { Button } from '@mui/material';
import { useState, useEffect } from 'react';
const TestPage = () => {
const [tabId, setTabId] = useState(-1);
useEffect(() => {
(async () => {
const activeTabs = await chrome.tabs.query({
active: true,
currentWindow: true,
});
console.log(`activeTabs:`, activeTabs[0].id);
if (activeTabs[0].id && activeTabs[0].id > 0) setTabId(activeTabs[0].id);
else throw new Error('no active tab');
})();
});
const handleSeedMessage = async () => {
const status = await sendMessage('popup:check-status');
console.log(`[popup]status: ${status}`);
};
const handleAttach = () => {
try {
chrome.debugger.attach({ tabId: tabId }, '1.2', () => {
console.log('[debugger] attached');
});
} catch (err) {
console.error(err);
}
};
const handleDetach = async () => {
try {
chrome.debugger.detach({ tabId: tabId }, () => {
console.log('[debugger] detached');
});
} catch (err) {
console.error(err);
}
};
const handleGetTarget = () => {
chrome.debugger.getTargets((targets) => {
console.log('[debugger] targets:', targets);
});
};
const enableRuntime = () => {
chrome.debugger.sendCommand({ tabId: tabId }, 'Runtime.enable', {}, (res) => {
console.log('[debugger] Runtime.enable:', res);
});
};
const disableRuntime = () => {
chrome.debugger.sendCommand({ tabId: tabId }, 'Runtime.disable', {}, (res) => {
console.log('[debugger] Runtime.disable:', res);
});
};
return (
<div>
<Button variant="contained" color="primary" onClick={handleSeedMessage}>
</Button>
<Button variant="contained" color="primary" onClick={handleAttach}>
attach
</Button>
<Button variant="contained" color="primary" onClick={handleDetach}>
detach
</Button>
<Button variant="contained" color="primary" onClick={handleGetTarget}>
getTargets
</Button>
<Button variant="contained" color="primary" onClick={enableRuntime}>
enableRuntime
</Button>
<Button variant="contained" color="primary" onClick={disableRuntime}>
disableRuntime
</Button>
</div>
);
};
export default TestPage;
+8 -5
View File
@@ -1,14 +1,17 @@
import { TimestampToDatetime } from '../components/TimestampToDatetime'; import { TimestampToDatetime } from '@/components/TimestampToDatetime';
import { DatetimeToTimestamp } from '../components/DatetimeToTimestamp'; import { DatetimeToTimestamp } from '@/components/DatetimeToTimestamp';
import { TimestampExecution } from '../components/TimestampExecution'; import { TimestampExecution } from '@/components/TimestampExecution';
import { Container, Box } from '@mui/material';
const TimestampPage = () => { const TimestampPage = () => {
return ( return (
<div className="timestamp-utils"> <Container maxWidth="md" sx={{ py: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, width: '100%' }}>
<TimestampExecution /> <TimestampExecution />
<TimestampToDatetime /> <TimestampToDatetime />
<DatetimeToTimestamp /> <DatetimeToTimestamp />
</div> </Box>
</Container>
); );
}; };
+743 -24
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -17,6 +17,10 @@
"lint": "eslint . --max-warnings=0" "lint": "eslint . --max-warnings=0"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@rrweb/all": "^2.0.0-alpha.18", "@rrweb/all": "^2.0.0-alpha.18",
"@rrweb/rrweb-plugin-console-record": "^2.0.0-alpha.18", "@rrweb/rrweb-plugin-console-record": "^2.0.0-alpha.18",
"@rrweb/rrweb-plugin-console-replay": "^2.0.0-alpha.18", "@rrweb/rrweb-plugin-console-replay": "^2.0.0-alpha.18",
@@ -24,6 +28,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@webext-core/messaging": "^2.3.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dexie": "^4.2.1", "dexie": "^4.2.1",
+4 -2
View File
@@ -48,7 +48,9 @@
"assets/**/*", "assets/**/*",
"hooks/**/*", "hooks/**/*",
".wxt/types/**/*.ts", ".wxt/types/**/*.ts",
".wxt/types/*.d.ts" ".wxt/types/*.d.ts",
"components",
"eslint.config.ts"
], ],
"exclude": ["node_modules", ".wxt", "eslint.config.ts"] "exclude": ["node_modules", ".wxt"]
} }
+24 -30
View File
@@ -1,30 +1,24 @@
export const messages = { import { defineExtensionMessaging } from '@webext-core/messaging';
popup: {
from: { interface ProtocolMap {
start: 'popup:start', // --- Popup 相关 ---
stop: 'popup:stop', 'popup:start': () => { ok: boolean; error?: string };
}, 'popup:stop': () => { ok: boolean; error?: string };
to: { 'popup:started': () => void;
stopped: 'popup:stopped', 'popup:stopped': () => void;
started: 'popup:started', 'popup:tab-changed': (data: { currentTabId: number; recordingTabId: number | undefined }) => void;
}, 'popup:check-status': () => { active: boolean; startTime?: number };
checkStatus: 'popup:check-status', 'popup:ready': () => void;
ready: 'popup:ready',
}, // --- Content 相关 ---
content: { 'content:save-track-events': (event: unknown) => boolean;
from: { 'content:start-recording': () => { ok: boolean; error?: string };
saveTrackeEvents: 'content:save-tracke-events', 'content:stop-recording': () => { ok: boolean };
}, 'content:check-status': () => boolean;
to: {
startRecording: 'content:start-recording', // --- Offscreen 相关 ---
stopRecording: 'content:stop-recording', 'offscreen:start-recording': (streamId: string) => void;
}, 'offscreen:stop-recording': () => void;
checkStatus: 'content:check-status', }
},
offscreen: { export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
to: {
startRecording: 'offscreen:start-recording',
stopRecording: 'offscreen:stop-recording',
},
},
};
+293
View File
@@ -0,0 +1,293 @@
/**
* IndexedDB 工具模块 - 用于分块存储录制事件
* 避免长时间录制导致内存溢出
*/
const DB_NAME = 'recording-events-db';
const DB_VERSION = 1;
const STORE_NAME = 'events';
const CHUNK_SIZE = 100; // 每个 chunk 存储的事件数量
export interface EventChunk {
id: number;
sessionId: string;
chunkIndex: number;
events: unknown[];
timestamp: number;
}
interface RecordingSession {
id: string;
startTime: number;
tabId: number;
chunkCount: number;
totalEvents: number;
}
let db: IDBDatabase | null = null;
/**
* 打开数据库连接
*/
export async function openDB(): Promise<IDBDatabase> {
if (db) return db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
// 创建事件 chunk 存储
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
store.createIndex('sessionId', 'sessionId', { unique: false });
store.createIndex('chunkIndex', 'chunkIndex', { unique: false });
store.createIndex('sessionId_chunkIndex', ['sessionId', 'chunkIndex'], { unique: true });
}
// 创建录制会话存储
if (!database.objectStoreNames.contains('sessions')) {
const sessionStore = database.createObjectStore('sessions', { keyPath: 'id' });
sessionStore.createIndex('startTime', 'startTime', { unique: false });
}
};
});
}
/**
* 初始化新的录制会话
*/
export async function initRecordingSession(sessionId: string, tabId: number): Promise<void> {
const database = await openDB();
return new Promise((resolve, reject) => {
const tx = database.transaction('sessions', 'readwrite');
const store = tx.objectStore('sessions');
const session: RecordingSession = {
id: sessionId,
startTime: Date.now(),
tabId,
chunkCount: 0,
totalEvents: 0,
};
const request = store.put(session);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 获取当前会话的 chunk 数量
*/
export async function getSessionChunkCount(sessionId: string): Promise<number> {
const database = await openDB();
return new Promise((resolve, reject) => {
const tx = database.transaction('sessions', 'readonly');
const store = tx.objectStore('sessions');
const request = store.get(sessionId);
request.onsuccess = () => {
const session = request.result as RecordingSession | undefined;
resolve(session?.chunkCount || 0);
};
request.onerror = () => reject(request.error);
});
}
/**
* 追加事件到存储(自动分块)
* 当当前 chunk 满时创建新 chunk
*/
export async function appendEvents(sessionId: string, newEvents: unknown[]): Promise<void> {
const database = await openDB();
const chunkCount = await getSessionChunkCount(sessionId);
// 获取或创建当前 chunk
let currentChunk: EventChunk | null = null;
let currentChunkIndex = chunkCount > 0 ? chunkCount - 1 : 0;
if (chunkCount > 0) {
currentChunk = await getChunk(database, sessionId, currentChunkIndex);
}
// 如果当前 chunk 不存在或已满,创建新 chunk
if (!currentChunk || currentChunk.events.length >= CHUNK_SIZE) {
currentChunkIndex = chunkCount;
currentChunk = {
id: 0, // 将由 IndexedDB 自动分配
sessionId,
chunkIndex: currentChunkIndex,
events: [],
timestamp: Date.now(),
};
// 更新会话的 chunk 计数
await updateSessionChunkCount(database, sessionId, chunkCount + 1);
}
// 将新事件添加到当前 chunk
currentChunk.events.push(...newEvents);
// 保存 chunk
await saveChunk(database, currentChunk);
}
/**
* 获取指定 chunk
*/
async function getChunk(
database: IDBDatabase,
sessionId: string,
chunkIndex: number,
): Promise<EventChunk | null> {
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('sessionId_chunkIndex');
const request = index.get([sessionId, chunkIndex]);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
* 保存 chunk
*/
async function saveChunk(database: IDBDatabase, chunk: EventChunk): Promise<void> {
return new Promise((resolve, reject) => {
const tx = database.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const request = store.put(chunk);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* 更新会话的 chunk 计数
*/
async function updateSessionChunkCount(
database: IDBDatabase,
sessionId: string,
chunkCount: number,
): Promise<void> {
return new Promise((resolve, reject) => {
const tx = database.transaction('sessions', 'readwrite');
const store = tx.objectStore('sessions');
const getRequest = store.get(sessionId);
getRequest.onsuccess = () => {
const session = getRequest.result as RecordingSession | undefined;
if (session) {
session.chunkCount = chunkCount;
const putRequest = store.put(session);
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
} else {
resolve();
}
};
getRequest.onerror = () => reject(getRequest.error);
});
}
/**
* 流式读取所有事件(按 chunk 顺序)
* 使用回调方式避免一次性加载所有事件到内存
*/
export async function streamAllEvents(
sessionId: string,
onChunk: (events: unknown[]) => void | Promise<void>,
): Promise<void> {
const database = await openDB();
const chunkCount = await getSessionChunkCount(sessionId);
for (let i = 0; i < chunkCount; i++) {
const chunk = await getChunk(database, sessionId, i);
if (chunk && chunk.events.length > 0) {
await onChunk(chunk.events);
}
}
}
/**
* 获取所有事件(用于下载,一次性加载)
* 注意:这会将所有事件加载到内存,仅在需要导出时调用
*/
export async function getAllEvents(sessionId: string): Promise<unknown[]> {
const allEvents: unknown[] = [];
await streamAllEvents(sessionId, (events) => {
allEvents.push(...events);
});
return allEvents;
}
/**
* 删除录制会话的所有数据
*/
export async function deleteRecordingSession(sessionId: string): Promise<void> {
const database = await openDB();
return new Promise((resolve, reject) => {
// 删除所有相关的 chunk
const chunkTx = database.transaction(STORE_NAME, 'readwrite');
const chunkStore = chunkTx.objectStore(STORE_NAME);
const index = chunkStore.index('sessionId');
const request = index.openCursor(IDBKeyRange.only(sessionId));
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
chunkTx.oncomplete = () => {
// 删除会话记录
const sessionTx = database.transaction('sessions', 'readwrite');
const sessionStore = sessionTx.objectStore('sessions');
const deleteRequest = sessionStore.delete(sessionId);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
};
chunkTx.onerror = () => reject(chunkTx.error);
});
}
/**
* 生成唯一的会话 ID
*/
export function generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* 清理数据库(用于调试或重置)
*/
export async function clearDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => {
db = null;
resolve();
};
request.onerror = () => reject(request.error);
});
}
+12 -12
View File
@@ -1,7 +1,7 @@
export const downloadHtmlInBackground = (events: unknown[]) => { export const downloadHtmlInBackground = (events: unknown[]) => {
if (!events || events.length === 0) return; if (!events || events.length === 0) return;
// 安全转义 (保持之前的修复) // 安全转义
const safeEventsString = JSON.stringify(events) const safeEventsString = JSON.stringify(events)
.replace(/\\/g, '\\\\') .replace(/\\/g, '\\\\')
.replace(/`/g, '\\`') .replace(/`/g, '\\`')
@@ -14,10 +14,15 @@ export const downloadHtmlInBackground = (events: unknown[]) => {
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>RRWeb 回放</title> <title>RRWeb 回放</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/style.css" />
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f0f2f5; }
#player { width: 100%; max-width: 1024px; }
</style>
</head> </head>
<body style="margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #f0f2f5;"> <body>
<div id="player"></div> <div id="player"></div>
<!-- 使用 UMD 版本,rrwebPlayer 会作为全局变量 -->
<script src="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/index.js"></script> <script src="https://cdn.jsdelivr.net/npm/rrweb-player@latest/dist/index.js"></script>
<script type="module"> <script type="module">
@@ -25,6 +30,7 @@ export const downloadHtmlInBackground = (events: unknown[]) => {
const events = JSON.parse(\`${safeEventsString}\`); const events = JSON.parse(\`${safeEventsString}\`);
// rrwebPlayer 作为全局变量可用
new rrwebPlayer({ new rrwebPlayer({
target: document.getElementById('player'), target: document.getElementById('player'),
props: { props: {
@@ -33,25 +39,19 @@ export const downloadHtmlInBackground = (events: unknown[]) => {
height: 576, height: 576,
autoPlay: true, autoPlay: true,
showController: true, showController: true,
// --- 👇 关键修复:添加下面这行 ---
// 这会告诉 rrweb 关闭严格的 iframe 沙盒限制
// 从而消除 "Blocked script execution" 错误
UNSAFE_replayCanvas: true, UNSAFE_replayCanvas: true,
// ------------------------------
plugins: [ plugins: [
getReplayConsolePlugin({ getReplayConsolePlugin({
level: ['info', 'log', 'warn', 'error'], level: ['info', 'log', 'warn', 'error'],
}) }),
] ],
}, },
}); });
</script> </script>
</body> </body>
</html>`; </html>`;
// 下载逻辑 (保持不变) // 下载逻辑
const blob = new Blob([htmlContent], { type: 'text/html' }); const blob = new Blob([htmlContent], { type: 'text/html' });
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
@@ -59,7 +59,7 @@ export const downloadHtmlInBackground = (events: unknown[]) => {
url: reader.result as string, url: reader.result as string,
filename: `replay-${Date.now()}.html`, filename: `replay-${Date.now()}.html`,
saveAs: true, saveAs: true,
}); }).then(r => console.log(r));
}; };
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}; };
+35
View File
@@ -0,0 +1,35 @@
import { browser } from 'wxt/browser';
/**
* 获取当前活动标签页的 ID
* @returns Promise<number | undefined> 返回活动标签页的 ID,如果未找到则返回 undefined
*/
export async function getActiveTabId(): Promise<number | undefined> {
const activeTabs = await browser.tabs.query({
active: true,
currentWindow: true,
});
const activeTab = activeTabs[0];
if (!activeTab) {
throw new Error('No active tab found.');
}
// 检查URL是否受限
if (activeTab?.url) {
const restrictedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'data:',
'file:',
];
if (restrictedProtocols.some((protocol) => activeTab.url!.startsWith(protocol))) {
throw new Error(`Cannot send message to a restricted URL: ${activeTab.url}`);
}
}
return activeTab.id;
}
+3 -5
View File
@@ -1,18 +1,16 @@
import * as rrweb from 'rrweb'; import * as rrweb from 'rrweb';
import { listenerHandler } from '@rrweb/types'; import { listenerHandler } from '@rrweb/types';
import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record'; import { getRecordConsolePlugin } from '@rrweb/rrweb-plugin-console-record';
import { sendMessage } from '@/utils/messages';
export const createRecorder = () => { export const createRecorder = () => {
let stopFn: listenerHandler | null = null; let stopFn: listenerHandler | null = null;
const startRecord = async (msg: string) => { const startRecord = async () => {
try { try {
const handler = rrweb.record({ const handler = rrweb.record({
emit(event) { emit(event) {
chrome.runtime.sendMessage({ sendMessage('content:save-track-events', event);
type: msg,
payload: event,
});
}, },
plugins: [getRecordConsolePlugin()], plugins: [getRecordConsolePlugin()],
}); });
+6
View File
@@ -0,0 +1,6 @@
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
startUrls: ['https://www.baidu.com', 'chrome://extensions/'],
chromiumArgs: ['chrome://extensions/'],
});
+2 -2
View File
@@ -16,12 +16,12 @@ export default defineConfig({
'tabs', 'tabs',
'offscreen', 'offscreen',
'downloads', 'downloads',
'debugger',
], ],
host_permissions: ['<all_urls>'], host_permissions: ['<all_urls>'],
action: { action: {
default_title: 'Testing Tools', default_title: 'Testing Tools',
}, },
// 注意:WXT 会根据 entrypoints/options/index.html 自动生成 options_ui 的 page 路径
options_ui: { options_ui: {
open_in_tab: true, open_in_tab: true,
}, },
@@ -41,7 +41,7 @@ export default defineConfig({
terserOptions: { terserOptions: {
format: { format: {
ascii_only: true, ascii_only: true,
comments: false, // 去掉注释,防止注释里有乱码 comments: false,
}, },
}, },
}, },