From a5d86a92c2b0583d74a6df5af9693e1f673366bf Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:07:27 +0800 Subject: [PATCH] develop (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 升级项目 Logo、完善 toast 提示及优化工程化配置 ### Features - **logo**: 替换全套项目图标,更新 wxt.config.ts 浏览器扩展图标配置。 ### Bug Fixes - **ui/toast**: 引入 组件修复 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 必须使用中文描述。 --- AGENTS.md | 1 + public/README.md | 1 - public/icon/128.png | Bin 6167 -> 7788 bytes public/icon/16.png | Bin 669 -> 932 bytes public/icon/32.png | Bin 1542 -> 1759 bytes public/icon/48.png | Bin 2088 -> 2461 bytes public/icon/96.png | Bin 4248 -> 5282 bytes public/wxt.svg | 56 ----- src/components/__tests__/CopyButton.test.tsx | 194 ++++++++++++++++++ src/components/__tests__/ToolCard.test.tsx | 118 ----------- src/entrypoints/content/uiPopover.ts | 2 +- src/pages/Dashboard/ToolCard.tsx | 107 ---------- src/utils/__tests__/chromeTabs.test.ts | 136 +----------- src/utils/__tests__/clipboard.test.ts | 36 +--- .../__tests__/useContextMenuData.test.ts | 14 +- src/utils/base64Converter.ts | 8 +- src/utils/chromeI18n.ts | 8 - src/utils/chromeTabs.ts | 54 ----- src/utils/clipboard.ts | 18 -- src/utils/contextMenu.ts | 6 +- src/utils/jsonFormatter.ts | 2 +- src/utils/jsonToToml.ts | 2 +- src/utils/jsonToYaml.ts | 2 +- src/utils/jwt.ts | 4 +- src/utils/qrCodeParser.ts | 2 +- src/utils/storageCleaner.ts | 17 +- src/utils/useContextMenuData.ts | 7 - src/utils/useDebounce.ts | 24 --- wxt.config.ts | 12 ++ 29 files changed, 230 insertions(+), 601 deletions(-) delete mode 100644 public/wxt.svg create mode 100644 src/components/__tests__/CopyButton.test.tsx delete mode 100644 src/components/__tests__/ToolCard.test.tsx delete mode 100644 src/pages/Dashboard/ToolCard.tsx delete mode 100644 src/utils/useDebounce.ts diff --git a/AGENTS.md b/AGENTS.md index 34bd6ef..28d1b5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,7 @@ src/pages/FeatureName/ - 图标: 使用 `lucide-react` 图标库 - 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) - ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径) +- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述`、`feat(功能名): 描述`) ## 关键外部库(非显而易见的) diff --git a/public/README.md b/public/README.md index b7b3f2f..8de0398 100644 --- a/public/README.md +++ b/public/README.md @@ -12,7 +12,6 @@ | `icon/48.png` | 48×48 图标(扩展管理页) | | `icon/96.png` | 96×96 图标 | | `icon/128.png` | 128×128 图标(Chrome Web Store) | -| `wxt.svg` | WXT 框架标志 SVG 图标 | ## 注意事项 diff --git a/public/icon/128.png b/public/icon/128.png index 4d9a614bc6e70fc24d2799c6584f1557b89266ed..8b7b5665fec7a02fc585e7bc67138e873d2b3a06 100644 GIT binary patch literal 7788 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 diff --git a/public/icon/16.png b/public/icon/16.png index d6bc0e0391b8ffd91f2d960015d224ff23aa7cb1..d3f04475907884d8be2308605d6dfc4b6c58f3d4 100644 GIT binary patch delta 910 zcmV;919AMF1*8X%B!Bf#OjJbx00006DPVr5J32ocE>Ijrm=!>jSBC9rgsclNJSR_E zEmBuihUI>EaWXnb97c^IPoNx0oMK#3EIwLMNj-pWR4qG8B0YEo6d?cu4`GbtV2s;Y zf!bS#)l_w)}afF?|OAW3pPT#YhYh%7l-9y?e%SB5oNltXHWK4PCTP8Ekjp0Q)OC*<5Ys#ScA(_Z<9%XkxX`k zPGEjDTX`%-W*%8&8&+Z{Oj#>TRwzhN7(!1QB}oM`L=`kT4JR&aneJ(q>v*B)berOL zq2XSK-EEiFUx?0PhR$r5$zhGam#)2QiM&*Kx^po5Q_PKlRXc8^JUiAZONQ-Fs-X@_HYg*R`7LVAEwY=1y#dqq@sI9hcpPjM_{Po}3+1c2AZB#NS6Au3V{{H^{{{H^{ z{{H^`{r&y@{r&y@{rvp=`}_O%_wVoP=;h?$;o#rj-`(BW)3BN@*mZU2(JwX->lV63!)Bt2Z7 z_rIvTOol-A>308|+N|e+qmsW-1j<}8|Ln^1Yq(8IEl?s**I2Tj3|8CmtLvj$jX=}! zkbn7Z=Q&(oS(bJUf~oryBgg0Shpu-E?660f7{Mcp$Ejor-Sl-@P(lT0IJq9o# klF&3oi6t~87L7v~$Z$VVOt9Jx00000Ne4wvM6N<$f)t05cK`qY delta 644 zcmV-~0(aQk4;ZlSW{C| zL`6zuT1ZY#O-xTwNlaBvOj1WjOG->uIypxiSb9EXaGZ}@NNH?%xBaK%;8}~ z5)hC#&sJcEu!RKp`B+pv&9j`;4f)vE1sS-cMWd~)EzBMB(lZ>yWVjia)Hv)Dqb&U# z?ULhz!+klKfO<5X3i9o3lft~cLNu8{rZ8*eX4+cCBt+QgFhk5Wk+k%13rMw-V~1MG zDq`X4;uD$1!3Gsz33Yc4inWS!;DtI<*VkW(mqpPg1?pH+WmyBDZA|)7D#Bm^L2Wj$ e3?nNy&=vr&>l~=1KF`ho0000Vm{ diff --git a/public/icon/32.png b/public/icon/32.png index 153315876f44d2d564faab16ab62a3503471c9c8..1de5c73df3802a91f7b8eb7a11272e8ef0017f3e 100644 GIT binary patch delta 1743 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 diff --git a/public/icon/48.png b/public/icon/48.png index d4ef488aa521b242fab5066d6f826edf3fde8857..86c73a9311d515df1d083504ccbd4327b31733b3 100644 GIT binary patch delta 2451 zcmV;E32gSL5Sm%-Mb$l@I&LlzzhPEk=JATWH0 z%5aL}Yy@Hd+=ZI36fJQHuXfi~CWE`x`b@Q;PH+DLG7w|2J8F z4KYC_HeVeyUVkPuR~9TmA}2qM%Kri|It(H=E?AXVSy&V!I7pG^P=DD_e$*x@KpZSS z7AQMQjrCTF?qihWL1?QaG))3fOB*vo94$f`COro#Hv%OxEhsQbhww95nJYR_6*@{E zC`2GaQX@1*1vf%nmEb|0moZCp8YfO1E=nRLIvgc68h;-yM>imr$p2D{=fSXvKU#%8 zRed{8buC3~EkIusF+&h3J|QhT6)G+`%CtzVqCskSD?xZNOlvhsVFX)J88=J%WMC*QO#n7LKsGc68ZR6r7gUh!Qi0=Mj?;a~&u*isFR`DHi;o$ijyZ>i zK2dKtP-qKcUm8|iOix<|QdSX5Pf3dGPk-l6cgkO?vN6W5BfqAmqohP^q^O>g2aJ1- zh;Xr)Xel~ontw!LP&OtQ3|o-&jOFNX=h;((!hcu6xn;VvXoj@GyR-G*q8G21Jz10O z(~L}Rg1V@8CR}cNaA6reU6zJihjUswLrQ#VM@dLU1V}|6ML_{3BW|(#d9LXH`Q4b? z+?BV`MbEqQ-@4GmnNnDoT9KDFkd{b)g=AoS7=L##Vrz^{N&4j{{H>^;i{!h{`~y=)yRu){rvm;-NVAdxFEyBGynhw2uVai zR7l5#l?7OnK^TBduqYJ?gR;B3Yru#NHh+c^E7uz=B{sUo2&zPtCo|8}tve=upR3bRN27mdyPl@x8B_FrMsLc^t#Wy~?`-K6&K=A$Mq zoO9jR*SECPS4_a1g*!+6Q<%nh)i-FmWrBn)jKS*x$M;R<6gAZ+Su{h!%9UOMK3m7w%+$oh#MI15;DZ*JO_5@z zp=`fq)m($K>I8N0B6N2Xx~>G3umo;v2bEz5jpOo5Z!XQe$>0;Ac6M-k zA{Iljx0@GeuPOvVA=VP8Y)EplNOTk+5(uo>Y-;$?qP!x3j-958Fg{e%3x5a_kbH)S z6#Gyldf4_L$(qmSCjWXhr#IP2`)@)x03A+F^vHNehrK)RkqE<}t&iTmIxxh*4ilDy zYTC0QQZT3=Sv4LXlUVtY=`~F}uHgQqt3SW>6l)vVFHz1zOs;o=v^@gVS`(2)f|0{e zu~{!J$D%N$_Xe&8+-GYW!GC9}%yf)>0vgTdBayJCx`?_5bRjt`3>BYsye<~T7`l_` z>r>nxvS)(IHuKJ9qS0t15F$aN5;B4*j3c9BFGpnYd>CD&3<0yjOG9-ZdlzsaP7o(5 z1XiDZ;$#Vej6j6JP$y4BWYc|;84Mq5W`p;nVL~pENMwRHEB@{AntwtbJq$)K$v$!X zLn4dGWD>btW&wG)&=4AONK7LvJ{$DW%Hp-2j7a~$V|jXpgoGUO42>BkXpEZ@9MljK z8q-+N7{@EDi>OEOvLh}RmauYzaza9bHH|_ji-sRL8?|-rUVLBMb@y6S3@kFEp3bXJ zuR+B`6~J|9%Rb5PBNh0VMqER{;5(S%q83I%hd@j6d`c_{Fps&Rxpf|L4PXrC_#Yr0VWWFKu{>u z1S;qBx!6bf)nx@2ymUq@+E3WMz|)%!gnW+@7==PXf=NdpAjkwN_T!l|=g&WTQ%3Zh z6+B+K_yRY?QD~${AS9xagjgyBjfRLRi>i8Yrs`QW$1#buA$Y9fxSKh5JqtsNuWtha zsjZF03WFfg;D4zcqpGscW#mV39JxuH^~+YthdpuGisxhq1_q-?y-D6gV4Go{xa{T}fGE!0u0>{aPXua}OXF$1s zxqq6o^I1`Ez`#I2o_A$hTAFhzs1lQ=9<2~^^!$$k; z6^FyQ;eW)1zwQyGpze>e1ek-%5@%D<-*WHe%c7QOyaf(tVI##{MHuI)cgn^B2o@4- ztaP>aU%c2dPtVfE!opk*o}ePA<3W`SaI*@+p#*Al2i_dqM&91?l)6&L(XFr)BNJ@g zDmrxaTq~?BEpS#c!xD@^G3bps1Q$HsN{U!?vwu2;$K%`a`0{d)mg28R2$9dj>i#S4 z9j;Pb+S}V*Tn6uL-5O}XW?v_?zO($bnLMiNh*3Zl{S8tGa58%}P@)-Nu? RLJR-^002ovPDHLkV1k6iWU>GN delta 2075 zcmV+$2;}#j6Q~f7B!Bl%OjJbx002lxPEJlyNl8yaLPgi zMMX+UNlr;gPDn^iNk~mXLP$qPPDn^iN=i>lN>4~gO+`jaNlH#iNl!{iPew>hNl8va zL`h9eQbtEiO-)cmMM_UlR6|2aPft`sMM_UkR7^}!M@LLaNPkU3L`hFiRZ2=wPEJ!s zMoU9NNI*bEPEAurL`y$EMM+6dO-@rzO;bTbNJK(N0$GUctvN(SOYpu_?5#EdbERRQ zo&sT$P>G8HSBCS$SVl@u0brA4qM=ick6)ad0A7*x##>5FQS7ro=&LtnqoPubjZlb* zQBhV+P*w84RDVZDO9FDDW1*m4o}gf!ok>Yf)T>AAuR8*CrdE-XSC5ZJQCIN6RzpKb z&!j%-uRHIqHUe&+SCo}gkdXmficf}zO-)f*R!~e!PT8kE=czN~r!xX}r(BtuEr5~& zU5p1=g(y~W*ScLpQ(gANSL?l0Mom-jyi#3QOmL%=3x8#k24IjcdW%_(h!tIdReO9P zVtP+;a!hV-G*o9xTwvh9Ue2;y^21g0!BSLIP_2?p@48G;P)ysdNQZMsvWq`am7PhC zn>vP>S(cY1eU%bxl_PnSB6g1gU5yG}ix6Cfil>AiU4BS*cDA!{z_)Cxs%uGNWcbKj zR9aT%ynj_tP*mEpQDR+DhP=0`MmVPa4etMaFh@g8LSb0x&cPn0ZY>0KA zsBuqqZ8~0Uo1$zuSZPsbXk2z>JXB=CuwjakUw=MOTufA1-MCl4pi!2NQS!b|m4Qxl zYE7n)OvR#0!kIwuvNth+nha~0F@Kj{l#_m@i(7_KR1>x95X3B>DXGQh#9~ zq@Gos>CC2RsD?k)ay+a!6tXb#%kW5vo2V)V?##ChvE3Z$os^gN%UMW;Q=FSehMy5` z6*C{NG?#&zvW}uwz={=tadBrhrNMmtvN`7=cM1MnG(()^vS9@=)zPT=|KiJADSWZEOK{+5`*{S)B zbLSKn%`Qq$x3I7%3JI|VX-LY;z3%Dh+0|9<6u*4shpWA-mstg?Fz~CcShe=zf}JiN z9z{hU^%joyAt50%BcpQj`he=4tgW4#oXWd87VSG8XT`}5%w4Jh>oz9czJIu&(ZT|v z!EsAS%FIZcTyG~&&!wL6VAZ>8-1k>naWaBKUM+Cdox68^zP~@&?C9ud0YqC;W+mn( z1vxo+E?w#bG`q$wENpM3l^8QLu1o?}ZLqOSPWDN>_NKWQs3{$&!KM%B5~q$AyKBP3 z!tBDEoP))f;XwFvl2g-$Coagx5K?4by`7zdy})K8B(k> z8w6%e0_s5MIT#64UE@A2HFevz)YO<3XE_N$l!!Kv1?h;2iURA&?R|CPQFvIvwA7fi zw3ry6dNxT`w0tZ9)nS=zlX&gqo+*bx?g1)y1ApgWI z55nCRcUP1ye%9cY=GJPZz>O`Pa%uN`{q*kO91jnVtzqGQyE?mrf+{*s9B6e`P{&cA z0>o6-Xp!z=?@~M2*Ur!3(DEQ4=w4Z^$&Dk4OEa}uI9k}-+fNO(3-@#IKW!ZpA0JX^&g&n8^;od`c~0$Q*U*P8ff2$|jO>y^!IeugV$%+-iR{Pf zYaXVyx~X;rO@Egogv7z}d}0d0aV`FSr`N1wGs5oY&F*{KBec~t;8hWard8a6h6@m1 zWBU2I1IY0_f=KyFT{j_k$???*`q)ZsuXdp0xfs!^cyply=bi*CzUGt9%r+L|#}XKv zYzbCad~KL5tICVL7B&>MV#BPn`8l|laaJ2e*002ovPDHLk FV1mX%&zS%K diff --git a/public/icon/96.png b/public/icon/96.png index 1beb67cfa9c781fe8f7331a72e93e3b8deaf0b90..a53fd4d29e1ab9b90f297af94ae144f8e378d601 100644 GIT binary patch literal 5282 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 delta 4244 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 qw(<6*b){Rs+eF2||@c)lnPYuQZ0000 - - - - - - - - - - - diff --git a/src/components/__tests__/CopyButton.test.tsx b/src/components/__tests__/CopyButton.test.tsx new file mode 100644 index 0000000..64af285 --- /dev/null +++ b/src/components/__tests__/CopyButton.test.tsx @@ -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(); + + 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(); + + // 点击后复制成功,按钮获得 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(); + + 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(); + + await user.click(screen.getByRole('button')); + + expect(mockedCopy).toHaveBeenCalledWith('something'); + expect(mockedToast.error).toHaveBeenCalled(); + }); + + // ==================== 新增测试 ==================== + + it('初始渲染时显示 Copy 图标且无 emerald 样式', () => { + render(); + + 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(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('title', '自定义提示'); + expect(button).toHaveAttribute('aria-label', '自定义提示'); + }); + + it('className 被正确透传到按钮', () => { + render(); + + 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( +
+ +
, + ); + + 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(); + + 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(); + + 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(); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('data-testid', 'copy-btn'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('id', 'copy-button-id'); + }); +}); diff --git a/src/components/__tests__/ToolCard.test.tsx b/src/components/__tests__/ToolCard.test.tsx deleted file mode 100644 index bf3da2e..0000000 --- a/src/components/__tests__/ToolCard.test.tsx +++ /dev/null @@ -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( - {}} - />, - ); - - expect(screen.getByText('测试工具')).toBeInTheDocument(); - expect(screen.getByText('这是一个测试工具')).toBeInTheDocument(); - }); - - it('无描述时仅渲染标题', () => { - render( {}} />); - - expect(screen.getByText('仅标题')).toBeInTheDocument(); - }); - - it('应渲染图标', () => { - const { container } = render( - {}} />, - ); - - const svgElement = container.querySelector('svg'); - expect(svgElement).toBeInTheDocument(); - }); - - it('提供快照内容时应渲染快照', () => { - render( - {}} - snapshot={
快照内容
} - />, - ); - - expect(screen.getByTestId('snapshot')).toBeInTheDocument(); - }); - - it('未提供快照时不渲染快照区域', () => { - const { container } = render( - {}} - onNavigate={function (): void { - throw new Error('Function not implemented.'); - }} - />, - ); - - expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); - }); - - it('应使用 CardActionArea 渲染,支持键盘聚焦', () => { - render( {}} />); - - const button = screen.getByRole('button', { name: /可聚焦/ }); - expect(button).toBeInTheDocument(); - }); - }); - - describe('交互测试', () => { - it('点击时应调用 onClick', () => { - const handleClick = vi.fn(); - render(); - - const button = screen.getByRole('button', { name: /可点击/ }); - fireEvent.click(button); - - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it('按 Enter 键时应调用 onClick', async () => { - const handleClick = vi.fn(); - render( - , - ); - - const button = screen.getByRole('button', { name: /键盘可触发/ }); - await act(async () => { - button.focus(); - await userEvent.keyboard('{Enter}'); - }); - - expect(handleClick).toHaveBeenCalledTimes(1); - }); - }); - - describe('样式测试', () => { - it('应应用自定义颜色代码', () => { - const { container } = render( - {}} />, - ); - - const svgElement = container.querySelector('svg'); - expect(svgElement).toBeInTheDocument(); - }); - }); -}); diff --git a/src/entrypoints/content/uiPopover.ts b/src/entrypoints/content/uiPopover.ts index 1a6bafb..faf985e 100644 --- a/src/entrypoints/content/uiPopover.ts +++ b/src/entrypoints/content/uiPopover.ts @@ -161,7 +161,7 @@ function positionPopover(popover: HTMLElement, x: number, y: number): void { let hideTimeout: ReturnType | null = null; -export function showPopover( +function showPopover( x: number, y: number, contentHtml: string, diff --git a/src/pages/Dashboard/ToolCard.tsx b/src/pages/Dashboard/ToolCard.tsx deleted file mode 100644 index e8f6379..0000000 --- a/src/pages/Dashboard/ToolCard.tsx +++ /dev/null @@ -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 = { - 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 { - title: string; - description?: string; - snapshot?: React.ReactNode; - colorKey: PaletteColorKey; - icon: ComponentType; - onNavigate: () => void; -} - -export default function ToolCard({ - title, - description, - snapshot, - colorKey, - icon: IconComponent, - onNavigate, - className, - ...props -}: ToolCardProps) { - const rgbValues = PALETTE_COLORS[colorKey]; - - return ( -
- {/* 上半部分:核心信息交互排版轴 */} -
-
- {/* 左侧圆形图标容器 */} -
- -
- - {/* 中间文字描述区:利用 flex-1 min-w-0 防御文本过长发生恶性撑开 */} -
-

- {title} -

- {description && ( -

- {description} -

- )} -
-
- - {/* 右侧指示小箭头 */} -
- -
- - {/* 覆盖整个上半部分的绝对定位隐形跳转层(A11y 无障碍标准合规) */} -
- - {/* 下半部分:未来的动态预览沙箱独立承载区 */} - {snapshot != null && ( -
- {snapshot} -
- )} -
- ); -} - -ToolCard.displayName = 'ToolCard'; diff --git a/src/utils/__tests__/chromeTabs.test.ts b/src/utils/__tests__/chromeTabs.test.ts index c992863..b9aabd6 100644 --- a/src/utils/__tests__/chromeTabs.test.ts +++ b/src/utils/__tests__/chromeTabs.test.ts @@ -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(); - }); - }); }); diff --git a/src/utils/__tests__/clipboard.test.ts b/src/utils/__tests__/clipboard.test.ts index 568cb14..5aedb84 100644 --- a/src/utils/__tests__/clipboard.test.ts +++ b/src/utils/__tests__/clipboard.test.ts @@ -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) {} -} - -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); - }); - }); }); diff --git a/src/utils/__tests__/useContextMenuData.test.ts b/src/utils/__tests__/useContextMenuData.test.ts index e0c9b97..01b2818 100644 --- a/src/utils/__tests__/useContextMenuData.test.ts +++ b/src/utils/__tests__/useContextMenuData.test.ts @@ -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 = { diff --git a/src/utils/base64Converter.ts b/src/utils/base64Converter.ts index 54ee1f4..494f3b5 100644 --- a/src/utils/base64Converter.ts +++ b/src/utils/base64Converter.ts @@ -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 类型 */ diff --git a/src/utils/chromeI18n.ts b/src/utils/chromeI18n.ts index e172fd5..3442b96 100644 --- a/src/utils/chromeI18n.ts +++ b/src/utils/chromeI18n.ts @@ -66,11 +66,3 @@ export function useI18n(namespace?: string | string[]) { isLoaded: true, }; } - -/** - * 预加载命名空间(无操作,兼容 useLazyTranslation) - */ -export async function preloadNamespaces(_namespaces: string[]): Promise { - // chrome.i18n 是同步的,无需预加载 - return Promise.resolve(); -} diff --git a/src/utils/chromeTabs.ts b/src/utils/chromeTabs.ts index bf892f5..451a085 100644 --- a/src/utils/chromeTabs.ts +++ b/src/utils/chromeTabs.ts @@ -2,35 +2,6 @@ * Chrome 标签页相关工具函数 */ -/** - * 获取当前活动的标签页 - */ -export async function getActiveTab(): Promise { - 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 { - 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 { - 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; - } -} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index 0ea3a3e..d6605a5 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -11,21 +11,3 @@ export async function copyTextToClipboard(text: string): Promise { return false; } } - -/** - * 复制图片到剪贴板 - * @param blob 要复制的图片 - * @returns Promise 是否复制成功 - */ -export async function copyImageToClipboard(blob: Blob): Promise { - try { - await navigator.clipboard.write([ - new ClipboardItem({ - 'image/png': blob, - }), - ]); - return true; - } catch { - return false; - } -} diff --git a/src/utils/contextMenu.ts b/src/utils/contextMenu.ts index eac11b9..3269530 100644 --- a/src/utils/contextMenu.ts +++ b/src/utils/contextMenu.ts @@ -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; diff --git a/src/utils/jsonFormatter.ts b/src/utils/jsonFormatter.ts index 9ea3b68..ff6c14b 100644 --- a/src/utils/jsonFormatter.ts +++ b/src/utils/jsonFormatter.ts @@ -71,7 +71,7 @@ export function formatJson(text: string, options: JsonFormatOptions): JsonFormat /** * JSON 压缩结果 */ -export interface JsonMinifyResult { +interface JsonMinifyResult { /** 压缩后的 JSON 字符串 */ minified: string; /** 原始输入的字节大小 */ diff --git a/src/utils/jsonToToml.ts b/src/utils/jsonToToml.ts index e810a1e..78e24d3 100644 --- a/src/utils/jsonToToml.ts +++ b/src/utils/jsonToToml.ts @@ -1,7 +1,7 @@ /** * JSON 转 TOML 转换结果 */ -export interface JsonToTomlResult { +interface JsonToTomlResult { /** 转换后的 TOML 字符串 */ output: string; /** 原始输入的字节大小 */ diff --git a/src/utils/jsonToYaml.ts b/src/utils/jsonToYaml.ts index 546feb0..9920023 100644 --- a/src/utils/jsonToYaml.ts +++ b/src/utils/jsonToYaml.ts @@ -1,7 +1,7 @@ /** * JSON 转 YAML 转换结果 */ -export interface JsonToYamlResult { +interface JsonToYamlResult { /** 转换后的 YAML 字符串 */ output: string; /** 原始输入的字节大小 */ diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index bb9f677..78d56bb 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -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[]; diff --git a/src/utils/qrCodeParser.ts b/src/utils/qrCodeParser.ts index 946f8ba..63ef41c 100644 --- a/src/utils/qrCodeParser.ts +++ b/src/utils/qrCodeParser.ts @@ -1,6 +1,6 @@ import QrScanner from 'qr-scanner'; -export interface QrCodeParseResult { +interface QrCodeParseResult { success: boolean; data?: string; error?: string; diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts index 1238e07..65ba10f 100644 --- a/src/utils/storageCleaner.ts +++ b/src/utils/storageCleaner.ts @@ -224,7 +224,7 @@ async function runCleanScript( return { success: false, error: 'No result returned' }; } -export async function injectClearLocalStorage(tabId: number): Promise { +async function injectClearLocalStorage(tabId: number): Promise { return runCleanScript( tabId, () => { @@ -236,7 +236,7 @@ export async function injectClearLocalStorage(tabId: number): Promise { +async function injectClearSessionStorage(tabId: number): Promise { return runCleanScript( tabId, () => { @@ -248,7 +248,7 @@ export async function injectClearSessionStorage(tabId: number): Promise { +async function injectClearIndexedDB(tabId: number): Promise { return runCleanScript( tabId, async () => { @@ -292,7 +292,7 @@ export async function injectClearIndexedDB(tabId: number): Promise { +async function injectClearCacheStorage(tabId: number): Promise { return runCleanScript( tabId, async () => { @@ -309,7 +309,7 @@ export async function injectClearCacheStorage(tabId: number): Promise { +async function injectUnregisterServiceWorkers(tabId: number): Promise { 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; -} diff --git a/src/utils/useContextMenuData.ts b/src/utils/useContextMenuData.ts index 8c2b1c1..ecc7ea2 100644 --- a/src/utils/useContextMenuData.ts +++ b/src/utils/useContextMenuData.ts @@ -90,10 +90,3 @@ export async function saveContextMenuData( }; await storageUtil.set(STORAGE_KEY, pendingData); } - -/** - * 清除右键菜单待处理数据 - */ -export async function clearContextMenuData(): Promise { - await storageUtil.remove(STORAGE_KEY); -} diff --git a/src/utils/useDebounce.ts b/src/utils/useDebounce.ts deleted file mode 100644 index e9a70ac..0000000 --- a/src/utils/useDebounce.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useState, useEffect } from 'react'; - -/** - * useDebounce Hook - 防抖值 - * - * @param value - 需要防抖的值 - * @param delay - 延迟时间(毫秒) - * @returns 防抖后的值 - */ -export function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedValue(value); - }, delay); - - return () => { - clearTimeout(timer); - }; - }, [value, delay]); - - return debouncedValue; -} diff --git a/wxt.config.ts b/wxt.config.ts index fd671cd..9bbbf6b 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -58,8 +58,20 @@ export default defineConfig({ 'contextMenus', ], host_permissions: [''], + icons: { + '16': 'icon/16.png', + '32': 'icon/32.png', + '48': 'icon/48.png', + '96': 'icon/96.png', + '128': 'icon/128.png', + }, action: { default_title: '__MSG_appName__', + default_icon: { + '16': 'icon/16.png', + '32': 'icon/32.png', + '48': 'icon/48.png', + }, }, side_panel: { default_path: 'entrypoints/sidepanel/index.html',