diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..0e3a523 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,72 @@ +# Testing Tools 项目指南 + +本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。 + +## 1. 项目概览 + +- **名称**: Testing Tools +- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) +- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript +- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性) +- **日期处理**: dayjs (配合 timezone 和 utc 插件) +- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。 + +## 2. 项目结构 + +```text +├── .github/ # CI/CD 工作流 +├── .husky/ # Git Hooks (pre-commit linting) +├── assets/ # 静态资源 (SVG 等) +├── components/ # 复用 UI 组件 +├── entrypoints/ # 浏览器扩展入口点 +│ ├── background.ts # 后台 Service Worker 逻辑 +│ ├── content.ts # 内容脚本注入逻辑 +│ ├── popup/ # 扩展弹出层 (主要功能区) +│ └── options/ # 扩展选项页面 +├── types/ # 全局 TypeScript 类型声明 +├── utils/ # 工具类 (存储、消息通信、日期处理封装) +├── wxt.config.ts # WXT 框架与 Manifest 配置 +└── package.json # 依赖管理与脚本 +``` + +## 3. 开发规范与风格约定 + +### 3.1 UI 设计语言 + +- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。 +- **MUI 定制**: + - 严禁使用 MUI 默认的粗犷边框和深重阴影。 + - 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`。 + - 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。 + - 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。 +- **布局**: 优先使用 `Stack` 和 `Box` 进行布局,确保自上而下的操作流顺畅。 + +### 3.2 技术选型惯例 + +- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。 +- **状态管理**: 优先使用 React 原生 `useState` 和 `useMemo`。 +- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。 +- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。 + +## 4. 关键指令 + +### 4.1 开发与调试 + +- `npm run dev`: 启动 Chrome 扩展开发模式。 +- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。 + +### 4.2 构建与检查 + +- `npm run build`: 构建生产版本。 +- `npm run compile`: TypeScript 类型检查。 +- `npm run lint`: ESLint 代码风格检查。 + +## 5. 权限与清单 (Manifest) + +- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`。 +- **宿主权限**: `` (用于在所有页面注入 content 脚本)。 +- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。 + +## 6. 维护者提示 + +在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。 diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index cabb953..d2994a2 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -28,14 +28,13 @@ body { } .app { - max-width: 800px; - margin: 0 auto; - padding: 20px; - font-family: Arial, sans-serif; - width: 380px; - min-width: 320px; + width: 400px; min-height: 100%; + margin: 0 auto; box-sizing: border-box; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + overflow-x: hidden; } @keyframes fadeIn { diff --git a/entrypoints/popup/pages/TimestampPage.tsx b/entrypoints/popup/pages/TimestampPage.tsx index c0fdd6f..3a6fc86 100644 --- a/entrypoints/popup/pages/TimestampPage.tsx +++ b/entrypoints/popup/pages/TimestampPage.tsx @@ -1,12 +1,10 @@ -import { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import dayjs from '@/utils/dayjs'; import { Button, TextField, Select, MenuItem, - FormControl, - InputLabel, Paper, Stack, Typography, @@ -14,27 +12,223 @@ import { IconButton, Snackbar, Alert, + InputAdornment, + alpha, + Tooltip, + Theme, } from '@mui/material'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; +import CheckIcon from '@mui/icons-material/Check'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +// ================= 常量配置 ================= +const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss'; const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const; -export default function TimestampPage() { +type UnitType = 'ms' | 's'; +type ZoneType = (typeof ZONES)[number]; + +const INPUT_STYLE = { + '& .MuiOutlinedInput-root': { + bgcolor: 'grey.50', + borderRadius: 3, + transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', + '& fieldset': { border: 'none' }, + '&:hover': { bgcolor: 'grey.100' }, + '&.Mui-focused': { + bgcolor: '#fff', + boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`, + }, + '&.Mui-error': { + boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`, + }, + }, + '& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' }, +}; + +// ================= 子组件:实时时钟 ================= +interface LiveClockProps { + unit: UnitType; + onCopy: (val: string) => void; + onUseNow: (val: number) => void; +} + +const LiveClock = React.memo(({ + unit, + onCopy, + onUseNow +}: LiveClockProps) => { const [now, setNow] = useState(() => Date.now()); - const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt'); - const [tsInput, setTsInput] = useState(() => String(Date.now())); - const [dtInput, setDtInput] = useState(() => dayjs().format('YYYY/MM/DD HH:mm:ss')); - const [result, setResult] = useState(''); - const [unit, setUnit] = useState<'ms' | 's'>('ms'); - const [zone, setZone] = useState<(typeof ZONES)[number]>('Asia/Shanghai'); - const [error, setError] = useState(''); - const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' }); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); + const displayVal = useMemo(() => + String(Math.floor(now / (unit === 'ms' ? 1 : 1000))), + [now, unit]); + + return ( + + + + {displayVal} + + + {unit} + + + + + onUseNow(now)} + sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }} + > + + + + + onCopy(displayVal)} + sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }} + > + + + + + + ); +}); + +LiveClock.displayName = 'LiveClock'; + +// ================= 子组件:多维度结果展示 ================= +interface ResultViewProps { + result: string; + mode: 'ts2dt' | 'dt2ts'; + unit: UnitType; + zone: string; + onCopy: (val: string) => void; +} + +const ResultView = React.memo(({ + result, + mode, + unit, + zone, + onCopy +}: ResultViewProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + onCopy(result); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [onCopy, result]); + + 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 ( + + + 转换结果 + + + + {copied ? : } + + + ), + }, + }} + sx={{ + ...INPUT_STYLE, + mb: 2, + '& .MuiOutlinedInput-root': { + ...INPUT_STYLE['& .MuiOutlinedInput-root'], + bgcolor: alpha('#2563eb', 0.03), + }, + }} + /> + + {/* 辅助信息预览 */} + + {[ + { label: '相对时间', value: extraInfo?.relative }, + { label: 'ISO 8601', value: extraInfo?.iso }, + { label: 'UTC 时间', value: extraInfo?.utc }, + ].map((item) => ( + + {item.label} + { if (item.value) onCopy(item.value); }} + sx={{ + fontFamily: 'monospace', + color: 'text.primary', + cursor: 'pointer', + '&:hover': { color: 'primary.main', textDecoration: 'underline' } + }} + > + {item.value} + + + ))} + + + ); +}); + +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('ms'); + const [zone, setZone] = useState('Asia/Shanghai'); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' }); + const copy = useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text); @@ -44,169 +238,175 @@ export default function TimestampPage() { } }, []); - const convertTs2Dt = useCallback(() => { - if (!tsInput) { - setError('请输入时间戳'); - return; + 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))); } - const num = Number(tsInput); - if (isNaN(num)) { - setError('无效数字'); - return; - } - const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num); - if (!d.isValid()) { - setError('无效时间戳'); - return; - } - setError(''); - setResult(d.tz(zone).format('YYYY/MM/DD HH:mm:ss')); - }, [tsInput, unit, zone]); + }, [mode, tsInput, dtInput, unit, zone]); - const convertDt2Ts = useCallback(() => { - if (!dtInput) { - setError('请输入日期时间'); - return; + // 智能实时转换 (Debounce Effect) + 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)); } - const d = dayjs.tz(dtInput, 'YYYY/MM/DD HH:mm:ss', zone); - if (!d.isValid()) { - setError('无效格式 (YYYY/MM/DD HH:mm:ss)'); - return; - } - setError(''); - const ms = d.valueOf(); - setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000))); - }, [dtInput, zone, unit]); + }, [mode, unit, zone]); return ( - - - - {Math.floor(now / (unit === 'ms' ? 1 : 1000))} - - - {unit === 'ms' ? '毫秒' : '秒'} - - - - + + + {/* 1. 实时时钟 */} + + + + { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }} + sx={{ + position: 'absolute', right: 80, top: 4, color: 'grey.400', + transition: 'transform 0.3s ease', + '&:hover': { transform: 'rotate(180deg)', color: 'primary.main' } + }} + > + + + - - + {/* 2. 模式切换 */} + + + {(['ts2dt', 'dt2ts'] as const).map((m) => ( + + ))} + + + {/* 3. 输入与设置 */} + + { + const val = e.target.value; + if (mode === 'ts2dt') { + setTsInput(val); + } else { + setDtInput(val); + } + setError(''); + }} + error={!!error} + helperText={error} + fullWidth + sx={INPUT_STYLE} + /> + + + + + + + + + {/* 4. 转换操作 (作为手动确认) */} - - - - {mode === 'ts2dt' ? ( - <> - { - setTsInput(e.target.value); - setError(''); - }} - error={!!error} - helperText={error} - fullWidth - size="small" - /> - - 单位 - - - - - ) : ( - <> - { - setDtInput(e.target.value); - setError(''); - }} - error={!!error} - helperText={error || '格式: YYYY/MM/DD HH:mm:ss'} - fullWidth - size="small" - /> - - 单位 - - - - - )} + {/* 5. 结果展示 */} + + - - 时区 - - - - copy(result)}> - - - ) : undefined, - }} - /> - - - setSnack({ ...snack, open: false })}> - + { setSnack((s) => ({ ...s, open: false })); }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + {snack.msg} - + ); } diff --git a/utils/dayjs.ts b/utils/dayjs.ts index 2a452a0..b046866 100644 --- a/utils/dayjs.ts +++ b/utils/dayjs.ts @@ -1,8 +1,13 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; dayjs.extend(utc); dayjs.extend(timezone); +dayjs.extend(relativeTime); + +dayjs.locale('zh-cn'); export default dayjs;