Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/app/service/service_worker/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ 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 } from "uuid";
import { getCombinedMeta } from "./utils";

const enum ScriptMenuRegisterType {
REGISTER = 1,
Expand Down Expand Up @@ -370,9 +369,10 @@ export class PopupService {
run.isEffective = o.effective!;
run.hasUserConfig = !!script.config;
} else {
if (script.selfMetadata) {
script.metadata = getCombinedMeta(script.metadata, script.selfMetadata);
}
// 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要
// if (script.selfMetadata) {
// script.metadata = getCombinedMeta(script.metadata, script.selfMetadata);
// }
run = scriptToMenu(script);
run.isEffective = o.effective!;
}
Expand Down
4 changes: 3 additions & 1 deletion src/app/service/service_worker/popup_scriptmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const scriptToMenu = (script: Script): ScriptMenu => {
enable: script.status === SCRIPT_STATUS_ENABLE,
updatetime: script.updatetime || 0,
hasUserConfig: !!script.config,
metadata: script.metadata,
// 不需要完整 metadata。目前在 Popup 未使用 metadata。
// 有需要时请把 metadata 里需要的部份抽出 (例如 @match @include @exclude),避免 chrome.storage.session 储存量过大
// metadata: script.metadata,
runStatus: script.runStatus,
runNum: script.type === SCRIPT_TYPE_NORMAL ? 0 : script.runStatus === SCRIPT_RUN_STATUS_RUNNING ? 1 : 0,
runNumByIframe: 0,
Expand Down
6 changes: 4 additions & 2 deletions src/app/service/service_worker/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Script, SCRIPT_RUN_STATUS, SCMetadata, ScriptLoadInfo } from "@App/app/repo/scripts";
import type { Script, SCRIPT_RUN_STATUS, ScriptLoadInfo } from "@App/app/repo/scripts";
import { type URLRuleEntry } from "@App/pkg/utils/url_matcher";
import { type IGetSender } from "@Packages/message/server";

Expand Down Expand Up @@ -192,7 +192,9 @@ export type ScriptMenu = {
enable: boolean; // 脚本是否启用
updatetime: number; // 脚本更新时间
hasUserConfig: boolean; // 是否有用户配置
metadata: SCMetadata; // 脚本元数据
// 不需要完整 metadata。目前在 Popup 未使用 metadata。
// 有需要时请把 metadata 里需要的部份抽出 (例如 @match @include @exclude),避免 chrome.storage.session 储存量过大
// metadata: SCMetadata; // 脚本元数据
runStatus?: SCRIPT_RUN_STATUS; // 脚本运行状态
runNum: number; // 脚本运行次数
runNumByIframe: number; // iframe运行次数
Expand Down
8 changes: 5 additions & 3 deletions src/locales/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,21 @@ export function watchLanguageChange(callback: (lng: string) => void) {
};
}

export const i18nLang = (): string => `${i18n?.language?.toLowerCase()}`;

export function i18nName(script: { name: string; metadata: SCMetadata }) {
const m = script.metadata[`name:${i18n?.language?.toLowerCase()}`];
const m = script.metadata[`name:${i18nLang()}`];
return m ? m[0] : script.name;
}

export function i18nDescription(script: { metadata: SCMetadata }) {
const m = script.metadata[`description:${i18n?.language?.toLowerCase()}`];
const m = script.metadata[`description:${i18nLang()}`];
return m ? m[0] : script.metadata.description;
}

// 判断是否是中文用户
export function isChineseUser() {
const language = i18n?.language?.toLowerCase();
const language = i18nLang();
return language.startsWith("zh-");
}

Expand Down
102 changes: 81 additions & 21 deletions src/pages/components/ScriptMenuList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
IconPlus,
IconSettings,
} from "@arco-design/web-react/icon";
import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts";
import type { SCMetadata } from "@App/app/repo/scripts";
import { SCRIPT_RUN_STATUS_RUNNING, ScriptDAO } 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";
Expand All @@ -32,7 +33,10 @@ import type {
ScriptMenuItemOption,
} from "@App/app/service/service_worker/types";
import { popupClient, runtimeClient, scriptClient } from "@App/pages/store/features/script";
import { i18nName } from "@App/locales/locales";
import { i18nLang, i18nName } from "@App/locales/locales";

// 用于读取 metadata
const scriptDAO = new ScriptDAO();

const CollapseItem = Collapse.Item;

