Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd269348b4 | |||
| 58af2af37b | |||
| 359326c6da | |||
| 0624b609be | |||
| f551428142 | |||
| 1ae057ccfa |
+10
-3
@@ -1,6 +1,13 @@
|
|||||||
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||||
# 类型检查
|
# 类型检查(全项目,因为类型错误可能跨文件传播)
|
||||||
npx tsc --noEmit
|
npx tsc --noEmit
|
||||||
|
|
||||||
# ESLint 严格检查(不允许 warning)
|
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
|
||||||
npx eslint . --max-warnings=0
|
# 新分支无 upstream 时,回退到与 origin/main 对比
|
||||||
|
MERGE_BASE=$(git merge-base HEAD @{upstream} 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null)
|
||||||
|
if [ -n "$MERGE_BASE" ]; then
|
||||||
|
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
|
||||||
|
if [ -n "$CHANGED_FILES" ]; then
|
||||||
|
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import QrCodePage from '../index';
|
import QrCodePage from '../index';
|
||||||
|
|
||||||
vi.mock('lucide-react', async (importOriginal) => {
|
vi.mock('lucide-react', async (importOriginal) => {
|
||||||
@@ -102,7 +102,7 @@ describe('QrCodePage', () => {
|
|||||||
expect(generateButton).not.toBeDisabled();
|
expect(generateButton).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('点击生成按钮应该切换到预览态', () => {
|
it('点击生成按钮应该切换到预览态', async () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
|
|
||||||
// 输入文本
|
// 输入文本
|
||||||
@@ -114,14 +114,16 @@ describe('QrCodePage', () => {
|
|||||||
fireEvent.click(generateButton);
|
fireEvent.click(generateButton);
|
||||||
|
|
||||||
// 验证切换到预览态
|
// 验证切换到预览态
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByText('原始文本')).toBeInTheDocument();
|
expect(screen.getByText('原始文本')).toBeInTheDocument();
|
||||||
expect(screen.getByText('编辑')).toBeInTheDocument();
|
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
|
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
|
||||||
// 预览态应该显示二维码
|
// 预览态应该显示二维码
|
||||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('预览态应该显示截断的文本', () => {
|
it('预览态应该显示截断的文本', async () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
|
|
||||||
// 输入长文本
|
// 输入长文本
|
||||||
@@ -135,10 +137,12 @@ describe('QrCodePage', () => {
|
|||||||
|
|
||||||
// 验证显示截断的文本(80字符 + "...")
|
// 验证显示截断的文本(80字符 + "...")
|
||||||
const truncatedText = longText.slice(0, 80) + '...';
|
const truncatedText = longText.slice(0, 80) + '...';
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
expect(screen.getByText(truncatedText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('预览态应该显示完整的短文本', () => {
|
it('预览态应该显示完整的短文本', async () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
|
|
||||||
// 输入短文本
|
// 输入短文本
|
||||||
@@ -150,10 +154,12 @@ describe('QrCodePage', () => {
|
|||||||
fireEvent.click(screen.getByText('生成二维码'));
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
// 验证显示完整文本
|
// 验证显示完整文本
|
||||||
|
await waitFor(() => {
|
||||||
expect(screen.getByText(shortText)).toBeInTheDocument();
|
expect(screen.getByText(shortText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('点击编辑按钮应该返回输入态', () => {
|
it('点击编辑按钮应该返回输入态', async () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
|
|
||||||
// 输入文本并生成
|
// 输入文本并生成
|
||||||
@@ -161,6 +167,11 @@ describe('QrCodePage', () => {
|
|||||||
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
fireEvent.click(screen.getByText('生成二维码'));
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 等待生成完成
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// 点击编辑按钮
|
// 点击编辑按钮
|
||||||
fireEvent.click(screen.getByText('编辑'));
|
fireEvent.click(screen.getByText('编辑'));
|
||||||
|
|
||||||
@@ -172,7 +183,7 @@ describe('QrCodePage', () => {
|
|||||||
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('返回编辑态应该保留上次输入的内容', () => {
|
it('返回编辑态应该保留上次输入的内容', async () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
|
|
||||||
// 输入文本并生成
|
// 输入文本并生成
|
||||||
@@ -180,6 +191,11 @@ describe('QrCodePage', () => {
|
|||||||
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
|
||||||
fireEvent.click(screen.getByText('生成二维码'));
|
fireEvent.click(screen.getByText('生成二维码'));
|
||||||
|
|
||||||
|
// 等待生成完成
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('编辑')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// 点击编辑按钮
|
// 点击编辑按钮
|
||||||
fireEvent.click(screen.getByText('编辑'));
|
fireEvent.click(screen.getByText('编辑'));
|
||||||
|
|
||||||
|
|||||||
@@ -165,8 +165,11 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 先设置 loading 状态,让 React 渲染加载动画
|
||||||
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
|
||||||
|
|
||||||
|
// 延迟到下一帧生成,确保 loading 状态先被渲染显示
|
||||||
|
setTimeout(() => {
|
||||||
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
const qrCodeDataUrl = generateQrCodeDataUrl(text);
|
||||||
|
|
||||||
if (!qrCodeDataUrl) {
|
if (!qrCodeDataUrl) {
|
||||||
@@ -186,6 +189,7 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
qrCodeDataUrl,
|
qrCodeDataUrl,
|
||||||
generating: false,
|
generating: false,
|
||||||
}));
|
}));
|
||||||
|
}, 0);
|
||||||
}, [generatorState.textToEncode, t]);
|
}, [generatorState.textToEncode, t]);
|
||||||
|
|
||||||
/** 返回编辑态,保留上次输入内容 */
|
/** 返回编辑态,保留上次输入内容 */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export function parseContextMenuClick(
|
|||||||
): ParseResult {
|
): ParseResult {
|
||||||
const featureKey = getMenuPageType(menuItemId);
|
const featureKey = getMenuPageType(menuItemId);
|
||||||
|
|
||||||
// 处理图片 URL(右键点击图片时)
|
// 处理图片 URL(仅 qrCode-image 菜单项,右键点击图片时)
|
||||||
if (info.srcUrl) {
|
if (menuItemId === 'qrCode-image' && info.srcUrl) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { featureKey, payload: info.srcUrl },
|
data: { featureKey, payload: info.srcUrl },
|
||||||
|
|||||||
@@ -152,13 +152,6 @@ const webExtensionMock = {
|
|||||||
getAll: vi.fn().mockResolvedValue([]),
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
remove: vi.fn().mockResolvedValue(undefined),
|
remove: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
alarms: {
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
onAlarm: {
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
contextMenus: {
|
contextMenus: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
onClicked: {
|
onClicked: {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ export default defineConfig({
|
|||||||
'cookies',
|
'cookies',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
'contextMenus',
|
'contextMenus',
|
||||||
'alarms',
|
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
Reference in New Issue
Block a user