From 84ffd2a1323b84e661b1a85382ae54b09695b827 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:54:23 +0800 Subject: [PATCH] Develop fastapi (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add side panel with navigation and storage cleaner functionality * feat: add OpenUrlPage and integrate into app navigation * feat: 添加 GlobalSnackbar 组件并在多个页面中集成,替换原有 Snackbar 实现 * feat: fix OpenUrl sidebar issue with architecture refactor - 修复原问题:不再直接替换侧边栏 URL,保持插件导航可见 - 采用配置页 + 查看页分离架构:OpenUrlPage (配置) + OpenUrlViewerPage (查看) - 支持多个 URL 快捷方式管理(添加/删除) - 每个 URL 提供两种打开方式:在侧边栏打开 / 在新标签页打开 - 侧边栏查看页使用 iframe 占满全部剩余空间 - 更新 TypeScript 类型定义 - 保留原有混合内容警告检查 - 数据持久化到 Chrome Storage * refactor: centralized route management - consolidate routing config into single source * feat: 更换logo * refactor: code review fixes - security and race condition improvements Co-Authored-By: Claude Opus 4.6 Changes: - wxt.config.ts: Remove unused `debugger` permission (no code uses it) - OpenUrlPage.tsx: Fix unreachable showMessage after window.close() - OpenUrlPage.tsx: Replace unnecessary div wrapper with Fragment to reduce DOM nesting - OpenUrlViewerPage.tsx: Add URL validation to prevent XSS via javascript:/data: URLs - OpenUrlViewerPage.tsx: Add sandbox attribute to iframe for security isolation - OpenUrlViewerPage.tsx: Add error handling for invalid URLs - StorageCleanerPage.tsx: Fix race condition in handleOptionChange preference saving - StorageCleanerPage.tsx: Remove unnecessary storage reads when saving preferences (use state directly) - StorageCleanerPage.tsx: Add timeout cleanup for setTimeout to follow React best practices * feat: implement drill-down navigation with master-detail dashboard * feat: enhance dashboard dynamism and refine options UI * feat: overhaul storage cleaner UI with real-time size estimation and modern aesthetics * feat: overhaul TimestampPage UI/UX and fix GlobalSnackbar positioning * fix: decouple popup routing from storage sync and enhance OpenUrlPage UI * fix: avoid closing sidepanel when opening URL preview from within sidepanel --- components/GlobalSnackbar.tsx | 178 + components/RouterContainer.tsx | 35 + components/StorageCleanerConfirm.tsx | 81 +- components/ToolCard.tsx | 114 + components/TopBar.tsx | 78 + config/routes.ts | 58 + entrypoints/background.ts | 11 + entrypoints/options/App.tsx | 161 +- entrypoints/popup/App.css | 55 +- entrypoints/popup/App.tsx | 111 +- entrypoints/popup/pages/DashboardPage.tsx | 78 + entrypoints/popup/pages/OpenUrlPage.tsx | 403 + entrypoints/popup/pages/OpenUrlViewerPage.tsx | 91 + .../popup/pages/StorageCleanerPage.tsx | 596 +- entrypoints/popup/pages/TimestampPage.tsx | 459 +- entrypoints/sidepanel/App.css | 91 + entrypoints/sidepanel/App.tsx | 21 + entrypoints/sidepanel/index.html | 12 + entrypoints/sidepanel/main.tsx | 10 + entrypoints/sidepanel/style.css | 22 + providers/RouterProvider.tsx | 123 + public/favicon.ico | Bin 15685 -> 0 bytes public/icon/128.png | Bin 3074 -> 18307 bytes public/icon/16.png | Bin 559 -> 1376 bytes public/icon/32.png | Bin 916 -> 2593 bytes public/icon/48.png | Bin 1334 -> 4474 bytes public/icon/96.png | Bin 2366 -> 12018 bytes public/wxt.svg | 9538 ++++++++++++++++- tsconfig.json | 19 +- types/storage.d.ts | 13 +- utils/chromeStorage.ts | 2 +- utils/storageCleaner.ts | 47 +- vite-env.d.ts | 1 + wxt.config.ts | 11 +- 34 files changed, 11680 insertions(+), 739 deletions(-) create mode 100644 components/GlobalSnackbar.tsx create mode 100644 components/RouterContainer.tsx create mode 100644 components/ToolCard.tsx create mode 100644 components/TopBar.tsx create mode 100644 config/routes.ts create mode 100644 entrypoints/popup/pages/DashboardPage.tsx create mode 100644 entrypoints/popup/pages/OpenUrlPage.tsx create mode 100644 entrypoints/popup/pages/OpenUrlViewerPage.tsx create mode 100644 entrypoints/sidepanel/App.css create mode 100644 entrypoints/sidepanel/App.tsx create mode 100644 entrypoints/sidepanel/index.html create mode 100644 entrypoints/sidepanel/main.tsx create mode 100644 entrypoints/sidepanel/style.css create mode 100644 providers/RouterProvider.tsx delete mode 100644 public/favicon.ico create mode 100644 vite-env.d.ts diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx new file mode 100644 index 0000000..11e3840 --- /dev/null +++ b/components/GlobalSnackbar.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react'; +import { Snackbar, Alert, type SxProps, type Theme, alpha } from '@mui/material'; + +export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error'; + +export interface GlobalSnackbarProps { + /** 消息内容 */ + message: string; + /** 是否显示 */ + open: boolean; + /** 关闭回调 */ + onClose: () => void; + /** 消息级别:影响颜色 */ + severity?: SnackbarSeverity; + /** 自动隐藏时间,毫秒,0 不自动关闭 */ + autoHideDuration?: number; + /** 弹出位置 */ + anchorOrigin?: { + vertical: 'top' | 'bottom'; + horizontal: 'left' | 'center' | 'right'; + }; + /** 是否使用 Alert 包裹(false 则使用原生 Snackbar message) */ + showAlert?: boolean; + /** Alert 是否隐藏图标 */ + hideIcon?: boolean; + /** 自定义样式,透传给 Snackbar */ + sx?: SxProps; + /** 自定义样式,透传给 Alert(仅当 showAlert=true 时生效) */ + alertSx?: SxProps; +} + +export interface SnackbarOptions { + severity?: SnackbarSeverity; + autoHideDuration?: number; + hideIcon?: boolean; + showAlert?: boolean; +} + +export interface UseSnackbarResult { + snackbarProps: GlobalSnackbarProps; + showMessage: (message: string, options?: SnackbarOptions) => void; + closeMessage: () => void; +} + +const defaultProps: Required< + Pick< + GlobalSnackbarProps, + 'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon' + > +> = { + severity: 'info', + autoHideDuration: 2000, + anchorOrigin: { vertical: 'bottom', horizontal: 'center' }, + showAlert: true, + hideIcon: false, +}; + +export function GlobalSnackbar({ + message, + open, + onClose, + severity = defaultProps.severity, + autoHideDuration = defaultProps.autoHideDuration, + anchorOrigin = defaultProps.anchorOrigin, + showAlert = defaultProps.showAlert, + hideIcon = defaultProps.hideIcon, + sx, + alertSx, +}: GlobalSnackbarProps) { + // 共享的固定定位样式 + const fixedSx: SxProps = { + position: 'fixed', + bottom: '24px !important', // 固定在视口底部 + left: '50% !important', + transform: 'translateX(-50%) !important', + zIndex: (theme) => theme.zIndex.tooltip + 100, + maxWidth: '90%', + width: 'max-content', + }; + + if (showAlert) { + return ( + + `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, + + '& .MuiAlert-icon': { + mr: 0.5, + fontSize: '1.1rem', + color: '#fff' + }, + '& .MuiAlert-message': { + color: '#fff', + padding: '6px 0', + textAlign: 'center' + } + }, + ...(Array.isArray(alertSx) ? alertSx : [alertSx]), + ]} + > + {message} + + + ); + } + + return ( + + ); +} + +export function useSnackbar(initialOptions?: SnackbarOptions): UseSnackbarResult { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(''); + const [options, setOptions] = useState(initialOptions || {}); + + const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => { + setMessage(newMessage); + setOptions({ ...initialOptions, ...newOptions }); + setOpen(true); + }; + + const closeMessage = () => { + setOpen(false); + }; + + const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') return; + closeMessage(); + }; + + const snackbarProps: GlobalSnackbarProps = { + message, + open, + onClose: handleClose, + severity: options.severity, + autoHideDuration: options.autoHideDuration, + hideIcon: options.hideIcon, + }; + + return { + snackbarProps, + showMessage, + closeMessage, + }; +} + +export default GlobalSnackbar; diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx new file mode 100644 index 0000000..0859fc9 --- /dev/null +++ b/components/RouterContainer.tsx @@ -0,0 +1,35 @@ +import { Box } from '@mui/material'; +import { ROUTES } from '@/config/routes'; +import { useRouter } from '@/providers/RouterProvider'; +import { useMemo } from 'react'; + +export default function RouterContainer() { + const { currentPage, isLoaded } = useRouter(); + + const animationClass = useMemo(() => { + return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; + }, [currentPage]); + + if (!isLoaded) { + return
Loading...
; + } + + const currentRoute = ROUTES.find(route => route.key === currentPage); + + return ( + + {currentRoute && } + + ); +} diff --git a/components/StorageCleanerConfirm.tsx b/components/StorageCleanerConfirm.tsx index 14da382..dc98c53 100644 --- a/components/StorageCleanerConfirm.tsx +++ b/components/StorageCleanerConfirm.tsx @@ -5,6 +5,7 @@ import { DialogActions, Typography, Box, + Chip, } from '@mui/material'; import type { StorageCleanerOptions } from '@/types/storage'; import Button from '@/components/Button'; @@ -22,6 +23,10 @@ export function StorageCleanerConfirm({ onConfirm, options, }: StorageCleanerConfirmProps) { + const selectedOptions = Object.entries(options) + .filter(([_, value]) => value) + .map(([key, _]) => key); + return ( - - 确认清理 + + 确认清理数据? + - - 将清理以下存储类型: + + 您将清除当前页面的选定存储项。 - - {options.localStorage && - localStorage} - {options.sessionStorage && - sessionStorage} - {options.indexedDB && - IndexedDB} - {options.cookies && - Cookies} - {options.cacheStorage && - Cache Storage} - {options.serviceWorkers && - Service Workers} + + + {selectedOptions.map((opt) => ( + + ))} - - 此操作不可撤销。 + + + ⚠️ 此操作不可撤销 - - - diff --git a/components/ToolCard.tsx b/components/ToolCard.tsx new file mode 100644 index 0000000..572370e --- /dev/null +++ b/components/ToolCard.tsx @@ -0,0 +1,114 @@ +import { Box, Typography, Stack } from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import React from 'react'; + +interface ToolCardProps { + title: string; + description?: string; + snapshot?: React.ReactNode; + colorCode: string; + icon: React.ReactNode; + onClick: () => void; + hasAI?: boolean; +} + +export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI }: ToolCardProps) { + return ( + + + + + {icon} + + + + {title} + {hasAI && } + + {description && ( + + {description} + + )} + + + + + + {snapshot && ( + + {snapshot} + + )} + + ); +} diff --git a/components/TopBar.tsx b/components/TopBar.tsx new file mode 100644 index 0000000..e1287eb --- /dev/null +++ b/components/TopBar.tsx @@ -0,0 +1,78 @@ +import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import { useRouter } from '@/providers/RouterProvider'; + +export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { + const { currentPage, goBack } = useRouter(); + + const handleDetach = () => { + // 弹出脱离窗口 (以独立面板形式打开当前 URL) + chrome.windows.create({ + url: window.location.href, + type: 'panel', + width: 420, + height: 600 + }); + }; + + const isDashboard = currentPage === 'dashboard'; + + return ( + + + {!isDashboard && ( + + + + )} + + + + Testing Tools + + + + + + + + + + + + + + + + ); +} diff --git a/config/routes.ts b/config/routes.ts new file mode 100644 index 0000000..6490ae9 --- /dev/null +++ b/config/routes.ts @@ -0,0 +1,58 @@ +import type { PageType } from '@/types/storage'; +import DashboardPage from '@/entrypoints/popup/pages/DashboardPage'; +import TimestampPage from '@/entrypoints/popup/pages/TimestampPage'; +import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; +import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage'; +import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; + +export interface RouteConfig { + key: PageType; + label: string; + defaultVisible: boolean; + component: React.ComponentType; +} + +export const ROUTES: RouteConfig[] = [ + { + key: 'dashboard', + label: 'Dashboard', + defaultVisible: true, + component: DashboardPage, + }, + { + key: 'timestamp', + label: '时间戳', + defaultVisible: true, + component: TimestampPage, + }, + { + key: 'storageCleaner', + label: '存储清理', + defaultVisible: true, + component: StorageCleanerPage, + }, + { + key: 'openUrl', + label: 'Open Url', + defaultVisible: true, + component: OpenUrlPage, + }, + { + key: 'openUrlViewer', + label: '查看', + defaultVisible: false, + component: OpenUrlViewerPage, + }, +]; + +export function getRouteByKey(key: PageType): RouteConfig | undefined { + return ROUTES.find(route => route.key === key); +} + +export function getDefaultVisibleRoutes(): PageType[] { + return ROUTES.filter(route => route.defaultVisible).map(route => route.key); +} + +export function getAllRouteKeys(): PageType[] { + return ROUTES.map(route => route.key); +} diff --git a/entrypoints/background.ts b/entrypoints/background.ts index ceeb396..ca0b70a 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -2,6 +2,17 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; export default defineBackground(() => { + // 监听扩展图标点击事件,打开侧边栏 + browser.action.onClicked.addListener(async (tab) => { + if (tab.id) { + try { + await browser.sidePanel.open({ tabId: tab.id }); + } catch (err) { + console.error('Failed to open side panel:', err); + } + } + }); + // 监听扩展安装或更新事件 browser.runtime.onInstalled.addListener(async ({ reason }) => { if (reason === 'install') { diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 2a77c9a..8b7c235 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -3,21 +3,17 @@ import { Box, Typography, Paper, - FormControlLabel, Switch, Button, Snackbar, Alert, CircularProgress, + Stack, } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import type { PageType } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; - -const PAGE_CONFIG = { - timestamp: { label: '时间戳', defaultVisible: true }, - storageCleaner: { label: '存储清理', defaultVisible: true }, -} as const satisfies Record; +import { ROUTES } from '@/config/routes'; function App() { const [visiblePages, setVisiblePages] = useState([]); @@ -34,12 +30,12 @@ function App() { const saved = await storageUtil.get('app/visiblePages', [ 'timestamp', 'storageCleaner', + 'openUrl', ] as PageType[]); - // Ensure we always have an array - setVisiblePages(saved ?? ['timestamp', 'storageCleaner']); + setVisiblePages(saved ?? ['timestamp', 'storageCleaner', 'openUrl']); } catch (error) { console.error('Failed to load config:', error); - setVisiblePages(['timestamp', 'storageCleaner']); + setVisiblePages(['timestamp', 'storageCleaner', 'openUrl']); } finally { setIsLoaded(true); } @@ -50,7 +46,6 @@ function App() { let newPages: PageType[]; if (isCurrentlyVisible) { - // 尝试隐藏,但至少保留一个 if (visiblePages.length <= 1) { showToast('至少需要保留一个可见页面', 'warning'); return; @@ -63,27 +58,23 @@ function App() { try { await storageUtil.set('app/visiblePages', newPages); setVisiblePages(newPages); - showToast( - `已${isCurrentlyVisible ? '隐藏' : '显示'} ${PAGE_CONFIG[page].label}`, - 'success', - ); + const route = ROUTES.find((r) => r.key === page); + showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${route?.label || page}`, 'success'); } catch (error) { console.error('Failed to save config:', error); - showToast('保存失败,请重试', 'warning'); + showToast('保存失败', 'warning'); } }; const handleRestoreDefaults = async () => { try { - const defaults = (Object.keys(PAGE_CONFIG) as PageType[]).filter( - (key) => PAGE_CONFIG[key].defaultVisible, - ); + const defaults = ROUTES.filter((route) => route.defaultVisible).map((route) => route.key); await storageUtil.set('app/visiblePages', defaults); setVisiblePages(defaults); - showToast('已恢复默认设置', 'success'); + showToast('已恢复默认', 'success'); } catch (error) { console.error('Failed to restore defaults:', error); - showToast('恢复失败,请重试', 'warning'); + showToast('恢复失败', 'warning'); } }; @@ -92,82 +83,82 @@ function App() { setToastSeverity(severity); }; - const handleCloseToast = () => { - setToast(null); - }; + const handleCloseToast = () => setToast(null); if (!isLoaded) { return ( - + ); } return ( - - - ⚙️ 扩展设置 - - - - 自定义 popup 弹窗中显示的功能页面。更改将立即生效,无需保存。 - - - - - 可见页面设置 - - - {(Object.keys(PAGE_CONFIG) as PageType[]).map((pageKey) => { - const config = PAGE_CONFIG[pageKey]; - const isChecked = visiblePages.includes(pageKey); - const isDisabled = !isChecked && visiblePages.length === 1; - - return ( - handlePageToggle(pageKey)} - disabled={isDisabled} - color="primary" - /> - } - label={config.label} - sx={{ - width: '100%', - mb: 1, - '&:last-child': { mb: 0 }, - }} - /> - ); - })} - - - + + - + + + + + {ROUTES.filter((route) => route.key !== 'dashboard' && route.key !== 'openUrlViewer').map( + (route, index, array) => { + const isChecked = visiblePages.includes(route.key); + const isDisabled = isChecked && visiblePages.length === 1; + + return ( + + + + {route.label} + + + {isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'} + + + handlePageToggle(route.key)} + disabled={isDisabled} + /> + + ); + }, + )} + + {toast} - - - - 提示:更改将立即应用到 popup 弹窗。如需查看效果,请关闭并重新打开扩展弹窗。 - - ); } diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index 1390b0e..33dddb5 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -61,54 +61,31 @@ body, overflow: hidden; /* 内部由 flex 子项控制滚动 */ } +/* Animations */ +@keyframes slideInRight { + from { + transform: translateX(30px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + @keyframes fadeIn { from { opacity: 0; - transform: translateY(-5px); } to { opacity: 1; - transform: translateY(0); } } -/* 导航容器 */ -.nav-container { - display: flex; - justify-content: center; - padding-top: 16px; - padding-bottom: 16px; - gap: 8px; - flex-shrink: 0; /* 禁止导航栏被挤压缩放 */ - background-color: #fff; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); +.page-transition-enter { + animation: slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; } -/* 导航按钮 */ -.nav-button { - padding: 8px 16px; - border: 1px solid var(--btn-bg); - border-radius: var(--radius); - background: transparent; - color: var(--btn-text); - cursor: pointer; - transition: all 0.2s ease; - font-size: 14px; -} - -.nav-button:hover { - background: var(--btn-bg); -} - -.nav-button.active { - background: var(--btn-bg); - font-weight: 500; -} - -.nav-button.settings-button { - padding: 8px 12px; - min-width: 36px; - display: flex; - align-items: center; - justify-content: center; +.page-transition-dashboard { + animation: fadeIn 0.3s ease-out forwards; } diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index c208d03..b45eacf 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,113 +1,20 @@ -import { useState, useEffect } from 'react'; -import { Box } from '@mui/material'; -import SettingsIcon from '@mui/icons-material/Settings'; -import type { PageType } from '@/types/storage'; -import { storageUtil } from '@/utils/chromeStorage'; -import TimestampPage from './pages/TimestampPage'; -import StorageCleanerPage from './pages/StorageCleanerPage'; import './App.css'; - - -const PAGE_CONFIG = { - timestamp: { label: '时间戳', defaultVisible: true }, - storageCleaner: { label: '存储清理', defaultVisible: true }, -} as const satisfies Record; +import RouterProvider from '@/providers/RouterProvider'; +import TopBar from '@/components/TopBar'; +import RouterContainer from '@/components/RouterContainer'; function App() { - const [currentPage, setCurrentPage] = useState('timestamp'); - const [visiblePages, setVisiblePages] = useState(['timestamp', 'storageCleaner']); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - loadInitialData(); - }, []); - - useEffect(() => { - if (isLoaded) { - storageUtil.set('app/currentRoute', currentPage); - } - }, [currentPage, isLoaded]); - - const loadInitialData = async () => { - try { - const [savedRoute, savedVisiblePages] = await Promise.all([ - storageUtil.get('app/currentRoute', 'timestamp'), - storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]), - ]); - - if (savedRoute) { - setCurrentPage(savedRoute); - } - - if (savedVisiblePages) { - setVisiblePages(savedVisiblePages); - } - } catch (error) { - console.error('Failed to load initial data:', error); - } finally { - setIsLoaded(true); - } - }; - - const handlePageChange = (page: PageType) => { - setCurrentPage(page); - }; - const handleOpenOptions = () => { chrome.runtime.openOptionsPage(); }; - const NavButton = ({ pageKey }: { pageKey: PageType }) => { - const config = PAGE_CONFIG[pageKey]; - if (!config) return null; - - return ( - - - - ); - }; - - if (!isLoaded) { - return
Loading...
; - } - return ( -
- - {(Object.keys(PAGE_CONFIG) as PageType[]) - .filter((key) => visiblePages.includes(key)) - .map((key) => )} - - - - - - {/* 统一滚动容器 */} - - {currentPage === 'timestamp' && } - {currentPage === 'storageCleaner' && } - -
+ +
+ + +
+
); } diff --git a/entrypoints/popup/pages/DashboardPage.tsx b/entrypoints/popup/pages/DashboardPage.tsx new file mode 100644 index 0000000..89c7da9 --- /dev/null +++ b/entrypoints/popup/pages/DashboardPage.tsx @@ -0,0 +1,78 @@ +import { Box, Typography, Container } from '@mui/material'; +import { useRouter } from '@/providers/RouterProvider'; +import ToolCard from '@/components/ToolCard'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import StorageIcon from '@mui/icons-material/Storage'; +import LanguageIcon from '@mui/icons-material/Language'; +import type { PageType } from '@/types/storage'; +import { useEffect, useState } from 'react'; +import dayjs from '@/utils/dayjs'; + +export default function DashboardPage() { + const { navigateTo, visiblePages } = useRouter(); + const [now, setNow] = useState(dayjs()); + + useEffect(() => { + const timer = setInterval(() => setNow(dayjs()), 1000); + return () => clearInterval(timer); + }, []); + + const isVisible = (key: string) => visiblePages.includes(key as PageType); + + return ( + + + + {isVisible('timestamp') && ( + } + onClick={() => navigateTo('timestamp')} + snapshot={ + + + {now.valueOf()} + + + {now.format('HH:mm:ss')} + + + } + /> + )} + + {isVisible('storageCleaner') && ( + } + onClick={() => navigateTo('storageCleaner')} + /> + )} + + {isVisible('openUrl') && ( + } + onClick={() => navigateTo('openUrl')} + /> + )} + + + + ); +} diff --git a/entrypoints/popup/pages/OpenUrlPage.tsx b/entrypoints/popup/pages/OpenUrlPage.tsx new file mode 100644 index 0000000..6bccd83 --- /dev/null +++ b/entrypoints/popup/pages/OpenUrlPage.tsx @@ -0,0 +1,403 @@ +import { useState, useEffect, useCallback, Fragment } from 'react'; +import { + Box, + TextField, + Alert, + List, + ListItem, + IconButton, + Typography, + Divider, + Container, + Stack, + alpha, + Theme, + Tooltip, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import AddIcon from '@mui/icons-material/Add'; +import LanguageIcon from '@mui/icons-material/Language'; +import LinkIcon from '@mui/icons-material/Link'; +import Button from '@/components/Button'; +import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; +import { storageUtil } from '@/utils/chromeStorage'; +import { useRouter } from '@/providers/RouterProvider'; +import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage'; + +const THEME_COLOR = '#9c27b0'; + +const INPUT_STYLE = { + '& .MuiOutlinedInput-root': { + bgcolor: 'background.paper', + borderRadius: 3.5, + border: '1px solid', + borderColor: 'grey.100', + transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', + '& fieldset': { border: 'none' }, + '&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' }, + '&.Mui-focused': { + bgcolor: '#fff', + borderColor: THEME_COLOR, + boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLOR, 0.1)}`, + }, + }, + '& .MuiInputBase-input': { + py: 1.2, + px: 2, + fontSize: '0.85rem', + fontWeight: 600, + }, + '& .MuiInputLabel-root': { + fontSize: '0.85rem', + fontWeight: 700, + color: 'text.secondary', + mb: 0.5, + '&.Mui-focused': { color: THEME_COLOR }, + }, +}; + +const DEFAULT_PREFERENCES: OpenUrlPreferences = { + entries: [], +}; + +export default function OpenUrlPage() { + const [entries, setEntries] = useState(DEFAULT_PREFERENCES.entries); + const [newName, setNewName] = useState(''); + const [newUrl, setNewUrl] = useState(''); + const [isLoaded, setIsLoaded] = useState(false); + const { snackbarProps, showMessage } = useSnackbar(); + const { syncNavigation } = useRouter(); + + const showMixedContentWarning = + newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1'); + + const isValidUrl = (url: string) => { + if (!url.trim()) return false; + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + useEffect(() => { + const loadPreferences = async () => { + try { + const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES); + if (saved && saved.entries) { + setEntries(saved.entries); + } + } catch (error) { + console.error('Failed to load Open Url preferences:', error); + } finally { + setIsLoaded(true); + } + }; + loadPreferences(); + }, []); + + const savePreferences = useCallback(() => { + const preferences: OpenUrlPreferences = { entries }; + storageUtil.set('openUrl/preferences', preferences).catch((error) => { + console.error('Failed to save Open Url preferences:', error); + }); + }, [entries]); + + useEffect(() => { + if (!isLoaded) return; + const timer = setTimeout(() => { + savePreferences(); + }, 500); + return () => clearTimeout(timer); + }, [entries, isLoaded, savePreferences]); + + const handleAddEntry = () => { + if (!newName.trim()) { + showMessage('请输入名称', { severity: 'error' }); + return; + } + if (!isValidUrl(newUrl)) { + showMessage('请输入有效的 URL', { severity: 'error' }); + return; + } + + setEntries([...entries, { name: newName.trim(), url: newUrl.trim() }]); + setNewName(''); + setNewUrl(''); + showMessage('添加成功', { severity: 'success' }); + }; + + const handleDeleteEntry = (index: number) => { + const newEntries = [...entries]; + newEntries.splice(index, 1); + setEntries(newEntries); + showMessage('删除成功', { severity: 'success' }); + }; + + const handleOpenInSidebar = async (entry: OpenUrlEntry) => { + try { + await storageUtil.set('openUrl/currentUrl', entry.url); + syncNavigation('openUrlViewer'); + + const [currentTab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + const tabId = currentTab.id; + if (!tabId) { + showMessage('无法获取当前标签页', { severity: 'error' }); + return; + } + + await chrome.sidePanel.setOptions({ + tabId, + path: 'sidepanel.html', + enabled: true, + }); + await chrome.sidePanel.open({ windowId: currentTab.windowId }); + + // 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭 + if (window.location.pathname.includes('popup.html')) { + window.close(); + } + } catch (error) { + console.error('Failed to open side panel:', error); + showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' }); + } + }; + + const handleOpenInNewTab = (entry: OpenUrlEntry) => { + chrome.tabs.create({ url: entry.url }); + window.close(); + }; + + return ( + + + {/* Header */} + + + + + + + URL 实验室 + + + 多环境跳转与安全性预检 + + + + + {/* Form Section */} + + + setNewName(e.target.value)} + fullWidth + variant="outlined" + sx={INPUT_STYLE} + InputLabelProps={{ shrink: true }} + /> + setNewUrl(e.target.value)} + fullWidth + variant="outlined" + sx={INPUT_STYLE} + InputLabelProps={{ shrink: true }} + /> + + {showMixedContentWarning && ( + + 混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。 + + )} + + + + + + {/* List Section */} + + + 已保存的快捷方式 ({entries.length}) + + + {entries.length === 0 ? ( + + + + 暂无快捷方式,请在上方添加 + + + ) : ( + + {entries.map((entry, index) => ( + + + + + {entry.name} + + + {entry.url} + + + + + handleOpenInSidebar(entry)} + sx={{ + color: THEME_COLOR, + bgcolor: alpha(THEME_COLOR, 0.05), + '&:hover': { bgcolor: THEME_COLOR, color: '#fff' }, + }} + > + + + + + handleOpenInNewTab(entry)} + sx={{ + color: 'grey.500', + bgcolor: 'grey.100', + '&:hover': { bgcolor: 'grey.600', color: '#fff' }, + }} + > + + + + + handleDeleteEntry(index)} + sx={{ + color: 'error.main', + '&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) }, + }} + > + + + + + + {index < entries.length - 1 && } + + ))} + + )} + + + + + ); +} diff --git a/entrypoints/popup/pages/OpenUrlViewerPage.tsx b/entrypoints/popup/pages/OpenUrlViewerPage.tsx new file mode 100644 index 0000000..ad07ec3 --- /dev/null +++ b/entrypoints/popup/pages/OpenUrlViewerPage.tsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react'; +import { Box, Typography } from '@mui/material'; +import { storageUtil } from '@/utils/chromeStorage'; + +// 只允许 HTTP/HTTPS 协议,阻止危险协议 +const ALLOWED_PROTOCOLS = ['http:', 'https:']; + +export default function OpenUrlViewerPage() { + const [currentUrl, setCurrentUrl] = useState(''); + const [isLoaded, setIsLoaded] = useState(false); + const [error, setError] = useState(null); + + // 验证 URL 是否安全 + const validateUrl = (url: string): string | null => { + try { + const urlObj = new URL(url); + if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) { + return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`; + } + return null; + } catch { + return '无效的 URL 格式'; + } + }; + + // 从存储加载当前选中的 URL + useEffect(() => { + const loadCurrentUrl = async () => { + try { + const saved = await storageUtil.get('openUrl/currentUrl', ''); + if (saved) { + const validationError = validateUrl(saved); + if (validationError) { + setError(validationError); + } else { + setCurrentUrl(saved); + setError(null); + } + } + } catch (error) { + console.error('Failed to load current URL:', error); + setError('加载 URL 失败'); + } finally { + setIsLoaded(true); + } + }; + loadCurrentUrl(); + }, []); + + if (!isLoaded) { + return ( + + Loading... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!currentUrl) { + return ( + + + 没有选中的 URL,请先在 OpenUrl 页面选择一个 URL 打开。 + + + ); + } + + return ( + +