Compare commits
4 Commits
edd4cf266c
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| a5d86a92c2 | |||
| c992986789 | |||
| 70b799aa2d | |||
| 58af2af37b |
@@ -4,8 +4,10 @@ npx tsc --noEmit
|
|||||||
|
|
||||||
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
|
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
|
||||||
# 新分支无 upstream 时,回退到与 origin/main 对比
|
# 新分支无 upstream 时,回退到与 origin/main 对比
|
||||||
BASE=$(git rev-parse --verify @{upstream} 2>/dev/null && echo "@{upstream}" || echo "origin/main")
|
MERGE_BASE=$(git merge-base HEAD @{upstream} 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null)
|
||||||
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$BASE"...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
|
if [ -n "$MERGE_BASE" ]; then
|
||||||
if [ -n "$CHANGED_FILES" ]; then
|
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
|
||||||
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
|
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ src/pages/FeatureName/
|
|||||||
- 图标: 使用 `lucide-react` 图标库
|
- 图标: 使用 `lucide-react` 图标库
|
||||||
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
|
- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述`、`feat(功能名): 描述`)
|
||||||
|
|
||||||
## 关键外部库(非显而易见的)
|
## 关键外部库(非显而易见的)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
||||||
| `icon/96.png` | 96×96 图标 |
|
| `icon/96.png` | 96×96 图标 |
|
||||||
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
||||||
| `wxt.svg` | WXT 框架标志 SVG 图标 |
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -53,6 +53,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title={tooltip ?? t('buttons.copy')}
|
title={tooltip ?? t('buttons.copy')}
|
||||||
|
aria-label={tooltip ?? t('buttons.copy')}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant, size }),
|
buttonVariants({ variant, size }),
|
||||||
copied &&
|
copied &&
|
||||||
@@ -62,7 +63,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
<Check className="h-[1.2em] w-[1.2em]" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// unmock the globally-mocked component so we test the real implementation
|
||||||
|
vi.unmock('@/components/CopyButton');
|
||||||
|
|
||||||
|
vi.mock('@/utils/clipboard', () => ({
|
||||||
|
copyTextToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const mockedCopy = vi.mocked(copyTextToClipboard);
|
||||||
|
const mockedToast = vi.mocked(toast);
|
||||||
|
|
||||||
|
describe('CopyButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="hello world" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('hello world');
|
||||||
|
expect(mockedToast.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功后图标切换为 Check,1.5 秒后恢复', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="test" />);
|
||||||
|
|
||||||
|
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).toContain('text-emerald');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1.5 秒后样式恢复
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制空文本时弹出 error toast', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).not.toHaveBeenCalled();
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时弹出 error toast', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(false);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="something" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('something');
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 新增测试 ====================
|
||||||
|
|
||||||
|
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
|
||||||
|
render(<CopyButton text="initial" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
|
||||||
|
expect(button).toHaveAttribute('aria-label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
|
||||||
|
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('title', '自定义提示');
|
||||||
|
expect(button).toHaveAttribute('aria-label', '自定义提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('className 被正确透传到按钮', () => {
|
||||||
|
render(<CopyButton text="class-test" className="my-custom-class" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).toContain('my-custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击事件阻止冒泡', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
const parentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<CopyButton text="stop-propagation" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalled();
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
const { unmount } = render(<CopyButton text="unmount-test" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
// 在 1.5 秒超时到期前卸载组件
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
|
||||||
|
// 前进剩余时间,确认没有异常
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快速连续点击不会创建多个重叠定时器', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="rapid-click" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
// 快速点击 3 次
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
|
||||||
|
expect(mockedCopy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
|
||||||
|
// advance 1.5 秒后,copied 状态应恢复为 false
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 button props 通过 ...props 透传', () => {
|
||||||
|
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('data-testid', 'copy-btn');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute('id', 'copy-button-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// unmock the globally-mocked component so we test the real implementation
|
||||||
|
vi.unmock('@/components/CopyButton');
|
||||||
|
|
||||||
|
vi.mock('@/utils/clipboard', () => ({
|
||||||
|
copyTextToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const mockedCopy = vi.mocked(copyTextToClipboard);
|
||||||
|
const mockedToast = vi.mocked(toast);
|
||||||
|
|
||||||
|
describe('CopyButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="hello world" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('hello world');
|
||||||
|
expect(mockedToast.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功后图标切换为 Check,1.5 秒后恢复', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="test" />);
|
||||||
|
|
||||||
|
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).toContain('text-emerald');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1.5 秒后样式恢复
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制空文本时弹出 error toast', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).not.toHaveBeenCalled();
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时弹出 error toast', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(false);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="something" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('something');
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 新增测试 ====================
|
||||||
|
|
||||||
|
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
|
||||||
|
render(<CopyButton text="initial" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
|
||||||
|
expect(button).toHaveAttribute('aria-label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
|
||||||
|
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('title', '自定义提示');
|
||||||
|
expect(button).toHaveAttribute('aria-label', '自定义提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('className 被正确透传到按钮', () => {
|
||||||
|
render(<CopyButton text="class-test" className="my-custom-class" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).toContain('my-custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击事件阻止冒泡', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
const parentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<CopyButton text="stop-propagation" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalled();
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
const { unmount } = render(<CopyButton text="unmount-test" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
// 在 1.5 秒超时到期前卸载组件
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
|
||||||
|
// 前进剩余时间,确认没有异常
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快速连续点击不会创建多个重叠定时器', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="rapid-click" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
// 快速点击 3 次
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
|
||||||
|
expect(mockedCopy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
|
||||||
|
// advance 1.5 秒后,copied 状态应恢复为 false
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 button props 通过 ...props 透传', () => {
|
||||||
|
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('data-testid', 'copy-btn');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute('id', 'copy-button-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
|
||||||
import { Clock } from 'lucide-react';
|
|
||||||
|
|
||||||
describe('ToolCard 组件', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
|
||||||
it('应渲染标题和描述', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="测试工具"
|
|
||||||
description="这是一个测试工具"
|
|
||||||
colorKey="primary"
|
|
||||||
icon={Clock}
|
|
||||||
onNavigate={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无描述时仅渲染标题', () => {
|
|
||||||
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const svgElement = container.querySelector('svg');
|
|
||||||
expect(svgElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('提供快照内容时应渲染快照', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="带快照"
|
|
||||||
colorKey="primary"
|
|
||||||
icon={Clock}
|
|
||||||
onNavigate={() => {}}
|
|
||||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('未提供快照时不渲染快照区域', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCard
|
|
||||||
title="无快照"
|
|
||||||
colorKey="primary"
|
|
||||||
icon={Clock}
|
|
||||||
onClick={() => {}}
|
|
||||||
onNavigate={function (): void {
|
|
||||||
throw new Error('Function not implemented.');
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
|
||||||
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互测试', () => {
|
|
||||||
it('点击时应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /可点击/ });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('按 Enter 键时应调用 onClick', async () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(
|
|
||||||
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
|
||||||
await act(async () => {
|
|
||||||
button.focus();
|
|
||||||
await userEvent.keyboard('{Enter}');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('样式测试', () => {
|
|
||||||
it('应应用自定义颜色代码', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const svgElement = container.querySelector('svg');
|
|
||||||
expect(svgElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Toaster as Sonner } from 'sonner';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
export function Toaster(props: ToasterProps) {
|
||||||
|
const { resolvedMode } = useThemeMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={resolvedMode}
|
||||||
|
className="toaster group"
|
||||||
|
position="bottom-center"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
|
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
|
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
|||||||
|
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
export function showPopover(
|
function showPopover(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
contentHtml: string,
|
contentHtml: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import type { ComponentType } from 'react';
|
|
||||||
import React from 'react';
|
|
||||||
import type { LucideProps } from 'lucide-react';
|
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import type { PaletteColorKey } from '@/config/features';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
|
||||||
primary: '13, 148, 136', // teal
|
|
||||||
success: '22, 163, 74', // green
|
|
||||||
warning: '217, 119, 6', // amber (存储清理的橙色轴)
|
|
||||||
error: '220, 38, 38', // red
|
|
||||||
secondary: '147, 51, 232',
|
|
||||||
info: '37, 99, 235', // blue
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ToolCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
snapshot?: React.ReactNode;
|
|
||||||
colorKey: PaletteColorKey;
|
|
||||||
icon: ComponentType<LucideProps>;
|
|
||||||
onNavigate: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToolCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
snapshot,
|
|
||||||
colorKey,
|
|
||||||
icon: IconComponent,
|
|
||||||
onNavigate,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ToolCardProps) {
|
|
||||||
const rgbValues = PALETTE_COLORS[colorKey];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
['--tool-color' as string]: rgbValues,
|
|
||||||
}}
|
|
||||||
/* 💡 核心修复点:
|
|
||||||
- 坚决不用 h-full 或固定高度,锁死 h-auto(高度自适应流),配合 py-4 px-4 牢牢把内容包裹在卡片体内。
|
|
||||||
- 废除原先会乱飘的内联 style 属性擦写,全权放权给 Tailwind 的声明式 hover 变体。
|
|
||||||
*/
|
|
||||||
className={cn(
|
|
||||||
'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border',
|
|
||||||
'hover:bg-muted/30',
|
|
||||||
'hover:border-[rgba(var(--tool-color),0.45)]',
|
|
||||||
'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{/* 上半部分:核心信息交互排版轴 */}
|
|
||||||
<div className="flex items-center justify-between w-full relative min-w-0 min-h-[44px]">
|
|
||||||
<div className="flex gap-3 items-center min-w-0 flex-1 pr-2">
|
|
||||||
{/* 左侧圆形图标容器 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0',
|
|
||||||
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
|
|
||||||
'text-[rgb(var(--tool-color))]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<IconComponent className="h-5 w-5 shrink-0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 中间文字描述区:利用 flex-1 min-w-0 防御文本过长发生恶性撑开 */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
|
||||||
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
{description && (
|
|
||||||
<p className="text-[11px] font-medium text-muted-foreground/90 mt-0.5 leading-normal w-full truncate">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧指示小箭头 */}
|
|
||||||
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 group-hover:translate-x-0.5">
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 覆盖整个上半部分的绝对定位隐形跳转层(A11y 无障碍标准合规) */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNavigate}
|
|
||||||
aria-label={`进入 ${title}`}
|
|
||||||
className="absolute inset-0 w-full h-full cursor-pointer bg-transparent border-none opacity-0 focus-visible:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 下半部分:未来的动态预览沙箱独立承载区 */}
|
|
||||||
{snapshot != null && (
|
|
||||||
<div className="mt-1 pt-3 border-t border-dashed border-border/80 w-full relative z-10 select-text">
|
|
||||||
{snapshot}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ToolCard.displayName = 'ToolCard';
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { formatBytes } from '@/utils/format';
|
import { formatBytes } from '@/utils/format';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { validateJson } from '@/utils/jsonFormatter';
|
import { validateJson } from '@/utils/jsonFormatter';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
validateJson,
|
validateJson,
|
||||||
} from '@/utils/jsonFormatter';
|
} from '@/utils/jsonFormatter';
|
||||||
import { formatBytes } from '@/utils/format';
|
import { formatBytes } from '@/utils/format';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { stringifyJson } from '@/utils/jwt';
|
import { stringifyJson } from '@/utils/jwt';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import JwtSection from './JwtSection';
|
import JwtSection from './JwtSection';
|
||||||
import { useJwt } from './useJwt';
|
import { useJwt } from './useJwt';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import type { UnitType } from './constants';
|
import type { UnitType } from './constants';
|
||||||
import { msToUnit } from './constants';
|
import { msToUnit } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ThemeModeProvider } from './ThemeModeProvider';
|
import { ThemeModeProvider } from './ThemeModeProvider';
|
||||||
import { RouterProvider } from './RouterProvider';
|
import { RouterProvider } from './RouterProvider';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
interface AppRootProps {
|
interface AppRootProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -11,6 +12,7 @@ export default function AppRoot({ children }: AppRootProps) {
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeModeProvider>
|
<ThemeModeProvider>
|
||||||
<RouterProvider>{children}</RouterProvider>
|
<RouterProvider>{children}</RouterProvider>
|
||||||
|
<Toaster />
|
||||||
</ThemeModeProvider>
|
</ThemeModeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,101 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||||
getActiveTab,
|
|
||||||
getActiveTabDomain,
|
|
||||||
openExtensionPage,
|
|
||||||
ensureContentScriptInjected,
|
|
||||||
} from '@/utils/chromeTabs';
|
|
||||||
|
|
||||||
describe('chromeTabs', () => {
|
describe('chromeTabs', () => {
|
||||||
describe('getActiveTab', () => {
|
|
||||||
it('应该返回当前活动标签页', async () => {
|
|
||||||
const mockTab = { id: 1, url: 'https://example.com', title: 'Example' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTab();
|
|
||||||
|
|
||||||
expect(result).toEqual(mockTab);
|
|
||||||
expect(chrome.tabs.query).toHaveBeenCalledWith({ active: true, currentWindow: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当没有活动标签页时应返回 null', async () => {
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await getActiveTab();
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当查询失败时应返回 null 并记录错误', async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Permission denied'));
|
|
||||||
|
|
||||||
const result = await getActiveTab();
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getActiveTabDomain', () => {
|
|
||||||
it('应该返回当前活动标签页的域名', async () => {
|
|
||||||
const mockTab = { id: 1, url: 'https://example.com/path?query=1' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理带有端口的 URL', async () => {
|
|
||||||
const mockTab = { id: 1, url: 'https://example.com:8080/path' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当标签页没有 URL 时应返回空字符串', async () => {
|
|
||||||
const mockTab = { id: 1 } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当没有活动标签页时应返回空字符串', async () => {
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 URL 解析失败时应返回空字符串并记录错误', async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
const mockTab = { id: 1, url: 'not-a-valid-url' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('');
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('解析域名失败:', expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理 chrome-extension URL', async () => {
|
|
||||||
const mockTab = { id: 1, url: 'chrome-extension://abc123/popup.html' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await getActiveTabDomain();
|
|
||||||
|
|
||||||
expect(result).toBe('abc123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('openExtensionPage', () => {
|
describe('openExtensionPage', () => {
|
||||||
it('应该在新标签页中打开扩展页面', async () => {
|
it('应该在新标签页中打开扩展页面', async () => {
|
||||||
await openExtensionPage('popup.html');
|
await openExtensionPage('popup.html');
|
||||||
@@ -116,44 +22,4 @@ describe('chromeTabs', () => {
|
|||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureContentScriptInjected', () => {
|
|
||||||
it('当存在活动标签页时应返回 true', async () => {
|
|
||||||
const mockTab = { id: 123, url: 'https://example.com' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await ensureContentScriptInjected();
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当没有活动标签页时应返回 false', async () => {
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await ensureContentScriptInjected();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当标签页没有 id 时应返回 false', async () => {
|
|
||||||
const mockTab = { url: 'https://example.com' } as chrome.tabs.Tab;
|
|
||||||
(chrome.tabs.query as any).mockResolvedValue([mockTab]);
|
|
||||||
|
|
||||||
const result = await ensureContentScriptInjected();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当整体操作失败时应返回 false 并记录错误', async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
(chrome.tabs.query as any).mockRejectedValue(new Error('Query failed'));
|
|
||||||
|
|
||||||
const result = await ensureContentScriptInjected();
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
// getActiveTab catches the error and logs "获取活动标签页失败"
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('获取活动标签页失败:', expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { describe, expect, it, vi, beforeAll } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { copyTextToClipboard, copyImageToClipboard } from '@/utils/clipboard';
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
|
||||||
// Mock ClipboardItem for test environment
|
|
||||||
class MockClipboardItem {
|
|
||||||
constructor(public items: Record<string, Blob>) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
(globalThis as any).ClipboardItem = MockClipboardItem;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clipboard', () => {
|
describe('clipboard', () => {
|
||||||
describe('copyTextToClipboard', () => {
|
describe('copyTextToClipboard', () => {
|
||||||
@@ -31,27 +22,4 @@ describe('clipboard', () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('copyImageToClipboard', () => {
|
|
||||||
it('复制成功时应返回 true', async () => {
|
|
||||||
const write = vi.fn().mockResolvedValue(undefined);
|
|
||||||
Object.assign(navigator, { clipboard: { write } });
|
|
||||||
|
|
||||||
const blob = new Blob(['png data'], { type: 'image/png' });
|
|
||||||
const result = await copyImageToClipboard(blob);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(write).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('复制失败时应返回 false', async () => {
|
|
||||||
const write = vi.fn().mockRejectedValue(new Error('Permission denied'));
|
|
||||||
Object.assign(navigator, { clipboard: { write } });
|
|
||||||
|
|
||||||
const blob = new Blob(['png data'], { type: 'image/png' });
|
|
||||||
const result = await copyImageToClipboard(blob);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import {
|
import { useContextMenuData, saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
useContextMenuData,
|
|
||||||
saveContextMenuData,
|
|
||||||
clearContextMenuData,
|
|
||||||
} from '@/utils/useContextMenuData';
|
|
||||||
|
|
||||||
describe('useContextMenuData', () => {
|
describe('useContextMenuData', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -48,14 +44,6 @@ describe('useContextMenuData', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clearContextMenuData', () => {
|
|
||||||
it('应该从 storage 中删除数据', async () => {
|
|
||||||
await clearContextMenuData();
|
|
||||||
|
|
||||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(['contextMenu/pendingData']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useContextMenuData Hook', () => {
|
describe('useContextMenuData Hook', () => {
|
||||||
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
||||||
const mockData = {
|
const mockData = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { formatBytes } from './format';
|
|||||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
/** 支持的图像 MIME 类型 */
|
/** 支持的图像 MIME 类型 */
|
||||||
export const SUPPORTED_IMAGE_TYPES = [
|
const SUPPORTED_IMAGE_TYPES = [
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/jpg',
|
'image/jpg',
|
||||||
@@ -20,7 +20,7 @@ export const SUPPORTED_IMAGE_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 支持的图像文件扩展名 */
|
/** 支持的图像文件扩展名 */
|
||||||
export const SUPPORTED_IMAGE_EXTENSIONS = [
|
const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||||
'.png',
|
'.png',
|
||||||
'.jpg',
|
'.jpg',
|
||||||
'.jpeg',
|
'.jpeg',
|
||||||
@@ -34,7 +34,7 @@ export const SUPPORTED_IMAGE_EXTENSIONS = [
|
|||||||
/**
|
/**
|
||||||
* 文本转 Base64 编码结果
|
* 文本转 Base64 编码结果
|
||||||
*/
|
*/
|
||||||
export interface TextToBase64Result {
|
interface TextToBase64Result {
|
||||||
/** Base64 编码结果 */
|
/** Base64 编码结果 */
|
||||||
output: string;
|
output: string;
|
||||||
/** 原始字节数 */
|
/** 原始字节数 */
|
||||||
@@ -226,7 +226,7 @@ export function formatFileSize(bytes: number): string {
|
|||||||
/**
|
/**
|
||||||
* Base64 解码为二进制后的产物
|
* Base64 解码为二进制后的产物
|
||||||
*/
|
*/
|
||||||
export interface Base64ToBlobResult {
|
interface Base64ToBlobResult {
|
||||||
/** 解码后的 Blob */
|
/** 解码后的 Blob */
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
/** 推断出的 MIME 类型 */
|
/** 推断出的 MIME 类型 */
|
||||||
|
|||||||
@@ -26,12 +26,8 @@ export function useI18n(namespace?: string | string[]) {
|
|||||||
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
const namespaces = Array.isArray(namespace) ? namespace : namespace ? [namespace] : [];
|
||||||
|
|
||||||
const t = (key: string, options?: Record<string, unknown>): string => {
|
const t = (key: string, options?: Record<string, unknown>): string => {
|
||||||
let msgId = key;
|
// 统一将分隔符转换为下划线,兼容 'namespace:key.path' 和 'key.path' 两种写法
|
||||||
|
const msgId = key.replace(':', '_').replace(/\./g, '_');
|
||||||
// 处理 namespace:key 格式(兼容原 i18next 用法)
|
|
||||||
if (key.includes(':')) {
|
|
||||||
msgId = key.replace(':', '_').replace(/\./g, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试直接查找 key
|
// 先尝试直接查找 key
|
||||||
let message = getMessage(msgId);
|
let message = getMessage(msgId);
|
||||||
@@ -70,11 +66,3 @@ export function useI18n(namespace?: string | string[]) {
|
|||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 预加载命名空间(无操作,兼容 useLazyTranslation)
|
|
||||||
*/
|
|
||||||
export async function preloadNamespaces(_namespaces: string[]): Promise<void> {
|
|
||||||
// chrome.i18n 是同步的,无需预加载
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,35 +2,6 @@
|
|||||||
* Chrome 标签页相关工具函数
|
* Chrome 标签页相关工具函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前活动的标签页
|
|
||||||
*/
|
|
||||||
export async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
|
|
||||||
try {
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
return tab || null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取活动标签页失败:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前活动的标签页域名
|
|
||||||
*/
|
|
||||||
export async function getActiveTabDomain(): Promise<string> {
|
|
||||||
const tab = await getActiveTab();
|
|
||||||
if (tab?.url) {
|
|
||||||
try {
|
|
||||||
const url = new URL(tab.url);
|
|
||||||
return url.hostname;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('解析域名失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在新标签页中打开扩展页面
|
* 在新标签页中打开扩展页面
|
||||||
* @param page - 扩展页面路径(如 'popup.html')
|
* @param page - 扩展页面路径(如 'popup.html')
|
||||||
@@ -50,28 +21,3 @@ export async function openExtensionPage(
|
|||||||
console.error('打开扩展页面失败:', error);
|
console.error('打开扩展页面失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 确保内容脚本已注入
|
|
||||||
*/
|
|
||||||
export async function ensureContentScriptInjected(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const tab = await getActiveTab();
|
|
||||||
if (!tab?.id) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('内容脚本未注入,尝试注入...');
|
|
||||||
console.error('注入内容脚本失败:', e);
|
|
||||||
await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
files: ['/content-scripts/content.js'],
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注入内容脚本失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,21 +11,3 @@ export async function copyTextToClipboard(text: string): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制图片到剪贴板
|
|
||||||
* @param blob 要复制的图片
|
|
||||||
* @returns Promise<boolean> 是否复制成功
|
|
||||||
*/
|
|
||||||
export async function copyImageToClipboard(blob: Blob): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'image/png': blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
|
|
||||||
export interface ContextMenuItemConfig {
|
interface ContextMenuItemConfig {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextMenuClickedInfo {
|
interface ContextMenuClickedInfo {
|
||||||
featureKey: PageType;
|
featureKey: PageType;
|
||||||
payload: string;
|
payload: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseResult {
|
interface ParseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: ContextMenuClickedInfo;
|
data?: ContextMenuClickedInfo;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function formatJson(text: string, options: JsonFormatOptions): JsonFormat
|
|||||||
/**
|
/**
|
||||||
* JSON 压缩结果
|
* JSON 压缩结果
|
||||||
*/
|
*/
|
||||||
export interface JsonMinifyResult {
|
interface JsonMinifyResult {
|
||||||
/** 压缩后的 JSON 字符串 */
|
/** 压缩后的 JSON 字符串 */
|
||||||
minified: string;
|
minified: string;
|
||||||
/** 原始输入的字节大小 */
|
/** 原始输入的字节大小 */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* JSON 转 TOML 转换结果
|
* JSON 转 TOML 转换结果
|
||||||
*/
|
*/
|
||||||
export interface JsonToTomlResult {
|
interface JsonToTomlResult {
|
||||||
/** 转换后的 TOML 字符串 */
|
/** 转换后的 TOML 字符串 */
|
||||||
output: string;
|
output: string;
|
||||||
/** 原始输入的字节大小 */
|
/** 原始输入的字节大小 */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* JSON 转 YAML 转换结果
|
* JSON 转 YAML 转换结果
|
||||||
*/
|
*/
|
||||||
export interface JsonToYamlResult {
|
interface JsonToYamlResult {
|
||||||
/** 转换后的 YAML 字符串 */
|
/** 转换后的 YAML 字符串 */
|
||||||
output: string;
|
output: string;
|
||||||
/** 原始输入的字节大小 */
|
/** 原始输入的字节大小 */
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
import { getMessage } from '@/utils/chromeI18n';
|
import { getMessage } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
export interface JwtHeader {
|
interface JwtHeader {
|
||||||
alg: string;
|
alg: string;
|
||||||
typ?: string;
|
typ?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
interface JwtPayload {
|
||||||
iss?: string;
|
iss?: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
aud?: string | string[];
|
aud?: string | string[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import QrScanner from 'qr-scanner';
|
import QrScanner from 'qr-scanner';
|
||||||
|
|
||||||
export interface QrCodeParseResult {
|
interface QrCodeParseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: string;
|
data?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ async function runCleanScript(
|
|||||||
return { success: false, error: 'No result returned' };
|
return { success: false, error: 'No result returned' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
return runCleanScript(
|
return runCleanScript(
|
||||||
tabId,
|
tabId,
|
||||||
() => {
|
() => {
|
||||||
@@ -236,7 +236,7 @@ export async function injectClearLocalStorage(tabId: number): Promise<StorageCle
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
return runCleanScript(
|
return runCleanScript(
|
||||||
tabId,
|
tabId,
|
||||||
() => {
|
() => {
|
||||||
@@ -248,7 +248,7 @@ export async function injectClearSessionStorage(tabId: number): Promise<StorageC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||||
return runCleanScript(
|
return runCleanScript(
|
||||||
tabId,
|
tabId,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -292,7 +292,7 @@ export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanR
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
||||||
return runCleanScript(
|
return runCleanScript(
|
||||||
tabId,
|
tabId,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -309,7 +309,7 @@ export async function injectClearCacheStorage(tabId: number): Promise<StorageCle
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||||
return runCleanScript(
|
return runCleanScript(
|
||||||
tabId,
|
tabId,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -381,10 +381,3 @@ export function formatCleaningResult(
|
|||||||
|
|
||||||
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') });
|
return t('storageCleaner:cleanedSummary', { items: parts.join(', ') });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEmptyResult(result: CleaningResult): boolean {
|
|
||||||
const values = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
|
||||||
);
|
|
||||||
return values.length === 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,10 +90,3 @@ export async function saveContextMenuData(
|
|||||||
};
|
};
|
||||||
await storageUtil.set(STORAGE_KEY, pendingData);
|
await storageUtil.set(STORAGE_KEY, pendingData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除右键菜单待处理数据
|
|
||||||
*/
|
|
||||||
export async function clearContextMenuData(): Promise<void> {
|
|
||||||
await storageUtil.remove(STORAGE_KEY);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useDebounce Hook - 防抖值
|
|
||||||
*
|
|
||||||
* @param value - 需要防抖的值
|
|
||||||
* @param delay - 延迟时间(毫秒)
|
|
||||||
* @returns 防抖后的值
|
|
||||||
*/
|
|
||||||
export function useDebounce<T>(value: T, delay: number): T {
|
|
||||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setDebouncedValue(value);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [value, delay]);
|
|
||||||
|
|
||||||
return debouncedValue;
|
|
||||||
}
|
|
||||||
@@ -58,8 +58,20 @@ export default defineConfig({
|
|||||||
'contextMenus',
|
'contextMenus',
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
|
icons: {
|
||||||
|
'16': 'icon/16.png',
|
||||||
|
'32': 'icon/32.png',
|
||||||
|
'48': 'icon/48.png',
|
||||||
|
'96': 'icon/96.png',
|
||||||
|
'128': 'icon/128.png',
|
||||||
|
},
|
||||||
action: {
|
action: {
|
||||||
default_title: '__MSG_appName__',
|
default_title: '__MSG_appName__',
|
||||||
|
default_icon: {
|
||||||
|
'16': 'icon/16.png',
|
||||||
|
'32': 'icon/32.png',
|
||||||
|
'48': 'icon/48.png',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
side_panel: {
|
side_panel: {
|
||||||
default_path: 'entrypoints/sidepanel/index.html',
|
default_path: 'entrypoints/sidepanel/index.html',
|
||||||
|
|||||||