Expand Down Expand Up @@ -114,8 +118,8 @@ const MenuItem = React.memo(({ menuItems, uuid }: MenuItemProps) => {
MenuItem.displayName = "MenuItem";

interface CollapseHeaderProps {
item: ScriptMenu;
onEnableChange: (item: ScriptMenu, checked: boolean) => void;
item: ScriptMenuEntry;
onEnableChange: (item: ScriptMenuEntry, checked: boolean) => void;
}

const CollapseHeader = React.memo(
Expand Down Expand Up @@ -164,12 +168,12 @@ const CollapseHeader = React.memo(
CollapseHeader.displayName = "CollapseHeader";

interface ListMenuItemProps {
item: ScriptMenu;
item: ScriptMenuEntry;
scriptMenus: GroupScriptMenuItemsProp;
menuExpandNum: number;
isBackscript: boolean;
url: URL | null;
onEnableChange: (item: ScriptMenu, checked: boolean) => void;
onEnableChange: (item: ScriptMenuEntry, checked: boolean) => void;
handleDeleteScript: (uuid: string) => void;
}

Expand Down Expand Up @@ -320,6 +324,13 @@ ListMenuItem.displayName = "ListMenuItem";

type TGrouppedMenus = Record<string, GroupScriptMenuItemsProp> & { __length__?: number };

type ScriptMenuEntry = ScriptMenu & {
menuUpdated?: number;
metadata: SCMetadata;
};

let scriptDataAsyncCounter = 0;

// Popup 页面使用的脚本/选单清单元件:只负责渲染与互动,状态与持久化交由外部 client 处理。
const ScriptMenuList = React.memo(
({
Expand All @@ -335,23 +346,30 @@ const ScriptMenuList = React.memo(
currentUrl: string;
menuExpandNum: number;
}) => {
const [list, setList] = useState(
[] as (ScriptMenu & {
menuUpdated?: number;
})[]
);
// extraData 为 undefined 时先等待异步加载完成,避免重复渲染
const [extraData, setExtraData] = useState<
| {
uuids: string;
lang: string;
metadata: Record<string, SCMetadata>;
}
| undefined
>(undefined);
const [scriptMenuList, setScriptMenuList] = useState<ScriptMenuEntry[]>([]);
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

初始状态下 scriptMenuList 为空数组,会导致在异步加载 metadata 期间显示"无数据",即使实际上有脚本存在。这会造成不好的用户体验(闪烁)。

建议的解决方案:

  1. 初始状态设为 undefined 来区分"加载中"和"无数据":
const [scriptMenuList, setScriptMenuList] = useState<ScriptMenuEntry[] | undefined>(undefined);
  1. 在渲染时增加加载状态判断:
{scriptMenuList === undefined ? (
  <Spin /> // 或其他加载指示器
) : scriptMenuList.length === 0 ? (
  <Empty description={t("no_data")} />
) : (
  scriptMenuList.map(...)
)}

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有道理
可以研究一下

const { t } = useTranslation();

const [grouppedMenus, setGrouppedMenus] = useState<TGrouppedMenus>({});

// 依 groupKey 进行聚合:将同语义(mainframe/subframe)命令合并为单一分组以供 UI 呈现。
useEffect(() => {
const list_ = list;
const updateScriptMenuList = (scriptMenuList: ScriptMenuEntry[]) => {
setScriptMenuList(scriptMenuList);
// 因为 scriptMenuList 的修改只在这处。
// 直接在这里呼叫 setGrouppedMenus, 不需要 useEffect
setGrouppedMenus((prev) => {
// 依 groupKey 进行聚合:将同语义(mainframe/subframe)命令合并为单一分组以供 UI 呈现。
const ret = {} as TGrouppedMenus;
let changed = false;
let retLen = 0;
for (const { uuid, menus, menuUpdated: m } of list_) {
for (const { uuid, menus, menuUpdated: m } of scriptMenuList) {
retLen++;
const menuUpdated = m || 0;
if (prev[uuid]?.menuUpdated === menuUpdated) {
Expand Down Expand Up @@ -383,7 +401,7 @@ const ScriptMenuList = React.memo(
// 若无引用变更则维持原物件以降低重渲染
return changed ? ret : prev;
});
}, [list]);
};

const url = useMemo(() => {
let url: URL;
Expand All @@ -399,8 +417,50 @@ const ScriptMenuList = React.memo(
return url;
}, [currentUrl]);

// string memo 避免 uuids 以外的改变影响
const uuids = useMemo(() => script.map((item) => item.uuid).join("\n"), [script]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const lang = useMemo(() => i18nLang(), [t]); // 当 t 改变时,重新检查当前页面语言

// 以 异步方式 取得 metadata 放入 extraData
// script 或 extraData 的更新时都会再次执行
useEffect(() => {
if (extraData && extraData.uuids === uuids && extraData.lang === lang) {
// extraData 已取得
// 把 getPopupData() 的 scriptMenuList 和 异步结果 的 metadata 合并至 scriptMenuList
const metadata = extraData.metadata;
const newScriptMenuList = script.map((item) => ({ ...item, metadata: metadata[item.uuid] || {} }));
updateScriptMenuList(newScriptMenuList);
} else {
// 取得 extraData
scriptDataAsyncCounter = (scriptDataAsyncCounter % 255) + 1; // 轮出 1 ~ 255
const lastCounter = scriptDataAsyncCounter;
scriptDAO.gets(uuids.split("\n")).then((res) => {
if (lastCounter !== scriptDataAsyncCounter) {
// 由于 state 改变,在结果取得前 useEffect 再次执行,因此需要忽略上次结果
return;
}
const metadataRecord = {} as Record<string, SCMetadata>;
const nameKey = `name:${lang}`;
for (const entry of res) {
if (entry) {
const m = entry.metadata;
const [icon] = m.icon || m.iconurl || m.icon64 || m.icon64url || [];
// metadataRecord 的储存量不影响 storage.session 但影响页面的记忆体
// 按需要可以增加其他 metadata, 例如 @match @include @exclude
metadataRecord[entry.uuid] = {
icon: [icon], // 只储存单个 icon
[nameKey]: [i18nName(entry)], // 只储存 i18n 的 name
} satisfies SCMetadata;
}
}
setExtraData({ uuids, lang, metadata: metadataRecord });
// 再次触发 useEffect
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise 缺少错误处理。如果 scriptDAO.gets() 失败(例如 IndexedDB 访问错误),将会导致未捕获的 Promise 拒绝。建议添加 .catch() 处理错误情况,例如:

scriptDAO.gets(uuids.split("\n")).then((res) => {
  // ... 现有逻辑
}).catch((error) => {
  console.error("Failed to load script metadata:", error);
  // 降级处理:使用空的 metadata
  setExtraData({ uuids, lang, metadata: {} });
});
Suggested change
// 再次触发 useEffect
// 再次触发 useEffect
}).catch((error) => {
console.error("Failed to load script metadata:", error);
// 降级处理:使用空的 metadata
setExtraData({ uuids, lang, metadata: {} });

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

雖然不預期出錯

});
}
}, [script, uuids, lang, extraData]);

useEffect(() => {
setList(script);
// 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。
const checkItems = new Map();
for (const [_uuid, menus] of Object.entries(grouppedMenus)) {
Expand Down Expand Up @@ -429,7 +489,7 @@ const ScriptMenuList = React.memo(
checkItems.clear();
document.removeEventListener("keypress", sharedKeyPressListner);
};
}, [script, grouppedMenus]);
}, [grouppedMenus]);

const handleDeleteScript = (uuid: string) => {
// 本地先行移除列表项(乐观更新);若删除失败会显示错误讯息。
Expand All @@ -438,18 +498,18 @@ const ScriptMenuList = React.memo(
});
};

const onEnableChange = (item: ScriptMenu, checked: boolean) => {
const onEnableChange = (item: ScriptMenuEntry, checked: boolean) => {
scriptClient.enable(item.uuid, checked).catch((err) => {
Message.error(err);
});
};

return (
<>
{list.length === 0 ? (
{scriptMenuList.length === 0 ? (
<Empty description={t("no_data")} />
) : (
list.map((item, _index) => (
scriptMenuList.map((item, _index) => (
<ListMenuItem
key={`${item.uuid}`}
url={url}
Expand Down
1 change: 1 addition & 0 deletions tests/pages/popup/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ vi.mock("@App/locales/locales", () => ({
localePath: "",
initLocales: vi.fn(),
changeLanguage: vi.fn(),
i18nLang: vi.fn((): string => "en"),
i18nName: vi.fn((script: any) => script.name),
i18nDescription: vi.fn((script: any) => script.metadata?.description || ""),
matchLanguage: () => Promise.resolve(undefined),
Expand Down
Loading