From 09e73920e29abf298b63b4756f384d4059981a1c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 2 Oct 2025 19:41:30 +0900 Subject: [PATCH 01/25] =?UTF-8?q?=E9=87=8D=E6=96=B0=E4=BF=AE=E8=AE=A2=20`G?= =?UTF-8?q?M=5FregisterMenuCommand`=20=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/cache.ts | 2 +- src/app/service/content/gm_api.ts | 116 +++-- src/app/service/queue.ts | 16 +- src/app/service/service_worker/client.ts | 14 +- src/app/service/service_worker/gm_api.ts | 20 +- src/app/service/service_worker/popup.ts | 446 +++++++++++++----- src/app/service/service_worker/types.ts | 75 ++- src/pages/components/ScriptMenuList/index.tsx | 163 +++++-- src/pages/popup/App.tsx | 84 +++- 9 files changed, 664 insertions(+), 272 deletions(-) diff --git a/src/app/cache.ts b/src/app/cache.ts index e4b40e87d..cef2fa771 100644 --- a/src/app/cache.ts +++ b/src/app/cache.ts @@ -159,7 +159,7 @@ class Cache extends ExtCache { } }); unlock(); - return newValue!; + return newValue!; // 必须注意当 value 为 「undefined, null, "", 0」 时,newValue 是 undefined } incr(key: string, increase: number): Promise { diff --git a/src/app/service/content/gm_api.ts b/src/app/service/content/gm_api.ts index 2f7211447..648f970cb 100644 --- a/src/app/service/content/gm_api.ts +++ b/src/app/service/content/gm_api.ts @@ -1,7 +1,13 @@ import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; -import type { NotificationMessageOption, ScriptMenuItem } from "../service_worker/types"; -import { base64ToBlob, strToBase64 } from "@App/pkg/utils/utils"; +import type { + GMRegisterMenuCommandParam, + GMUnRegisterMenuCommandParam, + NotificationMessageOption, + ScriptMenuItemOption, + TScriptMenuItemID, +} from "../service_worker/types"; +import { base64ToBlob, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -19,7 +25,15 @@ export interface IGM_Base { emitEvent(event: string, eventId: string, data: any): void; } -const integrity = {}; // 僅防止非法实例化 +const integrity = {}; // 仅防止非法实例化 + +const execEnvInit = (execEnv: GMApi) => { + if (!execEnv.contentEnvKey) { + execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 + execEnv.menuKeyRegistered = new Set(); + execEnv.menuIdCounter = 0; + } +}; // GM_Base 定义内部用变量和函数。均使用@protected // 暂不考虑 Object.getOwnPropertyNames(GM_Base.prototype) 和 ts-morph 脚本生成 @@ -440,52 +454,60 @@ export default class GMApi extends GM_Base { _GM_cookie(this, action, details, done); } - menuMap: Map | undefined; + // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 + // 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。 + menuKeyRegistered: Set | undefined; + + // 自动产生的菜单 ID 累计器(仅在未提供 options.id 时使用)。 + // 每个 contentEnvKey(执行环境)初始化时会重设;不持久化、只保证当前环境内递增唯一。 + menuIdCounter: number | undefined; + + // 内容脚本执行环境识别符,用于区分 mainframe / subframe 等环境并作为 menu key 的命名空间。 + // 由 execEnvInit() 以 randomMessageFlag() 生成,避免跨 frame 的 ID 碰撞。 + // (同一环境跨脚本也不一样) + contentEnvKey: string | undefined; @GMContext.API({ alias: "GM.registerMenuCommand" }) GM_registerMenuCommand( name: string, listener: (inputValue?: any) => void, - options_or_accessKey?: ScriptMenuItem["options"] | string - ): number { - if (!this.menuMap) { - this.menuMap = new Map(); - } - if (typeof options_or_accessKey === "object") { - const option: ScriptMenuItem["options"] = options_or_accessKey; - // 如果是对象,并且有id属性,则直接使用id - if (option.id && this.menuMap.has(option.id)) { - // 如果id存在,则直接使用 - this.EE.removeAllListeners("menuClick:" + option.id); - this.EE.addListener("menuClick:" + option.id, listener); - this.sendMessage("GM_registerMenuCommand", [option.id, name, option]); - return option.id; - } + options_or_accessKey?: ScriptMenuItemOption | string + ): TScriptMenuItemID { + execEnvInit(this); + // 浅拷贝避免修改/共用参数 + const options = ( + typeof options_or_accessKey === "string" + ? { accessKey: options_or_accessKey } + : options_or_accessKey + ? { ...options_or_accessKey } + : {} + ) as ScriptMenuItemOption; + let providedId: string | number | undefined = options.id; + delete options.id; // id不直接储存在options (id 影响 groupKey 操作) + if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + const ret = providedId; + // providedId = (typeof providedId === "number" ? `n${providedId}` : `t${providedId}`) as string; + providedId = `t${providedId}`; // 先跟随 TM 数字id跟字串id视为一致。日后有需要再使用 options 区分两者 + providedId = `${this.contentEnvKey!}.${providedId}`; // 区分 subframe mainframe + const menuKey = providedId as string; // menuKey为唯一键:{环境识别符}.t{注册ID} + // 检查之前有否注册 + if (menuKey && this.menuKeyRegistered!.has(menuKey)) { + // 有注册过,先移除 listeners + this.EE.removeAllListeners("menuClick:" + menuKey); } else { - options_or_accessKey = { accessKey: options_or_accessKey }; - let flag = 0; - this.menuMap.forEach((val, menuId) => { - if (val === name) { - flag = menuId; - } - }); - if (flag) { - return flag; - } + // 没注册过,先记录一下 + this.menuKeyRegistered!.add(menuKey); } - this.eventId += 1; - const id = this.eventId; - options_or_accessKey.id = id; - this.menuMap.set(id, name); - this.EE.addListener("menuClick:" + id, listener); - this.sendMessage("GM_registerMenuCommand", [id, name, options_or_accessKey]); - return id; + this.EE.addListener("menuClick:" + menuKey, listener); + // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) + this.sendMessage("GM_registerMenuCommand", [menuKey, name, options] as GMRegisterMenuCommandParam); + return ret; } @GMContext.API({ depend: ["GM_registerMenuCommand"], }) - CAT_registerMenuInput(...args: Parameters): number { + CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { return this.GM_registerMenuCommand(...args); } @@ -543,13 +565,15 @@ export default class GMApi extends GM_Base { } @GMContext.API({ alias: "GM.unregisterMenuCommand" }) - GM_unregisterMenuCommand(id: number): void { - if (!this.menuMap) { - this.menuMap = new Map(); - } - this.menuMap.delete(id); - this.EE.removeAllListeners("menuClick:" + id); - this.sendMessage("GM_unregisterMenuCommand", [id]); + GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { + execEnvInit(this); + // let menuKey = (typeof menuId === "number" ? `n${menuId}` : `t${menuId}`) as string; + let menuKey = `t${menuId}`; // 先跟随 TM 数字id跟字串id视为一致。日后有需要再使用 options 区分两者 + menuKey = `${this.contentEnvKey!}.${menuKey}`; // 区分 subframe mainframe // menuKey为唯一键:{环境识别符}.t{注册ID} + this.menuKeyRegistered!.delete(menuKey); + this.EE.removeAllListeners("menuClick:" + menuKey); + // 发送至 service worker 处理(唯一键) + this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); } @GMContext.API({ @@ -1213,8 +1237,8 @@ export default class GMApi extends GM_Base { } } -// 從 GM_Base 對象中解構出 createGMBase 函数並導出(可供其他模塊使用) +// 从 GM_Base 对象中解构出 createGMBase 函数并导出(可供其他模块使用) export const { createGMBase } = GM_Base; -// 從 GMApi 對象中解構出內部函數,用於後續本地使用,不導出 +// 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_xmlhttpRequest } = GMApi; diff --git a/src/app/service/queue.ts b/src/app/service/queue.ts index 670a4caf8..52cae439d 100644 --- a/src/app/service/queue.ts +++ b/src/app/service/queue.ts @@ -1,5 +1,10 @@ import type { Script, SCRIPT_RUN_STATUS, SCRIPT_STATUS, SCRIPT_TYPE } from "../repo/scripts"; -import type { InstallSource, ScriptMenuItem } from "./service_worker/types"; +import type { + InstallSource, + ScriptMenuItemOption, + TScriptMenuItemKey, + TScriptMenuItemName, +} from "./service_worker/types"; import type { Subscribe } from "../repo/subscribe"; export type TInstallScriptParams = { @@ -29,17 +34,18 @@ export type TScriptValueUpdate = { script: Script }; export type TScriptMenuRegister = { uuid: string; - id: number; - name: string; - options?: ScriptMenuItem["options"]; + key: TScriptMenuItemKey; + name: TScriptMenuItemName; + options?: Omit; tabId: number; frameId?: number; documentId?: string; }; export type TScriptMenuUnregister = { - id: number; + key: TScriptMenuItemKey; uuid: string; tabId: number; frameId?: number; + documentId?: string; }; diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index bcb1cb4f9..5714d3c42 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -9,9 +9,10 @@ import type { ScriptMenuItem, SearchType, TBatchUpdateListAction, + TScriptMenuItemKey, } from "./types"; import { Client } from "@Packages/message/client"; -import type { MessageSend } from "@Packages/message/types"; +import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; import type PermissionVerify from "./permission_verify"; import { type UserConfirm } from "./permission_verify"; import { type FileSystemType } from "@Packages/filesystem/factory"; @@ -281,6 +282,13 @@ export type GetPopupDataRes = { backScriptList: ScriptMenu[]; }; +export type MenuClickParams = { + uuid: string; + key: TScriptMenuItemKey; + sender: ExtMessageSender; + inputValue?: any; +}; + export class PopupClient extends Client { constructor(msgSender: MessageSend) { super(msgSender, "serviceWorker/popup"); @@ -293,14 +301,14 @@ export class PopupClient extends Client { menuClick(uuid: string, data: ScriptMenuItem, inputValue?: any) { return this.do("menuClick", { uuid, - id: data.id, + key: data.key, inputValue, sender: { tabId: data.tabId, frameId: data.frameId, documentId: data.documentId, }, - }); + } as MenuClickParams); } } diff --git a/src/app/service/service_worker/gm_api.ts b/src/app/service/service_worker/gm_api.ts index 7e0213a0b..e2458ad26 100644 --- a/src/app/service/service_worker/gm_api.ts +++ b/src/app/service/service_worker/gm_api.ts @@ -19,7 +19,14 @@ import FileSystemFactory from "@Packages/filesystem/factory"; import type FileSystem from "@Packages/filesystem/filesystem"; import { isWarpTokenError } from "@Packages/filesystem/error"; import { joinPath } from "@Packages/filesystem/utils"; -import type { EmitEventRequest, MessageRequest, NotificationMessageOption, Request } from "./types"; +import type { + EmitEventRequest, + GMRegisterMenuCommandParam, + GMUnRegisterMenuCommandParam, + MessageRequest, + NotificationMessageOption, + Request, +} from "./types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../queue"; import { BrowserNoSupport, notificationsUpdate } from "./utils"; @@ -782,11 +789,11 @@ export default class GMApi { @PermissionVerify.API({ alias: ["CAT_registerMenuInput"] }) GM_registerMenuCommand(request: Request, sender: IGetSender) { - const [id, name, options] = request.params; + const [key, name, options] = request.params as GMRegisterMenuCommandParam; // 触发菜单注册, 在popup中处理 this.mq.emit("registerMenuCommand", { uuid: request.script.uuid, - id, + key, name, options, tabId: sender.getSender()?.tab?.id || -1, @@ -797,13 +804,14 @@ export default class GMApi { @PermissionVerify.API({ alias: ["CAT_unregisterMenuInput"] }) GM_unregisterMenuCommand(request: Request, sender: IGetSender) { - const [id] = request.params; + const [key] = request.params as GMUnRegisterMenuCommandParam; // 触发菜单取消注册, 在popup中处理 this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, - id: id, + key, tabId: sender.getSender()?.tab?.id || -1, frameId: sender.getSender()?.frameId, + documentId: sender.getSender()?.documentId, }); } @@ -862,7 +870,7 @@ export default class GMApi { return tabData || {}; }) .then((data) => { - return data[sender.getExtMessageSender().tabId]; + return data![sender.getExtMessageSender().tabId]; }); } diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 944d5a77c..1d8ec49b6 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -1,9 +1,8 @@ import { type IMessageQueue } from "@Packages/message/message_queue"; import { type Group } from "@Packages/message/server"; -import type { ExtMessageSender } from "@Packages/message/types"; import { type RuntimeService } from "./runtime"; -import type { TScriptMatchInfoEntry, ScriptMenu } from "./types"; -import type { GetPopupDataReq, GetPopupDataRes } from "./client"; +import type { TScriptMatchInfoEntry, ScriptMenu, ScriptMenuItem } from "./types"; +import type { GetPopupDataReq, GetPopupDataRes, MenuClickParams } from "./client"; import { cacheInstance } from "@App/app/cache"; import type { Script, ScriptDAO } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; @@ -19,17 +18,63 @@ import { getStorageName, getCurrentTab } from "@App/pkg/utils/utils"; import type { SystemConfig } from "@App/pkg/config/config"; import { CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; import { timeoutExecution } from "@App/pkg/utils/timer"; +import { v5 as uuidv5, v4 as uuidv4 } from "uuid"; type TxUpdateScriptMenuCallback = ( result: ScriptMenu[] ) => Promise | ScriptMenu[] | undefined; +// 以 tabId 为 key 的「执行次数」快取(字串形式存放),供 badge 显示使用。 const runCountMap = new Map(); + +// 以 tabId 为 key 的「脚本数量」快取,供 badge 显示使用。 const scriptCountMap = new Map(); + +// 已设定过 badge 的 tabId 集合;切换到「不显示数字」时用来清除既有 badge。 const badgeShownSet = new Set(); +// 用于 timeoutExecution 的唯一前缀 key(含随机片段),避免不同 tab 的排程互相覆盖。 const cIdKey = `(cid_${Math.random()})`; +// uuidv5 的命名空间:用来稳定生成 groupKey,将「相同性质」的 menu 合并显示。 +const groupKeyNS = "43b9b9b1-75b7-4054-801c-1b0ad6b6b07b"; + +// -------------------------------------------------------------------------------------------------- + +// Chrome 限制:contextMenu 的 id 必须稳定不可频繁改变 +// (例如:id-1 一次放在 index0,接著 removeAll 后又放到 index8,再 removeAll 又放到 index4) +// 推测是 Chrome 内部程式码没有预期到 menu id 大量增加/删除/跳跃 +// 因此使用 chrome.contextMenus.create 建立新 id 的 menu item 时会发生冲突 +// 如果 tab 切换,id 若跟随 script.uuid 变化,冲突更严重 +// 会导致菜单项目可能无法正确显示 +// 解法:整个浏览器共用一批固定的 uuidv4 作为 contextMenu 项目 id(不分 tab) + +// 共用且稳定的 Chrome contextMenu 显示用 id 池(uuidv4 阵列),避免频繁新建 id 造成错乱。 +const contextMenuConvArr = [] as string[]; +// SC 内部 id → Chrome 显示 id 的映射表(用于把 parentId/子项关联到稳定的显示 id)。 +const contextMenuConvMap1 = new Map(); +// Chrome 显示 id → SC 内部 id 的反向映射表(用于点击事件回推原始 SC id)。 +const contextMenuConvMap2 = new Map(); + +// -------------------------------------------------------------------------------------------------- + +let lastActiveTabId = 0; +let menuRegisterNeedUpdate = false; + +// -------------------------------------------------------------------------------------------------- + +// menuRegister 用来保存「当前 Tab」的 menu 状态。 +// 其中会包含 mainframe 和 subframe 的项目。 +// 最后会再透过 groupKey 做整合显示。 + +// 每个 tab 的脚本菜单暂存;key 为 `${tabId}.${uuid}`,值为该脚本在该 tab 的菜单项(含 mainframe/subframe)。 +const menuRegister = new Map(); + +// -------------------------------------------------------------------------------------------------- + +// 串接中的更新承诺:序列化 genScriptMenu 执行,避免并行重建 contextMenu。 +let contextMenuUpdatePromise = Promise.resolve(); + // 处理popup页面的数据 export class PopupService { constructor( @@ -40,116 +85,225 @@ export class PopupService { private systemConfig: SystemConfig ) {} - genScriptMenuByTabMap(menu: ScriptMenu[]) { - let n = 0; + // 将 ScriptMenu[] 转为 Chrome contextMenus.CreateProperties[];同一 groupKey 仅保留一个实际显示项。 + genScriptMenuByTabMap(menuEntries: chrome.contextMenus.CreateProperties[], menu: ScriptMenu[]) { for (const { uuid, name, menus } of menu) { - // 如果是带输入框的菜单则不在页面内注册 - const nonInputMenus = menus.filter((item) => !item.options?.inputType); - // 创建脚本菜单 - if (nonInputMenus.length) { - n += nonInputMenus.length; - chrome.contextMenus.create({ - id: `scriptMenu_${uuid}`, + const subMenuEntries = [] as chrome.contextMenus.CreateProperties[]; + let withMenuItem = false; + const groupKeys = new Map(); + for (const { name, options, groupKey } of menus) { + if (options?.inputType) continue; // 如果是带输入框的菜单则不在页面内注册 + if (groupKeys.has(groupKey)) continue; + groupKeys.set(groupKey, name); + } + for (const [groupKey, name] of groupKeys) { + if (!name) continue; // 日后再调整 name 为空的情况 + // 创建菜单 + const menuUid = `scriptMenu_menu_${uuid}_${groupKey}`; + const createProperties = { + id: menuUid, + // id: `id${Math.floor(Math.random() * 9000 + 9000).toString(32)}`, title: name, contexts: ["all"], - parentId: "scriptMenu", - }); - nonInputMenus.forEach((menu) => { - // 创建菜单 - chrome.contextMenus.create({ - id: `scriptMenu_menu_${uuid}_${menu.id}`, - title: menu.name, + parentId: `scriptMenu_${uuid}`, // 上层是 `scriptMenu_${uuid}` + } as chrome.contextMenus.CreateProperties; + withMenuItem = true; // 日后或引入菜单分隔线的设计。 withMenuItem = true 表示实际菜单选项有。 + subMenuEntries.push(createProperties); + } + if (withMenuItem) { + // 创建脚本菜单 + menuEntries.push( + { + id: `scriptMenu_${uuid}`, + title: name, contexts: ["all"], - parentId: `scriptMenu_${uuid}`, - }); - }); + parentId: "scriptMenu", + }, + ...subMenuEntries + ); } } - return n; } // 生成chrome菜单 - async genScriptMenu(tabId: number) { - // 移除之前所有的菜单 - await chrome.contextMenus.removeAll(); + async genScriptMenu() { + // 使用简单 Promise chain 避免同一个程序同时跑 + contextMenuUpdatePromise = contextMenuUpdatePromise + .then(async () => { + const tabId = lastActiveTabId; + if (tabId === 0) return; + const menuEntries = [] as chrome.contextMenus.CreateProperties[]; + const displayType = await this.systemConfig.getScriptMenuDisplayType(); + if (displayType === "all") { + const [menu, backgroundMenu] = await Promise.all([this.getScriptMenu(tabId), this.getScriptMenu(-1)]); + if (menu?.length) this.genScriptMenuByTabMap(menuEntries, menu); + if (backgroundMenu?.length) this.genScriptMenuByTabMap(menuEntries, backgroundMenu); // 后台脚本的菜单 + if (menuEntries.length > 0) { + // 创建根菜单 + // 若有子项才建立根节点「ScriptCat」,避免出现空的顶层菜单。 + menuEntries.unshift({ + id: "scriptMenu", + title: "ScriptCat", + contexts: ["all"], + }); + } + } - if ((await this.systemConfig.getScriptMenuDisplayType()) !== "all") { - return; - } + // 移除之前所有的菜单 + await chrome.contextMenus.removeAll(); + contextMenuConvMap1.clear(); + contextMenuConvMap2.clear(); - const [menu, backgroundMenu] = await Promise.all([this.getScriptMenu(tabId), this.getScriptMenu(-1)]); - if (!menu.length && !backgroundMenu.length) { - return; - } - let n = 0; - // 创建根菜单 - chrome.contextMenus.create({ - id: "scriptMenu", - title: "ScriptCat", - contexts: ["all"], + let i = 0; + for (const menuEntry of menuEntries) { + // 菜单项目用的共通 uuid. 不会随 tab 切换或换页换iframe载入等行为改变。稳定id + // 以固定槽位 i 对应稳定显示 id:即使 removeAll 重建,显示 id 仍保持一致以规避 Chrome 的不稳定行为。 + const menuDisplayId = contextMenuConvArr[i] || (contextMenuConvArr[i] = uuidv4()); + // 把 SC管理用id 换成 menu显示用id + if (menuEntry.id) { + // 建立 SC id ↔ 显示 id 的双向映射:parentId/点击回推都依赖此映射。 + contextMenuConvMap1.set(menuEntry.id!, menuDisplayId); // 用于parentId转换menuDisplayId + contextMenuConvMap2.set(menuDisplayId, menuEntry.id!); // 用于menuDisplayId转换成SC管理用id + menuEntry.id = menuDisplayId; + } + if (menuEntry.parentId) { + menuEntry.parentId = contextMenuConvMap1.get(menuEntry.parentId) || menuEntry.parentId; + } + + i++; + // 由于使用旧id,旧的内部context menu item应会被重用因此不会造成记忆体失控。 + // (推论内部有cache机制,即使removeAll也是有残留) + chrome.contextMenus.create(menuEntry); + } + }) + .catch(console.warn); + } + + // 在多次本地记录操作后,只需要执行一次有锁记录更新。不用更新回传 false + // 将「无锁的本地 menuRegister」同步回「有锁的快取」;若无实质变更则不发布更新事件。 + async syncMenuCommandsToSessionStore(tabId: number, uuid: string): Promise { + let retUpdated = false; + await this.txUpdateScriptMenu(tabId, async (data) => { + const script = data.find((item) => item.uuid === uuid); + if (script && menuRegisterNeedUpdate) { + menuRegisterNeedUpdate = false; + retUpdated = true; + // 本地纪录(最新)复制到外部存取(有锁) + script.menus = [...(menuRegister.get(`${tabId}.${uuid}`) || [])]; + } + return data; }); - if (menu) { - n += this.genScriptMenuByTabMap(menu); - } - // 后台脚本的菜单 - if (backgroundMenu) { - n += this.genScriptMenuByTabMap(backgroundMenu); + if (retUpdated) { + this.mq.publish("popupMenuRecordUpdated", { tabId, uuid }); } - if (n === 0) { - // 如果没有菜单,删除菜单 - await chrome.contextMenus.remove("scriptMenu"); + return retUpdated; + } + + // 标记需要同步后,若成功写回快取,再触发实际菜单重建(避免多次小变更重复重建)。 + async onMenuCommandsChanged(tabId: number, uuid: string) { + menuRegisterNeedUpdate = true; + const didTxRecordUpdate = await this.syncMenuCommandsToSessionStore(tabId, uuid); + if (didTxRecordUpdate) { + // 更新数据后再更新菜单 + await this.updateScriptMenu(tabId); } } async registerMenuCommand(message: TScriptMenuRegister) { + // GM_registerMenuCommand 是同步函数。 + // 所以流程是:先在 popup.ts 即时更新「无锁的记录」,再回写到「有锁记录」交给 Popup/App.tsx。 + // 这样可以避免新增/删除操作的次序冲突。 + // 外部(popup.tsx)只会读取 menu items,不会直接修改(若要修改,必须透过 popup.ts)。 + // 给脚本添加菜单 - await this.txUpdateScriptMenu(message.tabId, async (data) => { - const script = data.find((item) => item.uuid === message.uuid); - if (script) { - const menu = script.menus.find((item) => item.id === message.id); - if (!menu) { - script.menus.push({ - id: message.id, - name: message.name, - options: message.options, - tabId: message.tabId, - frameId: message.frameId, - documentId: message.documentId, - }); - } else { - // 存在修改信息 - menu.name = message.name; - menu.options = message.options; - } + + const { key, name, uuid, tabId } = message; // 唯一键, 项目显示名字, 脚本uuid + // message.key是唯一的。 即使在同一tab里的mainframe subframe也是不一样 + + // 以 `${tabId}.${uuid}` 作为隔离命名空间,避免跨分页/框架互相干扰。 + const mrKey = `${tabId}.${uuid}`; // 防止多分页间的registerMenuCommand互相影响 + + let menus = menuRegister.get(mrKey) as ScriptMenuItem[]; + if (!menus) { + menus = [] as ScriptMenuItem[]; + menuRegister.set(mrKey, menus); + } + + // 以 options+name 生成稳定 groupKey:相同语义项目在 UI 只呈现一次,但可同时触发多个来源(frame)。 + // groupKey 用来表示「相同性质的项目」,允许重叠。 + // 例如 subframe 和 mainframe 创建了相同的 menu item,显示时只会出现一个。 + // 但点击后,两边都会执行。 + // 目的只是整理显示,实际上内部还是存有多笔 entry(分别记录不同的 frameId 和 id)。 + const groupKey = uuidv5( + JSON.stringify({ ...(message.options || {}), autoClose: "", id: "", name: name }), + groupKeyNS + ); + + let found = false; + for (const item of menus) { + if (item.key === key) { + found = true; + // 存在修改信息 + item.name = name; + item.options = { ...message.options }; + item.groupKey = groupKey; + break; } - return data; - }); - // 更新数据后再更新菜单 - await this.updateScriptMenu(); + } + if (!found) { + const entry = { + groupKey, + key: key, // unique primary key + name: name, + options: message.options, + tabId: tabId, // fix + frameId: message.frameId, // fix with unique key + documentId: message.documentId, // fix with unique key + }; + menus.push(entry); + } + // 更新有锁记录 + await this.onMenuCommandsChanged(tabId, uuid); } - async unregisterMenuCommand({ id, uuid, tabId }: TScriptMenuUnregister) { - await this.txUpdateScriptMenu(tabId, async (data) => { - // 删除脚本菜单 - const script = data.find((item) => item.uuid === uuid); - if (script) { - script.menus = script.menus.filter((item) => item.id !== id); + async unregisterMenuCommand({ key, uuid, tabId }: TScriptMenuUnregister) { + const mrKey = `${tabId}.${uuid}`; + + let menus = menuRegister.get(mrKey) as ScriptMenuItem[]; + if (!menus) { + menus = [] as ScriptMenuItem[]; + menuRegister.set(mrKey, menus); + } + + for (let i = 0, l = menus.length; i < l; i++) { + if (menus[i].key === key) { + menus.splice(i, 1); + break; } - return data; - }); - await this.updateScriptMenu(); + } + // 更新有锁记录 + await this.onMenuCommandsChanged(tabId, uuid); } - async updateScriptMenu() { - // 获取当前页面并更新菜单 - const tab = await getCurrentTab(); - // 生成菜单 - if (tab?.id) { - await this.genScriptMenu(tab.id); + async updateScriptMenu(tabId: number) { + if (tabId !== lastActiveTabId) return; // 其他页面的指令,不理 + + // 注意:不要使用 getCurrentTab()。 + // 因为如果使用者切换到其他应用(如 Excel/Photoshop),网页仍可能触发 menu 的注册/解除操作。 + // 若此时用 getCurrentTab(),就无法正确更新右键选单。 + + // 检查一下 tab的有效性 + // 仅针对目前 lastActiveTabId 进行检查与更新,避免误在非当前 tab 重建菜单。 + const tab = await chrome.tabs.get(lastActiveTabId); + if (tab && !tab.frozen && tab.active && !tab.discarded && tab.lastAccessed) { + // 更新菜单 / 生成菜单 + await this.genScriptMenu(); } } - scriptToMenu(script: Script): ScriptMenu { + // 将 Script 转为 ScriptMenu 并初始化其在该 tab 的菜单暂存(menus 空阵列、计数归零)。 + scriptToMenu(script: Script, tabId: number): ScriptMenu { + menuRegister.set(`${tabId}.${script.uuid}`, []); return { uuid: script.uuid, name: script.name, @@ -168,12 +322,14 @@ export class PopupService { // 获取popup页面数据 async getPopupData(req: GetPopupDataReq): Promise { + const { url, tabId } = req; const [matchingResult, runScripts, backScriptList] = await Promise.all([ - this.runtime.getPageScriptMatchingResultByUrl(req.url, true), - this.getScriptMenu(req.tabId), + this.runtime.getPageScriptMatchingResultByUrl(url, true), + this.getScriptMenu(tabId), this.getScriptMenu(-1), ]); // 与运行时脚本进行合并 + // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); // 合并后结果 const scriptMenuMap = new Map(); @@ -187,11 +343,13 @@ export class PopupService { run.isEffective = o.effective!; run.hasUserConfig = !!script.config; } else { - run = this.scriptToMenu(script); + run = this.scriptToMenu(script, tabId); run.isEffective = o.effective!; } scriptMenuMap.set(script.uuid, run); } + + // 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。 // 把运行了但是不在匹配中的脚本加入到菜单的最后 (因此 runMap 和 scriptMenuMap 分开成两个变数) for (const script of runScripts) { // 把运行了但是不在匹配中的脚本加入菜单 @@ -201,7 +359,7 @@ export class PopupService { } const scriptMenu = [...scriptMenuMap.values()]; // 检查是否在黑名单中 - const isBlacklist = this.runtime.isUrlBlacklist(req.url); + const isBlacklist = this.runtime.isUrlBlacklist(url); // 后台脚本只显示开启或者运行中的脚本 return { isBlacklist, scriptList: scriptMenu, backScriptList }; } @@ -212,7 +370,8 @@ export class PopupService { } // 事务更新脚本菜单 - txUpdateScriptMenu(tabId: number, callback: TxUpdateScriptMenuCallback) { + // 以快取层的事务操作安全更新某 tab 的 ScriptMenu 阵列,避免竞态条件。 + txUpdateScriptMenu(tabId: number, callback: TxUpdateScriptMenuCallback): Promise { const cacheKey = `${CACHE_KEY_TAB_SCRIPT}${tabId}`; return cacheInstance.tx(cacheKey, (menu) => callback(menu || [])); } @@ -220,6 +379,7 @@ export class PopupService { async addScriptRunNumber({ tabId, frameId, scripts }: { tabId: number; frameId: number; scripts: Script[] }) { // 设置数据 return await this.txUpdateScriptMenu(tabId, async (data) => { + // 特例:frameId 为 0/未提供时,重置当前 tab 的计数资料(视为页面重新载入)。 if (!frameId) { data = []; } @@ -227,12 +387,13 @@ export class PopupService { scripts.forEach((script) => { const scriptMenu = data.find((item) => item.uuid === script.uuid); if (scriptMenu) { + // runNum:累计总执行次数;runNumByIframe:仅 iframe 执行次数(用于精细显示/统计)。 scriptMenu.runNum = (scriptMenu.runNum || 0) + 1; if (frameId) { scriptMenu.runNumByIframe = (scriptMenu.runNumByIframe || 0) + 1; } } else { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, tabId); item.isEffective = true; item.runNum = 1; if (frameId) { @@ -251,6 +412,7 @@ export class PopupService { }); } + // 处理「非页面型(background)」脚本的安装/启用/删除/状态变更,并同步其菜单至 tabId = -1 的命名空间。 dealBackgroundScriptInstall() { // 处理后台脚本 this.mq.subscribe("installScript", async (data) => { @@ -269,7 +431,7 @@ export class PopupService { const scriptMenu = menu.find((item) => item.uuid === script.uuid); // 加入菜单 if (!scriptMenu) { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, -1); menu.push(item); } return menu; @@ -289,7 +451,7 @@ export class PopupService { if (script.status === SCRIPT_STATUS_ENABLE) { // 加入菜单 if (index === -1) { - const item = this.scriptToMenu(script); + const item = this.scriptToMenu(script, -1); menu.push(item); } } else { @@ -329,33 +491,26 @@ export class PopupService { }); } - async menuClick({ - uuid, - id, - sender, - inputValue, - }: { - uuid: string; - id: number; - sender: ExtMessageSender; - inputValue?: any; - }) { + // 触发目标 tab/frame 的「menuClick」事件;key 为菜单唯一键以定位对应 listener。 + async menuClick({ uuid, key, sender, inputValue }: MenuClickParams) { // 菜单点击事件 await this.runtime.emitEventToTab(sender, { uuid, event: "menuClick", - eventId: id.toString(), + eventId: `${key}`, data: inputValue, }); return true; } - async updateBadgeIcon(tabId: number | undefined = -1) { - if (tabId < 0) { - const tab = await getCurrentTab(); - tabId = tab?.id; - } - if (typeof tabId !== "number") return; + async updateBadgeIcon() { + // badge 显示数字的策略: + // - script_count:显示脚本数 + // - run_count:显示执行次数 + // - 其他:不显示数字 + // 如果切换为「不显示数字」模式,需要清空已经显示过的 badge。 + const tabId = lastActiveTabId; + if (!tabId) return; const badgeNumberType: string = await this.systemConfig.getBadgeNumberType(); let map: Map | undefined; if (badgeNumberType === "script_count") { @@ -377,6 +532,7 @@ export class PopupService { if (typeof text !== "string") return; const backgroundColor = await this.systemConfig.getBadgeBackgroundColor(); const textColor = await this.systemConfig.getBadgeTextColor(); + // 标记此 tab 的 badge 已设定,便于后续在「不显示」模式时进行清理。 badgeShownSet.add(tabId); timeoutExecution( `${cIdKey}-tabId#${tabId}`, @@ -417,6 +573,10 @@ export class PopupService { } runCountMap.delete(tabId); scriptCountMap.delete(tabId); + const mrKeys = [...menuRegister.keys()].filter((key) => key.startsWith(`${tabId}.`)); + for (const key of mrKeys) { + menuRegister.delete(key); + } // 清理数据tab关闭需要释放的数据 this.txUpdateScriptMenu(tabId, async (scripts) => { for (const { uuid } of scripts) { @@ -433,6 +593,15 @@ export class PopupService { }); }); // 监听页面切换加载菜单 + // 进程启动时可能尚未触发 onActivated:补一次初始化以建立当前 tab 的菜单与 badge。 + getCurrentTab().then((tab) => { + // 处理载入时未触发 chrome.tabs.onActivated 的情况 + if (!lastActiveTabId && tab?.id) { + lastActiveTabId = tab.id; + this.genScriptMenu(); + this.updateBadgeIcon(); + } + }); chrome.tabs.onActivated.addListener((activeInfo) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -440,8 +609,11 @@ export class PopupService { // 没有 tabId 资讯,无法加载菜单 return; } - this.genScriptMenu(activeInfo.tabId); - this.updateBadgeIcon(activeInfo.tabId); + lastActiveTabId = activeInfo.tabId; + // 目前设计:subframe 和 mainframe 的 contextMenu 是共用的。 + // 换句话说,subframe 的右键菜单可以执行 mainframe 的选项,反之亦然。 + this.genScriptMenu(); + this.updateBadgeIcon(); }); // chrome.tabs.onUpdated.addListener((tabId, _changeInfo, _tab) => { // const lastError = chrome.runtime.lastError; @@ -469,9 +641,15 @@ export class PopupService { // 出现错误不处理chrome菜单点击 return; } - const menuIds = `${info.menuItemId}`.split("_"); + // 先以显示 id 逆向查回 SC 内部 id(防 Chrome 映射差异),再依 `scriptMenu_menu_${uuid}_${groupKey}` 解析来源。 + const id1 = info.menuItemId; + const id2 = contextMenuConvMap2.get(`${id1}`) || id1; + const id9 = id2; + // scriptMenu_menu_${uuid}_${groupKey}` + if (!`${id9}`.startsWith("scriptMenu_menu_")) return; // 不处理非 scriptMenu_menu_ 开首的 + const menuIds = `${id9}`.split("_"); if (menuIds.length === 4) { - const [, , uuid, id] = menuIds; + const [, , uuid, groupKey] = menuIds; // 寻找menu信息 const menu = await this.getScriptMenu(tab!.id!); let script = menu.find((item) => item.uuid === uuid); @@ -483,19 +661,22 @@ export class PopupService { bgscript = true; } if (script) { - const menuItem = script.menus.find((item) => item.id === parseInt(id, 10)); - if (menuItem) { - await this.menuClick({ - uuid: script.uuid, - id: menuItem.id, - sender: { - tabId: bgscript ? -1 : tab!.id!, - frameId: menuItem.frameId || 0, - documentId: menuItem.documentId || "", - }, - }); - return; - } + // 仅触发「非输入型」且 groupKey 相符的项目;同 groupKey 可能代表多个 frame 来源,一次性全部触发。 + const menuItems = script.menus.filter((item) => item.groupKey === groupKey && !item.options?.inputType); + await Promise.allSettled( + menuItems.map((menuItem) => + this.menuClick({ + uuid: script.uuid, + key: menuItem.key, + sender: { + tabId: bgscript ? -1 : tab!.id!, + frameId: menuItem.frameId || 0, + documentId: menuItem.documentId || "", + }, + } as MenuClickParams) + ) + ); + return; } } }); @@ -504,12 +685,15 @@ export class PopupService { // runCountMap.clear(); // 监听运行次数 + // 监听页面载入事件以更新脚本执行计数;若为当前活动 tab,同步刷新 badge。 this.mq.subscribe( "pageLoad", async ({ tabId, frameId, scripts }: { tabId: number; frameId: number; document: string; scripts: Script[] }) => { await this.addScriptRunNumber({ tabId, frameId, scripts }); - // 设置角标 - await this.updateBadgeIcon(tabId); + // 设置角标 (chrome.tabs.onActivated 切换后) + if (lastActiveTabId > 0 && tabId === lastActiveTabId) { + await this.updateBadgeIcon(); + } } ); } diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5e03ffa22..68855db55 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -64,25 +64,72 @@ export type Api = (request: Request, con: IGetSender) => Promise; // popup +export type ScriptMenuItemOption = { + id?: number; + autoClose?: boolean; + title?: string; + accessKey?: string; + // 可选输入框 + inputType?: "text" | "number" | "boolean"; + inputLabel?: string; + inputDefaultValue?: string | number | boolean; + inputPlaceholder?: string; +}; + +// 根据 TM (Tampermonkey) 定义: +// GM_registerMenuCommand 会回传一个「累计数字 ID」,由系统自动生成。 +// 如透过 options.id 传入自订的 ID,则回传型别为 string 或 number。 +// 注意:这个 ID 主要是「脚本执行环境内部使用」,而不是用来直接显示。 +export type TScriptMenuItemID = number | string; + +// 为了语意清楚:用于 menu item 显示名称的型别 +// 显示在右键选单上的「文字名称」。 +// 例如「开启设定」、「清除快取」。 +export type TScriptMenuItemName = string; + +// 为了语意清楚:用于 menu item 唯一键值的型别 +// 每个 menu item 的「唯一键」,用来辨识是否同一个命令。 +// 即使名称一样,key 也必须唯一。 +// 通常由 GM_registerMenuCommand 信息传递时传入。 +export type TScriptMenuItemKey = string; + +// 单一的选单项目结构。 +// - groupKey:用来把「相同性质」的项目合并(例如 mainframe / subframe 都注册相同命令)。 +// - key:唯一键,对应 GM_registerMenuCommand 信息传递的第一个参数。 +// - name:显示文字。 +// - options:选单的额外设定,例如是否是输入框、是否自动关闭等。 +// - tabId:表示来自哪个分页,-1 表示背景脚本。 +// - frameId / documentId:用于区分 iframe 或特定文件。 export type ScriptMenuItem = { - id: number; - name: string; - options?: { - id?: number; - autoClose?: boolean; - title?: string; - accessKey?: string; - // 可选输入框 - inputType?: "text" | "number" | "boolean"; - inputLabel?: string; - inputDefaultValue?: string | number | boolean; - inputPlaceholder?: string; - }; + groupKey: string; + key: TScriptMenuItemKey; + name: TScriptMenuItemName; + options?: ScriptMenuItemOption; tabId: number; //-1表示后台脚本 frameId?: number; documentId?: string; }; +// 一组选单项目,对应到一个脚本 (uuid)。 +// - uuid:脚本唯一 ID。 +// - groupKey:分组键,确保 UI 显示时不重复。 +// - menus:此脚本在当前分页的所有选单项目。 +export type GroupScriptMenuItem = { + uuid: string; + groupKey: string; + menus: ScriptMenuItem[]; +}; + +// GM_registerMenuCommand 信息传递的呼叫参数型别: +// [唯一键, 显示名称, options(不包含 id 属性)] +// 使用范例:GM_registerMenuCommand信息传递("myKey", "开启设定", { autoClose: true }); +export type GMRegisterMenuCommandParam = [TScriptMenuItemKey, TScriptMenuItemName, Omit]; + +// GM_unregisterMenuCommand 信息传递的呼叫参数型别: +// [唯一键] +// 使用范例:GM_unregisterMenuCommand信息传递("myKey"); +export type GMUnRegisterMenuCommandParam = [TScriptMenuItemKey]; + export type ScriptMenu = { uuid: string; // 脚本uuid name: string; // 脚本名称 @@ -95,7 +142,7 @@ export type ScriptMenu = { runNum: number; // 脚本运行次数 runNumByIframe: number; // iframe运行次数 menus: ScriptMenuItem[]; // 脚本菜单 - isEffective: boolean | null; // 是否在当前网址啟动 + isEffective: boolean | null; // 是否在当前网址启动 }; export type TBatchUpdateRecord = diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx index ff053e8c0..6ba8575e9 100644 --- a/src/pages/components/ScriptMenuList/index.tsx +++ b/src/pages/components/ScriptMenuList/index.tsx @@ -25,7 +25,12 @@ import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; import { RiPlayFill, RiStopFill } from "react-icons/ri"; import { useTranslation } from "react-i18next"; import { ScriptIcons } from "@App/pages/options/routes/utils"; -import type { ScriptMenu, ScriptMenuItem } from "@App/app/service/service_worker/types"; +import type { + GroupScriptMenuItem, + ScriptMenu, + ScriptMenuItem, + ScriptMenuItemOption, +} from "@App/app/service/service_worker/types"; import { popupClient, runtimeClient, scriptClient } from "@App/pages/store/features/script"; import { messageQueue, systemConfig } from "@App/pages/store/global"; import { i18nName } from "@App/locales/locales"; @@ -33,26 +38,34 @@ import { type TScriptRunStatus } from "@App/app/service/queue"; const CollapseItem = Collapse.Item; -const sendMenuAction = (uuid: string, menu: ScriptMenuItem, inputValue?: any) => { - popupClient.menuClick(uuid, menu, inputValue).then(() => { - menu.options?.autoClose !== false && window.close(); +const sendMenuAction = ( + uuid: string, + name: string, + options: ScriptMenuItemOption | undefined, + menus: ScriptMenuItem[], + inputValue?: any +) => { + Promise.allSettled(menus.map((menu) => popupClient.menuClick(uuid, menu, inputValue))).then(() => { + options?.autoClose !== false && window.close(); }); }; const FormItem = Form.Item; type MenuItemProps = { - menu: ScriptMenuItem; + menuItems: ScriptMenuItem[]; uuid: string; }; -const MenuItem = React.memo(({ menu, uuid }: MenuItemProps) => { - const initialValue = menu.options?.inputDefaultValue; +const MenuItem = React.memo(({ menuItems, uuid }: MenuItemProps) => { + const menuItem = menuItems[0]; + const { name, options } = menuItem; + const initialValue = options?.inputDefaultValue; const InputMenu = (() => { - const placeholder = menu.options?.inputPlaceholder; + const placeholder = options?.inputPlaceholder; - switch (menu.options?.inputType) { + switch (options?.inputType) { case "text": return ; case "number": @@ -74,7 +87,7 @@ const MenuItem = React.memo(({ menu, uuid }: MenuItemProps) => { onSubmit={(v) => { const inputValue = v.inputValue; console.log(v); - sendMenuAction(uuid, menu, inputValue); + sendMenuAction(uuid, name, options, menuItems, inputValue); }} > {InputMenu && ( @@ -154,6 +167,7 @@ CollapseHeader.displayName = "CollapseHeader"; interface ListMenuItemProps { item: ScriptMenu; + scriptMenus: GroupScriptMenuItem[]; menuExpandNum: number; isBackscript: boolean; url: URL | null; @@ -162,7 +176,7 @@ interface ListMenuItemProps { } const ListMenuItem = React.memo( - ({ item, menuExpandNum, isBackscript, url, onEnableChange, handleDeleteScript }: ListMenuItemProps) => { + ({ item, scriptMenus, menuExpandNum, isBackscript, url, onEnableChange, handleDeleteScript }: ListMenuItemProps) => { const { t } = useTranslation(); const [isEffective, setIsEffective] = useState(item.isEffective); @@ -173,14 +187,14 @@ const ListMenuItem = React.memo( }; const visibleMenus = useMemo(() => { - const m = item.menus; + const m = scriptMenus; return m.length > menuExpandNum && !isExpand ? m.slice(0, menuExpandNum) : m; - }, [item.menus, isExpand, menuExpandNum]); + }, [scriptMenus, isExpand, menuExpandNum]); - const shouldShowMore = useMemo(() => item.menus.length > menuExpandNum, [item.menus, menuExpandNum]); + const shouldShowMore = useMemo(() => scriptMenus.length > menuExpandNum, [scriptMenus, menuExpandNum]); - const handleExcludeUrl = (item: ScriptMenu, excludePattern: string, isExclude: boolean) => { - scriptClient.excludeUrl(item.uuid, excludePattern, isExclude).finally(() => { + const handleExcludeUrl = (uuid: string, excludePattern: string, isExclude: boolean) => { + scriptClient.excludeUrl(uuid, excludePattern, isExclude).finally(() => { setIsEffective(isExclude); }); }; @@ -227,7 +241,7 @@ const ListMenuItem = React.memo( status="warning" type="secondary" icon={!isEffective ? : } - onClick={() => handleExcludeUrl(item, `*://${url.host}/*`, !isEffective)} + onClick={() => handleExcludeUrl(item.uuid, `*://${url.host}/*`, !isEffective)} > {(!isEffective ? t("exclude_on") : t("exclude_off")).replace("$0", `${url.host}`)} @@ -244,9 +258,10 @@ const ListMenuItem = React.memo(
- {/* 判断菜单数量,再判断是否展开 */} - {visibleMenus.map((menu) => { - return ; + {/* 依数量与展开状态决定要显示的分组项(收合时只显示前 menuExpandNum 笔) */} + {visibleMenus.map(({ uuid, groupKey, menus }) => { + // 不同脚本之间可能出现相同的 groupKey;为避免 React key 冲突,需加上 uuid 做区分。 + return ; })} {shouldShowMore && (