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:
LingandRX
2026-06-06 01:07:27 +08:00
committed by GitHub
parent c992986789
commit a5d86a92c2
29 changed files with 230 additions and 601 deletions
+1 -135
View File
@@ -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();
});
});
});
+2 -34
View File
@@ -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 -13
View File
@@ -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 = {
+4 -4
View File
@@ -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 类型 */
-8
View File
@@ -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();
}
-54
View File
@@ -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;
}
}
-18
View File
@@ -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;
}
}
+3 -3
View File
@@ -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;
+1 -1
View File
@@ -71,7 +71,7 @@ export function formatJson(text: string, options: JsonFormatOptions): JsonFormat
/**
* JSON 压缩结果
*/
export interface JsonMinifyResult {
interface JsonMinifyResult {
/** 压缩后的 JSON 字符串 */
minified: string;
/** 原始输入的字节大小 */
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* JSON 转 TOML 转换结果
*/
export interface JsonToTomlResult {
interface JsonToTomlResult {
/** 转换后的 TOML 字符串 */
output: string;
/** 原始输入的字节大小 */
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* JSON 转 YAML 转换结果
*/
export interface JsonToYamlResult {
interface JsonToYamlResult {
/** 转换后的 YAML 字符串 */
output: string;
/** 原始输入的字节大小 */
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import QrScanner from 'qr-scanner';
export interface QrCodeParseResult {
interface QrCodeParseResult {
success: boolean;
data?: string;
error?: string;
+5 -12
View File
@@ -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;
}
-7
View File
@@ -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);
}
-24
View File
@@ -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;
}