be5e2f02ee
* feat: optimize popup standalone window layout and enhance storage cleaner synchronization * docs: 更新README文档并删除过时文件 - 更新README文档,添加项目结构、功能特性和路由系统等详细信息 - 删除不再使用的文档文件,包括CLAUDE.md、GEMINI.md和多个设计规范文档 - 清理项目中的过时配置文件和计划文档 * feat: 添加 Vitest 测试框架和组件测试 - 添加 Vitest 配置 (vitest.config.ts, vitest.setup.ts) - 创建组件测试: Button, ToolCard, GlobalSnackbar, TopBar, RouterContainer, StorageCleanerConfirm - 创建工具测试: routes, storageCleaner - 修复 background.ts 监听器参数问题 - 修复 options/App.tsx 硬编码默认值 - 更新 lint-staged.config.mjs (添加 .mjs 支持, 添加 --no-warn-ignored) - 更新 tsconfig.json (添加测试类型支持, 移除测试文件排除) - 更新 package.json (添加测试脚本和依赖) * fix: 修复 StorageCleanerPage Chrome API 监听器内存泄漏 使用 useRef 模式存储 loadInfo 函数引用,避免依赖数组变化导致的监听器重复注册问题 * refactor(popup): 优化 OpenUrl 页面样式和导航逻辑 重构 OpenUrl 页面输入框样式,改进聚焦状态效果 移除 RouterProvider 依赖,直接通过存储设置侧边栏路由 在 OpenUrlViewer 页面添加加载状态指示器和错误处理 监听存储变化实现 URL 自动更新 * feat(ui): 优化存储清理页面UI和交互效果 重构存储清理页面组件,增强视觉层次和交互体验: - 使用新的错误提示样式和布局 - 改进选项卡片样式,增加悬停动画和选中状态 - 调整整体间距和排版,提升视觉一致性 - 添加微交互效果如悬停缩放和阴影 - 优化颜色方案和过渡动画 - 统一组件尺寸和字体层级 * feat: 添加二维码工具页面,支持URL转二维码和二维码解析功能 * chore: update package-lock.json (npm audit fix) * refactor(主题): 将页面样式抽离到统一配置文件 将各页面的颜色和样式配置抽离到config/pageTheme.ts中统一管理 优化测试用例中使用each替代forEach 更新路由测试以包含新的qrCode页面 * feat(二维码页面): 添加复制二维码功能并优化样式 添加复制二维码到剪贴板的功能,并调整按钮布局和样式。同时将 ContentCopyIcon 导入位置调整到其他图标导入之后,并修复缩进问题。在 tsconfig.json 中添加 vitest/globals 类型支持。 * feat(theme): 为所有页面添加统一的背景色和卡片背景色 为应用中的所有页面添加了统一的浅灰色背景(#f5f5f5)和白色卡片背景(#ffffff),以保持视觉一致性。修改了ToolCard组件以支持自定义卡片背景色,并更新了所有相关页面使用新的主题配置。 * feat: 添加复制按钮组件并优化现有复制功能 refactor(utils): 创建剪贴板工具函数 feat(components): 新增可复用的CopyButton组件 refactor(pages): 在QrCodePage和TimestampPage中使用CopyButton style: 格式化代码并调整部分样式 * refactor(存储): 统一qrCode相关存储键名 将'qrCode/expanded'重命名为'qrCode/qrExpanded'以保持命名一致性 * feat: 添加二维码工具功能并更新项目配置 - 新增二维码工具页面及相关组件和工具函数 - 添加 MIT 许可证文件 - 更新 package.json 配置为公开项目 - 更新 README 文档说明新功能
551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import dayjs from '@/utils/dayjs';
|
|
import {
|
|
TextField,
|
|
Select,
|
|
MenuItem,
|
|
Stack,
|
|
Typography,
|
|
Box,
|
|
IconButton,
|
|
Tooltip,
|
|
Container,
|
|
Fade,
|
|
Divider,
|
|
alpha,
|
|
} from '@mui/material';
|
|
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
import Button from '@/components/Button';
|
|
import CopyButton from '@/components/CopyButton';
|
|
import { DATE_FORMAT, ZONES, timestampPageStyles } from '@/config/pageTheme';
|
|
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
|
|
|
// ================= 子组件:实时时钟 (优化交互) =================
|
|
interface LiveClockProps {
|
|
unit: UnitType;
|
|
onUseNow: (val: number) => void;
|
|
onUnitChange: (u: UnitType) => void;
|
|
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void;
|
|
}
|
|
|
|
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const onUseNowRef = useRef(onUseNow);
|
|
const showMessageRef = useRef(showMessage);
|
|
|
|
useEffect(() => {
|
|
onUseNowRef.current = onUseNow;
|
|
showMessageRef.current = showMessage;
|
|
}, [onUseNow, showMessage]);
|
|
|
|
useEffect(() => {
|
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(t);
|
|
}, []);
|
|
|
|
const displayVal = useMemo(
|
|
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
[now, unit],
|
|
);
|
|
|
|
const handleUseNow = useCallback(() => {
|
|
onUseNowRef.current(now);
|
|
}, [now]);
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
p: 1.8,
|
|
mb: 2.5,
|
|
bgcolor: alpha('#2196f3', 0.04),
|
|
borderRadius: 4,
|
|
border: '1px solid',
|
|
borderColor: alpha('#2196f3', 0.1),
|
|
}}
|
|
>
|
|
<Stack spacing={0.5}>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: 'primary.main',
|
|
fontWeight: 800,
|
|
fontSize: '0.6rem',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
当前时间戳
|
|
</Typography>
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{
|
|
fontWeight: 800,
|
|
color: 'text.primary',
|
|
fontFamily: 'monospace',
|
|
fontSize: '1.2rem',
|
|
letterSpacing: '-0.5px',
|
|
lineHeight: 1.2,
|
|
}}
|
|
>
|
|
{displayVal}
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
{/* 胶囊式单位切换器 */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
p: 0.4,
|
|
bgcolor: alpha('#2196f3', 0.08),
|
|
borderRadius: 2.5,
|
|
border: '1px solid',
|
|
borderColor: alpha('#2196f3', 0.1),
|
|
}}
|
|
>
|
|
{(['ms', 's'] as const).map((u) => (
|
|
<Box
|
|
key={u}
|
|
onClick={() => onUnitChange(u)}
|
|
sx={{
|
|
px: 1.2,
|
|
py: 0.35,
|
|
borderRadius: 2,
|
|
cursor: 'pointer',
|
|
fontSize: '0.65rem',
|
|
fontWeight: 900,
|
|
transition: 'all 0.2s',
|
|
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
color: unit === u ? 'primary.main' : alpha('#2196f3', 0.4),
|
|
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
|
}}
|
|
>
|
|
{u.toUpperCase()}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Divider
|
|
orientation="vertical"
|
|
flexItem
|
|
sx={{ mx: 0.5, my: 1, borderColor: alpha('#2196f3', 0.1) }}
|
|
/>
|
|
|
|
<Stack direction="row" spacing={0.5}>
|
|
<Tooltip title="填充到下方">
|
|
<IconButton
|
|
size="small"
|
|
onClick={handleUseNow}
|
|
sx={{
|
|
color: timestampPageStyles.primaryColor,
|
|
bgcolor: '#fff',
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
|
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
|
}}
|
|
>
|
|
<AccessTimeIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<CopyButton
|
|
text={displayVal}
|
|
tooltip="复制时间戳"
|
|
size="small"
|
|
color={timestampPageStyles.primaryColor}
|
|
showMessage={showMessage}
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
LiveClock.displayName = 'LiveClock';
|
|
|
|
// ================= 子组件:多维度结果展示 =================
|
|
interface ResultViewProps {
|
|
result: string;
|
|
mode: 'ts2dt' | 'dt2ts';
|
|
unit: UnitType;
|
|
zone: string;
|
|
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void;
|
|
}
|
|
|
|
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
|
|
const extraInfo = useMemo(() => {
|
|
if (!result) return null;
|
|
const d =
|
|
mode === 'ts2dt'
|
|
? dayjs(result, DATE_FORMAT).tz(zone)
|
|
: unit === 'ms'
|
|
? dayjs(Number(result))
|
|
: dayjs.unix(Number(result));
|
|
|
|
return {
|
|
relative: d.fromNow(),
|
|
iso: d.toISOString(),
|
|
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
};
|
|
}, [result, mode, zone, unit]);
|
|
|
|
if (!result) return null;
|
|
|
|
return (
|
|
<Fade in={!!result}>
|
|
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
color: 'text.secondary',
|
|
mb: 1.2,
|
|
display: 'block',
|
|
fontWeight: 800,
|
|
fontSize: '0.7rem',
|
|
}}
|
|
>
|
|
转换结果
|
|
</Typography>
|
|
|
|
<Box
|
|
sx={{
|
|
bgcolor: alpha('#2196f3', 0.05),
|
|
p: 2,
|
|
borderRadius: 4,
|
|
position: 'relative',
|
|
mb: 2.5,
|
|
border: '1px solid',
|
|
borderColor: alpha('#2196f3', 0.1),
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="body1"
|
|
sx={{
|
|
fontFamily: 'monospace',
|
|
fontWeight: 700,
|
|
color: 'primary.main',
|
|
wordBreak: 'break-all',
|
|
pr: 4,
|
|
fontSize: '1rem',
|
|
}}
|
|
>
|
|
{result}
|
|
</Typography>
|
|
<CopyButton
|
|
text={result}
|
|
tooltip="复制结果"
|
|
size="small"
|
|
color={timestampPageStyles.primaryColor}
|
|
style={{
|
|
position: 'absolute',
|
|
right: 8,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Stack spacing={1.2}>
|
|
{[
|
|
{ label: '相对时间', value: extraInfo?.relative },
|
|
{ label: 'ISO 8601', value: extraInfo?.iso },
|
|
{ label: 'UTC 时间', value: extraInfo?.utc },
|
|
].map((item) => (
|
|
<Box
|
|
key={item.label}
|
|
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
|
|
>
|
|
{item.label}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
fontFamily: 'monospace',
|
|
color: 'text.secondary',
|
|
fontWeight: 600,
|
|
fontSize: '0.65rem',
|
|
}}
|
|
>
|
|
{item.value}
|
|
</Typography>
|
|
{item.value && (
|
|
<CopyButton
|
|
text={item.value}
|
|
tooltip="复制"
|
|
size="small"
|
|
color="primary"
|
|
showMessage={showMessage}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
</Fade>
|
|
);
|
|
});
|
|
|
|
ResultView.displayName = 'ResultView';
|
|
|
|
// ================= 主页面组件 =================
|
|
export default function TimestampPage() {
|
|
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
|
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
|
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
|
const [unit, setUnit] = useState<UnitType>('ms');
|
|
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
|
const [result, setResult] = useState('');
|
|
const [error, setError] = useState('');
|
|
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
|
|
|
const convert = useCallback(() => {
|
|
if (mode === 'ts2dt') {
|
|
const rawInput = tsInput.trim();
|
|
if (!rawInput) return;
|
|
const num = Number(rawInput);
|
|
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(DATE_FORMAT));
|
|
} else {
|
|
const rawInput = dtInput.trim();
|
|
if (!rawInput) return;
|
|
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
|
if (!d.isValid()) {
|
|
setError('格式错误');
|
|
return;
|
|
}
|
|
setError('');
|
|
const ms = d.valueOf();
|
|
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
|
}
|
|
}, [mode, tsInput, dtInput, unit, zone]);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(convert, 400);
|
|
return () => clearTimeout(timer);
|
|
}, [convert]);
|
|
|
|
const handleUseNow = useCallback(
|
|
(now: number) => {
|
|
if (mode === 'ts2dt') {
|
|
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
|
} else {
|
|
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
|
}
|
|
},
|
|
[mode, unit, zone],
|
|
);
|
|
|
|
return (
|
|
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 3 }}>
|
|
<Container sx={{ py: 2 }}>
|
|
{/* Header with Icon */}
|
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
|
<Box
|
|
sx={{
|
|
p: 1,
|
|
borderRadius: 2.5,
|
|
bgcolor: alpha('#2196f3', 0.1),
|
|
color: 'primary.main',
|
|
display: 'flex',
|
|
}}
|
|
>
|
|
<AccessTimeIcon sx={{ fontSize: 20 }} />
|
|
</Box>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography
|
|
variant="subtitle1"
|
|
fontWeight={900}
|
|
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
|
|
>
|
|
时间戳转换
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
|
|
Unix 毫秒数转换与格式化
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
|
|
{/* Live Clock Card */}
|
|
<LiveClock
|
|
unit={unit}
|
|
onUseNow={handleUseNow}
|
|
onUnitChange={setUnit}
|
|
showMessage={showMessage}
|
|
/>
|
|
|
|
{/* Mode Switcher */}
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
display: 'flex',
|
|
p: 0.6,
|
|
bgcolor: 'grey.100',
|
|
borderRadius: 4,
|
|
mb: 2.5,
|
|
border: '1px solid',
|
|
borderColor: 'grey.200',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
height: 'calc(100% - 10px)',
|
|
width: 'calc(50% - 5px)',
|
|
bgcolor: '#fff',
|
|
borderRadius: 3.5,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
|
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
|
top: 5,
|
|
left: 5,
|
|
}}
|
|
/>
|
|
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
|
<Box
|
|
key={m}
|
|
onClick={() => {
|
|
setMode(m);
|
|
setError('');
|
|
setResult('');
|
|
}}
|
|
sx={{
|
|
flex: 1,
|
|
py: 1,
|
|
textAlign: 'center',
|
|
position: 'relative',
|
|
zIndex: 1,
|
|
cursor: 'pointer',
|
|
fontWeight: 800,
|
|
fontSize: '0.75rem',
|
|
color: mode === m ? 'primary.main' : 'text.secondary',
|
|
transition: 'color 0.3s',
|
|
}}
|
|
>
|
|
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
{/* Input Area */}
|
|
<Stack spacing={2} sx={{ mb: 3 }}>
|
|
<TextField
|
|
placeholder={mode === 'ts2dt' ? '输入时间戳...' : DATE_FORMAT}
|
|
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
if (mode === 'ts2dt') {
|
|
setTsInput(val);
|
|
} else {
|
|
setDtInput(val);
|
|
}
|
|
setError('');
|
|
}}
|
|
error={!!error}
|
|
helperText={error}
|
|
fullWidth
|
|
sx={timestampPageStyles.INPUT_STYLE}
|
|
/>
|
|
|
|
<Stack direction="row" spacing={1.5}>
|
|
{/* 优化后的单位选择按钮组 */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
bgcolor: 'grey.50',
|
|
p: 0.5,
|
|
borderRadius: 3.5,
|
|
border: '1px solid',
|
|
borderColor: 'grey.100',
|
|
}}
|
|
>
|
|
{(['ms', 's'] as const).map((u) => (
|
|
<Box
|
|
key={u}
|
|
onClick={() => setUnit(u)}
|
|
sx={{
|
|
flex: 1,
|
|
py: 0.8,
|
|
textAlign: 'center',
|
|
borderRadius: 3,
|
|
cursor: 'pointer',
|
|
fontSize: '0.75rem',
|
|
fontWeight: 800,
|
|
transition: 'all 0.2s',
|
|
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
color: unit === u ? 'primary.main' : 'text.disabled',
|
|
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
|
}}
|
|
>
|
|
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Select
|
|
fullWidth
|
|
value={zone}
|
|
onChange={(e) => setZone(e.target.value as ZoneType)}
|
|
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }}
|
|
MenuProps={{
|
|
PaperProps: {
|
|
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
|
},
|
|
}}
|
|
>
|
|
{ZONES.map((z) => (
|
|
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
|
|
{z}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{/* Main Action */}
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
onClick={convert}
|
|
sx={{
|
|
py: 1.4,
|
|
borderRadius: 4,
|
|
bgcolor: 'primary.main',
|
|
fontWeight: 800,
|
|
fontSize: '0.9rem',
|
|
boxShadow: 'none',
|
|
'&:hover': {
|
|
bgcolor: 'primary.dark',
|
|
boxShadow: `0 8px 24px ${alpha('#2196f3', 0.2)}`,
|
|
},
|
|
}}
|
|
>
|
|
立即转换
|
|
</Button>
|
|
|
|
{/* Result View */}
|
|
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
|
|
</Container>
|
|
|
|
<GlobalSnackbar {...snackbarProps} />
|
|
</Box>
|
|
);
|
|
}
|