develop (#67)
feat: 升级项目 Logo、完善 toast 提示及优化工程化配置 ### Features - **logo**: 替换全套项目图标,更新 wxt.config.ts 浏览器扩展图标配置。 ### Bug Fixes - **ui/toast**: 引入 <Toaster> 组件修复 toast 无法显示问题,并调整位置为正下方。 - **i18n**: 统一将 t() 函数中的 `.` 和 `:` 转换为 `_`,修复国际化文案读取失败问题。 - **context-menu**: 限制 srcUrl 检查仅在二维码图片菜单项生效,避免误判。 - **hooks**: 修复 pre-push 钩子,仅对变更文件进行 eslint 检查,并支持无 upstream 时的回退对比。 - **components**: 修复 CopyButton 动画与组件导入方式,并补齐无障碍属性与测试。 - **async**: 延迟生成二维码以正确渲染 loading 状态,并适配相关异步测试。 ### Refactor & Cleans - **clean**: 清理未使用的组件(ToolCard)、Hook(useDebounce)及多个未使用的工具函数导出。 - **structure**: 统一移动测试文件至 __tests__ 目录,移除不必要的类型导出。 ### Documentation - **docs**: 在 AGENTS.md 中明确规范 Git Commit 必须使用中文描述。
This commit is contained in:
@@ -1,101 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getActiveTab,
|
||||
getActiveTabDomain,
|
||||
openExtensionPage,
|
||||
ensureContentScriptInjected,
|
||||
} from '@/utils/chromeTabs';
|
||||
import { openExtensionPage } from '@/utils/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', () => {
|
||||
it('应该在新标签页中打开扩展页面', async () => {
|
||||
await openExtensionPage('popup.html');
|
||||
@@ -116,44 +22,4 @@ describe('chromeTabs', () => {
|
||||
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 { copyTextToClipboard, copyImageToClipboard } from '@/utils/clipboard';
|
||||
|
||||
// Mock ClipboardItem for test environment
|
||||
class MockClipboardItem {
|
||||
constructor(public items: Record<string, Blob>) {}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
(globalThis as any).ClipboardItem = MockClipboardItem;
|
||||
});
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
|
||||
describe('clipboard', () => {
|
||||
describe('copyTextToClipboard', () => {
|
||||
@@ -31,27 +22,4 @@ describe('clipboard', () => {
|
||||
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 { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useContextMenuData,
|
||||
saveContextMenuData,
|
||||
clearContextMenuData,
|
||||
} from '@/utils/useContextMenuData';
|
||||
import { useContextMenuData, saveContextMenuData } from '@/utils/useContextMenuData';
|
||||
|
||||
describe('useContextMenuData', () => {
|
||||
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', () => {
|
||||
it('当 storage 中有匹配数据时应调用 onData 回调', async () => {
|
||||
const mockData = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatBytes } from './format';
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
/** 支持的图像 MIME 类型 */
|
||||
export const SUPPORTED_IMAGE_TYPES = [
|
||||
const SUPPORTED_IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
@@ -20,7 +20,7 @@ export const SUPPORTED_IMAGE_TYPES = [
|
||||
] as const;
|
||||
|
||||
/** 支持的图像文件扩展名 */
|
||||
export const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||
const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
@@ -34,7 +34,7 @@ export const SUPPORTED_IMAGE_EXTENSIONS = [
|
||||
/**
|
||||
* 文本转 Base64 编码结果
|
||||
*/
|
||||
export interface TextToBase64Result {
|
||||
interface TextToBase64Result {
|
||||
/** Base64 编码结果 */
|
||||
output: string;
|
||||
/** 原始字节数 */
|
||||
@@ -226,7 +226,7 @@ export function formatFileSize(bytes: number): string {
|
||||
/**
|
||||
* Base64 解码为二进制后的产物
|
||||
*/
|
||||
export interface Base64ToBlobResult {
|
||||
interface Base64ToBlobResult {
|
||||
/** 解码后的 Blob */
|
||||
blob: Blob;
|
||||
/** 推断出的 MIME 类型 */
|
||||
|
||||
@@ -66,11 +66,3 @@ export function useI18n(namespace?: string | string[]) {
|
||||
isLoaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载命名空间(无操作,兼容 useLazyTranslation)
|
||||
*/
|
||||
export async function preloadNamespaces(_namespaces: string[]): Promise<void> {
|
||||
// chrome.i18n 是同步的,无需预加载
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -2,35 +2,6 @@
|
||||
* 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')
|
||||
@@ -50,28 +21,3 @@ export async function openExtensionPage(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制图片到剪贴板
|
||||
* @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';
|
||||
|
||||
export interface ContextMenuItemConfig {
|
||||
interface ContextMenuItemConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
contexts: [`${chrome.contextMenus.ContextType}`, ...`${chrome.contextMenus.ContextType}`[]];
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuClickedInfo {
|
||||
interface ContextMenuClickedInfo {
|
||||
featureKey: PageType;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
interface ParseResult {
|
||||
success: boolean;
|
||||
data?: ContextMenuClickedInfo;
|
||||
error?: string;
|
||||
|
||||
@@ -71,7 +71,7 @@ export function formatJson(text: string, options: JsonFormatOptions): JsonFormat
|
||||
/**
|
||||
* JSON 压缩结果
|
||||
*/
|
||||
export interface JsonMinifyResult {
|
||||
interface JsonMinifyResult {
|
||||
/** 压缩后的 JSON 字符串 */
|
||||
minified: string;
|
||||
/** 原始输入的字节大小 */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* JSON 转 TOML 转换结果
|
||||
*/
|
||||
export interface JsonToTomlResult {
|
||||
interface JsonToTomlResult {
|
||||
/** 转换后的 TOML 字符串 */
|
||||
output: string;
|
||||
/** 原始输入的字节大小 */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* JSON 转 YAML 转换结果
|
||||
*/
|
||||
export interface JsonToYamlResult {
|
||||
interface JsonToYamlResult {
|
||||
/** 转换后的 YAML 字符串 */
|
||||
output: string;
|
||||
/** 原始输入的字节大小 */
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@
|
||||
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
export interface JwtHeader {
|
||||
interface JwtHeader {
|
||||
alg: string;
|
||||
typ?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
interface JwtPayload {
|
||||
iss?: string;
|
||||
sub?: string;
|
||||
aud?: string | string[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import QrScanner from 'qr-scanner';
|
||||
|
||||
export interface QrCodeParseResult {
|
||||
interface QrCodeParseResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
|
||||
@@ -224,7 +224,7 @@ async function runCleanScript(
|
||||
return { success: false, error: 'No result returned' };
|
||||
}
|
||||
|
||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
return runCleanScript(
|
||||
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(
|
||||
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(
|
||||
tabId,
|
||||
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(
|
||||
tabId,
|
||||
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(
|
||||
tabId,
|
||||
async () => {
|
||||
@@ -381,10 +381,3 @@ export function formatCleaningResult(
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除右键菜单待处理数据
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user