From 2cd21973f30d9da8c39e2b75cb01015916a2baa7 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:49:47 +0800 Subject: [PATCH] Develop (#5) * feat: enhance StorageCleanerPage with accordion for storage options and immediate preference saving * feat: add reusable Button component and update StorageCleanerPage and TimestampPage to use it * feat: add StorageCleanerConfirm component for confirmation dialog in StorageCleanerPage * feat: implement fixed height and minimal scrollbar for Popup layout --- components/Button.tsx | 32 ++++++ components/StorageCleanerConfirm.tsx | 82 ++++++++++++++ .../plans/2026-04-05-popup-layout-fix-plan.md | 32 ++++++ .../2026-04-05-popup-layout-fix-design.md | 48 ++++++++ entrypoints/popup/App.css | 69 ++++++++---- entrypoints/popup/App.tsx | 16 ++- .../popup/pages/StorageCleanerPage.tsx | 106 ++++-------------- entrypoints/popup/pages/TimestampPage.tsx | 14 +-- 8 files changed, 281 insertions(+), 118 deletions(-) create mode 100644 components/Button.tsx create mode 100644 components/StorageCleanerConfirm.tsx create mode 100644 docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md create mode 100644 docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..26d601f --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,32 @@ +import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material'; + +export type ButtonProps = MuiButtonProps; + +export function Button({ sx = [], ...props }: ButtonProps) { + return ( + + ); +} + +export default Button; diff --git a/components/StorageCleanerConfirm.tsx b/components/StorageCleanerConfirm.tsx new file mode 100644 index 0000000..14da382 --- /dev/null +++ b/components/StorageCleanerConfirm.tsx @@ -0,0 +1,82 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + Box, +} from '@mui/material'; +import type { StorageCleanerOptions } from '@/types/storage'; +import Button from '@/components/Button'; + +export interface StorageCleanerConfirmProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + options: StorageCleanerOptions; +} + +export function StorageCleanerConfirm({ + open, + onClose, + onConfirm, + options, +}: StorageCleanerConfirmProps) { + return ( + + + 确认清理 + + + + 将清理以下存储类型: + + + {options.localStorage && - localStorage} + {options.sessionStorage && - sessionStorage} + {options.indexedDB && - IndexedDB} + {options.cookies && - Cookies} + {options.cacheStorage && - Cache Storage} + {options.serviceWorkers && - Service Workers} + + + 此操作不可撤销。 + + + + + + + + ); +} + +export default StorageCleanerConfirm; diff --git a/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md b/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md new file mode 100644 index 0000000..a070d99 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md @@ -0,0 +1,32 @@ +# 实施计划:修复 Popup 布局与滚动条样式 + +## 1. 目标 + +按照设计规范实施固定高度布局与极简滚动条,确保扩展弹窗显示稳定且美观。 + +## 2. 实施步骤 + +### 2.1 CSS 核心样式更新 (`entrypoints/popup/App.css`) + +1. **视口锁定**: 更新 `html`, `body` 样式,固定 `width: 400px`, `height: 600px`。 +2. **根容器改造**: + - 将 `.app` 修改为 Flex 容器:`display: flex; flex-direction: column; height: 100%; overflow: hidden;`。 + - 移除 `margin: 0 auto;` 和 `min-height: 100%;`。 +3. **滚动条变量与全局注入**: + - 在 `:root` 中定义 `--sb-` 开头的滚动条样式变量。 + - 使用 `*::-webkit-scrollbar` 系列伪元素定义全局极简滚动条。 + +### 2.2 React 结构重构 (`entrypoints/popup/App.tsx`) + +1. **注入滚动容器**: 在 `nav-container` 之后,将所有的页面渲染(`TimestampPage`, `StorageCleanerPage`)包裹在一个统一的 `Box` 中。 +2. **设置容器样式**: 给该 `Box` 设置 `sx={{ flex: 1, overflowY: 'auto', scrollbarGutter: 'stable' }}`。 + +### 2.3 质量保障 + +1. **Lint 检测**: 运行 `npm run lint` 检查样式变量引用和 JSX 结构。 +2. **类型检查**: 运行 `npm run compile` 验证 MUI 组件属性。 + +## 3. 验证计划 + +- 手动切换导航栏,观察窗口尺寸是否维持在 600px。 +- 在“存储清理”页面展开所有选项,观察右侧是否出现极细滚动条,且不会挤压内容。 diff --git a/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md b/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md new file mode 100644 index 0000000..bdadb8b --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md @@ -0,0 +1,48 @@ +# 设计规范:Popup 弹窗固定高度与极简滚动条 + +## 1. 目标 + +解决 Chrome 扩展弹窗高度“只增不减”的布局问题,提供稳定的 600px 固定高度体验,并实现符合极简主义设计规范的可复用滚动条样式。 + +## 2. 核心架构方案 (方案 A) + +采用 **Flex 布局 + 视口锁定** 的策略,将弹窗尺寸固定在 `400px * 600px`。 + +### 2.1 容器层级设计 + +- **`html`, `body`**: 锁定为 `400px * 600px`,并设置 `overflow: hidden` 防止出现双滚动条。 +- **`.app` (根容器)**: + - 设置为 `display: flex; flex-direction: column;`。 + - 高度撑满父级 (`100%`)。 + - 锁定 `overflow: hidden`。 +- **`nav-container` (导航栏)**: + - 位于顶部,高度固定,不参与 Flex 缩放。 +- **内容展示区 (Page Container)**: + - 设置 `flex: 1`,自动填充剩余空间。 + - 设置 `overflow-y: auto`,启用局部滚动。 + - 设置 `scrollbar-gutter: stable`,预留滚动条空间,防止布局抖动。 + +## 3. 极简滚动条设计规范 + +为了确保在所有页面和组件中表现一致,采用全局 Webkit 伪元素定制方案。 + +### 3.1 变量定义 + +在 `:root` 中定义 CSS 变量以增强兼容性和可维护性: + +- `--sb-width`: `6px` (极细) +- `--sb-thumb-color`: `rgba(0, 0, 0, 0.1)` (浅灰色半透明) +- `--sb-thumb-hover`: `rgba(0, 0, 0, 0.18)` (悬停时微深) +- `--sb-track-color`: `transparent` (背景完全透明) + +### 3.2 样式表现 + +- **滑块 (Thumb)**: 胶囊状圆角 (`10px`)。 +- **背景剪裁 (Background Clip)**: 使用 `content-box` 结合透明边框来实现滑块与边缘的微距感。 +- **自动应用**: 使用通配符 `*::-webkit-scrollbar` 确保全局所有溢出容器均自动继承此样式。 + +## 4. 成功准则 + +- 切换“时间戳”和“存储清理”页面时,浏览器窗口大小保持 `600px` 不变。 +- 当页面内容超过视口时,右侧显示细长、半透明的滚动条。 +- 内容加载或 Accordion 展开时,页面水平方向不发生位移。 diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index 085ebb5..1390b0e 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -1,18 +1,4 @@ -/* 隐藏页面滚动条 */ -html, -body { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - scrollbar-gutter: stable; -} - -/* 隐藏body滚动条但允许滚动 */ -body { - overflow: auto; -} - +/* 极简滚动条变量 */ :root { /* 统一圆角变量 */ --radius: 8px; @@ -25,16 +11,54 @@ body { /* 统一按钮文字颜色变量 */ --btn-text: #333333; --border: 1px solid #e0e0e0; + + /* 滚动条变量 */ + --sb-width: 6px; + --sb-thumb-color: rgba(0, 0, 0, 0.1); + --sb-thumb-hover: rgba(0, 0, 0, 0.2); + --sb-track-color: transparent; +} + +/* 强制固定视口尺寸 */ +html, +body, +#root { + margin: 0; + padding: 0; + width: 400px; + height: 600px; + overflow: hidden; /* 禁用外层滚动 */ +} + +/* 全局极简滚动条定制 */ +*::-webkit-scrollbar { + width: var(--sb-width); +} + +*::-webkit-scrollbar-track { + background: var(--sb-track-color); +} + +*::-webkit-scrollbar-thumb { + background: var(--sb-thumb-color); + border-radius: 10px; + background-clip: content-box; + border: 1px solid transparent; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--sb-thumb-hover); } .app { - width: 400px; - min-height: 100%; - margin: 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - overflow-x: hidden; + overflow: hidden; /* 内部由 flex 子项控制滚动 */ } @keyframes fadeIn { @@ -52,9 +76,12 @@ body { .nav-container { display: flex; justify-content: center; - margin-top: 16px; - margin-bottom: 16px; + padding-top: 16px; + padding-bottom: 16px; gap: 8px; + flex-shrink: 0; /* 禁止导航栏被挤压缩放 */ + background-color: #fff; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); } /* 导航按钮 */ diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 3050ccb..c208d03 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -93,8 +93,20 @@ function App() { - {currentPage === 'timestamp' && } - {currentPage === 'storageCleaner' && } + + {/* 统一滚动容器 */} + + {currentPage === 'timestamp' && } + {currentPage === 'storageCleaner' && } + ); } diff --git a/entrypoints/popup/pages/StorageCleanerPage.tsx b/entrypoints/popup/pages/StorageCleanerPage.tsx index 0c0a477..7e9a448 100644 --- a/entrypoints/popup/pages/StorageCleanerPage.tsx +++ b/entrypoints/popup/pages/StorageCleanerPage.tsx @@ -4,7 +4,6 @@ import { Typography, Box, Checkbox, - Button, FormControlLabel, Alert, Snackbar, @@ -16,8 +15,14 @@ import { import RefreshIcon from '@mui/icons-material/Refresh'; import WarningIcon from '@mui/icons-material/Warning'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Button from '@/components/Button'; +import StorageCleanerConfirm from '@/components/StorageCleanerConfirm'; import { storageUtil } from '@/utils/chromeStorage'; -import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage'; +import type { + StorageCleanerOptions, + CleaningResult, + StorageCleanerPreferences, +} from 'types/storage'; import { getCurrentTab, isRestrictedUrl, @@ -70,10 +75,7 @@ export default function StorageCleanerPage() { setDomain(new URL(tab.url).hostname); // Load user preferences - const prefs = await storageUtil.get( - 'storageCleaner/preferences', - DEFAULT_PREFERENCES, - ); + const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES); setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh); setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes); }; @@ -84,10 +86,7 @@ export default function StorageCleanerPage() { const handleAutoRefreshChange = useCallback(async (checked: boolean) => { setAutoRefresh(checked); // Save preference immediately - const prefs = await storageUtil.get( - 'storageCleaner/preferences', - DEFAULT_PREFERENCES, - ); + const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES); await storageUtil.set('storageCleaner/preferences', { ...(prefs || DEFAULT_PREFERENCES), autoRefresh: checked, @@ -98,13 +97,12 @@ export default function StorageCleanerPage() { setOptions((prev) => { const newOptions = { ...prev, [key]: !prev[key] }; // Save options immediately - storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES) - .then(prefs => { - storageUtil.set('storageCleaner/preferences', { - ...(prefs || DEFAULT_PREFERENCES), - selectedTypes: newOptions, - }); + storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES).then((prefs) => { + storageUtil.set('storageCleaner/preferences', { + ...(prefs || DEFAULT_PREFERENCES), + selectedTypes: newOptions, }); + }); return newOptions; }); }, []); @@ -122,12 +120,9 @@ export default function StorageCleanerPage() { serviceWorkers: checked, }; setOptions(newOptions); - + // Save options immediately - const prefs = await storageUtil.get( - 'storageCleaner/preferences', - DEFAULT_PREFERENCES, - ); + const prefs = await storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES); await storageUtil.set('storageCleaner/preferences', { ...(prefs || DEFAULT_PREFERENCES), selectedTypes: newOptions, @@ -193,9 +188,6 @@ export default function StorageCleanerPage() { {/* Header */} - - 存储清理 - 当前页面: {domain || '加载中...'} @@ -327,10 +319,8 @@ export default function StorageCleanerPage() { variant="contained" onClick={() => setShowConfirm(true)} sx={{ - py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none', - bgcolor: 'primary.main', transition: 'all 0.2s', - '&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' }, - '&:active': { transform: 'translateY(0)' } + bgcolor: 'primary.main', + '&:hover': { bgcolor: 'primary.dark' }, }} disabled={loading} fullWidth @@ -362,60 +352,12 @@ export default function StorageCleanerPage() { )} {/* Confirmation Dialog */} - {showConfirm && ( - - 确认清理 - - 将清理以下存储类型: - - - {options.localStorage && ( - - localStorage - )} - {options.sessionStorage && ( - - sessionStorage - )} - {options.indexedDB && - IndexedDB} - {options.cookies && - Cookies} - {options.cacheStorage && ( - - Cache Storage - )} - {options.serviceWorkers && ( - - Service Workers - )} - - - 此操作不可撤销。 - - - - - - - )} + setShowConfirm(false)} + onConfirm={handleClean} + options={options} + /> {/* Snackbar */} { setMode(m); setError(''); setResult(''); }} - sx={{ - position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none', - fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s', - color: mode === m ? 'text.primary' : 'text.disabled', - '&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' }, - }} > {m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'} @@ -384,12 +378,6 @@ export default function TimestampPage() {