feat: import saved favorites to batches
This commit is contained in:
+310
-2
@@ -16,6 +16,21 @@ import { promptForAudienceProfileFields } from "./audience-profile-field-dialog"
|
|||||||
import { promptForAuthorIds } from "./author-id-dialog";
|
import { promptForAuthorIds } from "./author-id-dialog";
|
||||||
import { promptForBatchName } from "./batch-name-dialog";
|
import { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
|
import { promptForFavoriteFolderName } from "./favorite-folder-dialog";
|
||||||
|
import { syncFavoriteRowPickers } from "./favorite-row-picker";
|
||||||
|
import {
|
||||||
|
createFavoritesDrawer,
|
||||||
|
type FavoritesDrawerController
|
||||||
|
} from "./favorites-drawer";
|
||||||
|
import { toFavoriteMarketRecords } from "./favorites-import";
|
||||||
|
import {
|
||||||
|
createChromeFavoritesStorage,
|
||||||
|
createFavoritesRepository,
|
||||||
|
type FavoriteCreatorInput,
|
||||||
|
type FavoriteFolder,
|
||||||
|
type FavoritesRepository,
|
||||||
|
type FavoritesStateV1
|
||||||
|
} from "./favorites-store";
|
||||||
import {
|
import {
|
||||||
applyRowOrder,
|
applyRowOrder,
|
||||||
applyRowVisibility,
|
applyRowVisibility,
|
||||||
@@ -79,7 +94,10 @@ export interface CreateMarketControllerOptions {
|
|||||||
options?: AudienceProfileCsvOptions
|
options?: AudienceProfileCsvOptions
|
||||||
) => string;
|
) => string;
|
||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
|
confirmFavoriteDelete?: (message: string) => boolean;
|
||||||
|
confirmFavoriteImport?: (message: string) => boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
favoritesRepository?: FavoritesRepository;
|
||||||
getAuthState?: () => Promise<AuthStateValue>;
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
|
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
|
||||||
loadBusinessAbility?: (
|
loadBusinessAbility?: (
|
||||||
@@ -104,6 +122,12 @@ export interface CreateMarketControllerOptions {
|
|||||||
onCsvReady?: (csv: string, filename?: string) => void;
|
onCsvReady?: (csv: string, filename?: string) => void;
|
||||||
promptAuthorIds?: () => Promise<string | null> | string | null;
|
promptAuthorIds?: () => Promise<string | null> | string | null;
|
||||||
promptBatchName?: () => Promise<string | null> | string | null;
|
promptBatchName?: () => Promise<string | null> | string | null;
|
||||||
|
promptFavoriteFolderName?: (
|
||||||
|
options: {
|
||||||
|
initialValue?: string;
|
||||||
|
title: "新建收藏夹" | "重命名收藏夹";
|
||||||
|
}
|
||||||
|
) => Promise<string | null> | string | null;
|
||||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||||
window: Window;
|
window: Window;
|
||||||
@@ -139,6 +163,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const loadBusinessAbility =
|
const loadBusinessAbility =
|
||||||
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
|
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
|
||||||
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||||
|
const favoritesRepository =
|
||||||
|
options.favoritesRepository ??
|
||||||
|
createFavoritesRepository({
|
||||||
|
storage: createChromeFavoritesStorage()
|
||||||
|
});
|
||||||
|
const confirmFavoriteImport =
|
||||||
|
options.confirmFavoriteImport ?? options.window.confirm.bind(options.window);
|
||||||
|
const confirmFavoriteDelete =
|
||||||
|
options.confirmFavoriteDelete ?? options.window.confirm.bind(options.window);
|
||||||
const mutationObserverFactory =
|
const mutationObserverFactory =
|
||||||
options.mutationObserverFactory ??
|
options.mutationObserverFactory ??
|
||||||
((callback: MutationCallback) => new MutationObserver(callback));
|
((callback: MutationCallback) => new MutationObserver(callback));
|
||||||
@@ -148,6 +181,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const promptAuthorIds =
|
const promptAuthorIds =
|
||||||
options.promptAuthorIds ??
|
options.promptAuthorIds ??
|
||||||
(() => promptForAuthorIds(options.document));
|
(() => promptForAuthorIds(options.document));
|
||||||
|
const promptFavoriteFolderName =
|
||||||
|
options.promptFavoriteFolderName ??
|
||||||
|
((dialogOptions: {
|
||||||
|
initialValue?: string;
|
||||||
|
title: "新建收藏夹" | "重命名收藏夹";
|
||||||
|
}) => promptForFavoriteFolderName(options.document, dialogOptions));
|
||||||
const submitBatch =
|
const submitBatch =
|
||||||
options.submitBatch ??
|
options.submitBatch ??
|
||||||
((payload: BatchPayload) =>
|
((payload: BatchPayload) =>
|
||||||
@@ -190,6 +229,25 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
let scheduledSyncTimeoutId: number | null = null;
|
let scheduledSyncTimeoutId: number | null = null;
|
||||||
const selectedAuthorIds = new Set<string>();
|
const selectedAuthorIds = new Set<string>();
|
||||||
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||||
|
let cachedFavoritesState: FavoritesStateV1 | undefined;
|
||||||
|
let favoritesRefreshVersion = 0;
|
||||||
|
let favoriteActionButtons: HTMLButtonElement[] = [];
|
||||||
|
const favoritesDrawer: FavoritesDrawerController = createFavoritesDrawer(
|
||||||
|
options.document,
|
||||||
|
{
|
||||||
|
onCreateFolder: async () => {
|
||||||
|
await createFavoriteFolder();
|
||||||
|
},
|
||||||
|
onDeleteFolder: deleteFavoriteFolder,
|
||||||
|
onImportFolder: async (_folderId, authorIds) => {
|
||||||
|
await submitFavoriteAuthors(authorIds);
|
||||||
|
},
|
||||||
|
onImportSelected: submitFavoriteAuthors,
|
||||||
|
onRemoveCreatorFromFolder: removeFavoriteCreatorFromFolder,
|
||||||
|
onRenameFolder: renameFavoriteFolder
|
||||||
|
},
|
||||||
|
confirmFavoriteImport
|
||||||
|
);
|
||||||
const observer = mutationObserverFactory(() => {
|
const observer = mutationObserverFactory(() => {
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
return;
|
return;
|
||||||
@@ -207,10 +265,14 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const selectionControlsMissing =
|
const selectionControlsMissing =
|
||||||
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
||||||
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
||||||
|
const favoriteControlsMissing =
|
||||||
|
cachedFavoritesState !== undefined &&
|
||||||
|
favoriteActionButtons.some((button) => !button.isConnected);
|
||||||
if (
|
if (
|
||||||
nextPageSignature === lastKnownPageSignature &&
|
nextPageSignature === lastKnownPageSignature &&
|
||||||
!toolbarNeedsRemount &&
|
!toolbarNeedsRemount &&
|
||||||
!selectionControlsMissing
|
!selectionControlsMissing &&
|
||||||
|
!favoriteControlsMissing
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -457,7 +519,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
};
|
};
|
||||||
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||||
|
|
||||||
const ready = runSyncCycle();
|
const ready = (async () => {
|
||||||
|
await runSyncCycle();
|
||||||
|
await refreshFavorites();
|
||||||
|
await waitForDomSettled();
|
||||||
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dispose() {
|
dispose() {
|
||||||
@@ -467,6 +533,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
options.window.clearTimeout(scheduledSyncTimeoutId);
|
options.window.clearTimeout(scheduledSyncTimeoutId);
|
||||||
scheduledSyncTimeoutId = null;
|
scheduledSyncTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
favoritesDrawer.dispose();
|
||||||
},
|
},
|
||||||
ready
|
ready
|
||||||
};
|
};
|
||||||
@@ -634,10 +701,199 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
applyRowOrder(table, records.map((record) => record.authorId));
|
applyRowOrder(table, records.map((record) => record.authorId));
|
||||||
bindSelectionControls(table);
|
bindSelectionControls(table);
|
||||||
syncMarketSelectionState(table, selectedAuthorIds);
|
syncMarketSelectionState(table, selectedAuthorIds);
|
||||||
|
syncFavoriteRowPickersForCurrentTable(table);
|
||||||
lastKnownPageSignature = readMarketPageSignature(options.document);
|
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshFavorites(): Promise<void> {
|
||||||
|
const refreshVersion = ++favoritesRefreshVersion;
|
||||||
|
try {
|
||||||
|
const state = await favoritesRepository.read();
|
||||||
|
if (isDisposed || refreshVersion !== favoritesRefreshVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedFavoritesState = state;
|
||||||
|
favoritesDrawer.setError("");
|
||||||
|
favoritesDrawer.render(state);
|
||||||
|
runWithoutMutationSync(() => {
|
||||||
|
syncFavoriteRowPickersForCurrentTable();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (isDisposed || refreshVersion !== favoritesRefreshVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
favoritesDrawer.setError("收藏夹暂不可用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFavoriteRowPickersForCurrentTable(
|
||||||
|
table = syncMarketTable(options.document)
|
||||||
|
): void {
|
||||||
|
if (!cachedFavoritesState || !table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIdsByAuthorId = new Map<string, Set<string>>();
|
||||||
|
cachedFavoritesState.memberships.forEach((membership) => {
|
||||||
|
const folderIds = folderIdsByAuthorId.get(membership.authorId) ?? new Set<string>();
|
||||||
|
folderIds.add(membership.folderId);
|
||||||
|
folderIdsByAuthorId.set(membership.authorId, folderIds);
|
||||||
|
});
|
||||||
|
const serializedCoreUserIds = readSerializedFavoriteCoreUserIds(options.document);
|
||||||
|
|
||||||
|
syncFavoriteRowPickers({
|
||||||
|
document: options.document,
|
||||||
|
folderIdsByAuthorId,
|
||||||
|
folders: cachedFavoritesState.folders,
|
||||||
|
onCreateFolder: createFavoriteFolder,
|
||||||
|
onSetCreatorFolderIds: setFavoriteCreatorFolderIds,
|
||||||
|
rows: table.rows.map((rowDom) => ({
|
||||||
|
actionCell: rowDom.actionCell,
|
||||||
|
authorId: rowDom.authorId,
|
||||||
|
authorName: rowDom.authorName,
|
||||||
|
coreUserId:
|
||||||
|
(rowDom as MarketRowDom & { coreUserId?: string }).coreUserId ??
|
||||||
|
serializedCoreUserIds.get(rowDom.authorId)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
favoriteActionButtons = Array.from(
|
||||||
|
options.document.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[data-sces-favorite-row-action="button"]'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFavoriteFolder(): Promise<FavoriteFolder | null> {
|
||||||
|
const name = await promptFavoriteFolderName({ title: "新建收藏夹" });
|
||||||
|
if (name === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return runFavoriteStorageMutation(async () => {
|
||||||
|
const folder = await favoritesRepository.createFolder(name);
|
||||||
|
await refreshFavorites();
|
||||||
|
return folder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFavoriteCreatorFolderIds(
|
||||||
|
creator: FavoriteCreatorInput,
|
||||||
|
folderIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
await runFavoriteStorageMutation(async () => {
|
||||||
|
await favoritesRepository.setCreatorFolderIds(creator, folderIds);
|
||||||
|
await refreshFavorites();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFavoriteFolder(folderId: string): Promise<void> {
|
||||||
|
const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId);
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = await promptFavoriteFolderName({
|
||||||
|
initialValue: folder.name,
|
||||||
|
title: "重命名收藏夹"
|
||||||
|
});
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runFavoriteStorageMutation(async () => {
|
||||||
|
await favoritesRepository.renameFolder(folderId, name);
|
||||||
|
await refreshFavorites();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFavoriteFolder(folderId: string): Promise<void> {
|
||||||
|
const folder = cachedFavoritesState?.folders.find((item) => item.id === folderId);
|
||||||
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmFavoriteDelete(`确定删除收藏夹“${folder.name}”吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runFavoriteStorageMutation(async () => {
|
||||||
|
await favoritesRepository.deleteFolder(folderId);
|
||||||
|
await refreshFavorites();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFavoriteCreatorFromFolder(
|
||||||
|
authorId: string,
|
||||||
|
folderId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await runFavoriteStorageMutation(async () => {
|
||||||
|
await favoritesRepository.removeCreatorFromFolder(authorId, folderId);
|
||||||
|
await refreshFavorites();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFavoriteStorageMutation<T>(
|
||||||
|
mutation: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
favoritesDrawer.setBusy(true);
|
||||||
|
try {
|
||||||
|
return await mutation();
|
||||||
|
} catch (error) {
|
||||||
|
favoritesDrawer.setError(readFavoriteOperationError(error));
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
favoritesDrawer.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFavoriteAuthors(authorIds: string[]): Promise<void> {
|
||||||
|
favoritesDrawer.setBusy(true);
|
||||||
|
try {
|
||||||
|
const requestedAuthorIds = new Set(
|
||||||
|
authorIds.map((authorId) => authorId.trim()).filter(Boolean)
|
||||||
|
);
|
||||||
|
const state = await favoritesRepository.read();
|
||||||
|
const records = toFavoriteMarketRecords(
|
||||||
|
state.creators.filter((creator) => requestedAuthorIds.has(creator.authorId))
|
||||||
|
);
|
||||||
|
if (records.length === 0) {
|
||||||
|
favoritesDrawer.setError("当前没有可导入的达人");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchName = await promptBatchName();
|
||||||
|
if (batchName === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!batchName.trim()) {
|
||||||
|
favoritesDrawer.setError("请输入批次名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authState = await getAuthState();
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
throw new Error("请先登录插件");
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitBatch(
|
||||||
|
createBatchPayload({
|
||||||
|
authState,
|
||||||
|
batchName,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
records
|
||||||
|
})
|
||||||
|
);
|
||||||
|
favoritesDrawer.setError("批次提交成功");
|
||||||
|
} catch (error) {
|
||||||
|
favoritesDrawer.setError(readFavoriteBatchError(error));
|
||||||
|
} finally {
|
||||||
|
favoritesDrawer.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
|
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
return;
|
return;
|
||||||
@@ -1629,6 +1885,58 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSerializedFavoriteCoreUserIds(document: Document): Map<string, string> {
|
||||||
|
const coreUserIds = new Map<string, string>();
|
||||||
|
const serializedRows = document.documentElement.getAttribute("data-sces-market-rows");
|
||||||
|
if (!serializedRows) {
|
||||||
|
return coreUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = JSON.parse(serializedRows) as unknown;
|
||||||
|
if (!Array.isArray(rows)) {
|
||||||
|
return coreUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (!row || typeof row !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorId = (row as { authorId?: unknown }).authorId;
|
||||||
|
const coreUserId = (row as { coreUserId?: unknown }).coreUserId;
|
||||||
|
if (
|
||||||
|
typeof authorId === "string" &&
|
||||||
|
authorId.trim() &&
|
||||||
|
typeof coreUserId === "string" &&
|
||||||
|
coreUserId.trim()
|
||||||
|
) {
|
||||||
|
coreUserIds.set(authorId.trim(), coreUserId.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return coreUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return coreUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFavoriteOperationError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "收藏夹操作失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFavoriteBatchError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "批次提交失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
function getNextSortState(
|
function getNextSortState(
|
||||||
currentSort: MarketSortState | undefined,
|
currentSort: MarketSortState | undefined,
|
||||||
field: MarketSortState["field"]
|
field: MarketSortState["field"]
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createMarketResultStore } from "../src/content/market/result-store";
|
import { createMarketResultStore } from "../src/content/market/result-store";
|
||||||
|
import {
|
||||||
|
createFavoritesRepository,
|
||||||
|
type FavoritesStorage
|
||||||
|
} from "../src/content/market/favorites-store";
|
||||||
import type { SpreadInfoConfig } from "../src/content/market/types";
|
import type { SpreadInfoConfig } from "../src/content/market/types";
|
||||||
|
|
||||||
const disposers: Array<() => void> = [];
|
const disposers: Array<() => void> = [];
|
||||||
@@ -20,6 +24,7 @@ describe("market-content-entry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||||
vi.doUnmock("../src/content/market/index");
|
vi.doUnmock("../src/content/market/index");
|
||||||
delete (
|
delete (
|
||||||
globalThis as typeof globalThis & {
|
globalThis as typeof globalThis & {
|
||||||
@@ -4544,6 +4549,291 @@ describe("market-content-entry", () => {
|
|||||||
?.textContent
|
?.textContent
|
||||||
).toBe("0.8% - 1%");
|
).toBe("0.8% - 1%");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mounts favorites controls without replacing the native action text", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
favoritesRepository: createTestFavoritesRepository(),
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
|
||||||
|
expect(document.querySelector('[data-sces-favorites-tab="button"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-testid="action-cell-111"] [data-sces-favorite-row-action="button"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="action-cell-111"]')?.textContent).toContain("下单");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saves the serialized market row identity through its favorite picker", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
|
||||||
|
{ authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
"data-sces-market-rows",
|
||||||
|
JSON.stringify([
|
||||||
|
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
const setCreatorFolderIds = vi.fn(repository.setCreatorFolderIds.bind(repository));
|
||||||
|
repository.setCreatorFolderIds = setCreatorFolderIds;
|
||||||
|
const promptFavoriteFolderName = vi.fn(() => "母婴优质达人");
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
promptFavoriteFolderName,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorite-row-action="button"]');
|
||||||
|
click('[data-sces-favorite-row-picker="create-folder"]');
|
||||||
|
await waitForMockCall(setCreatorFolderIds);
|
||||||
|
await (setCreatorFolderIds.mock.results[0]?.value as Promise<void>);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(repository.getStoredState()).toMatchObject({
|
||||||
|
creators: [
|
||||||
|
{
|
||||||
|
authorId: "111",
|
||||||
|
authorName: "达人 A",
|
||||||
|
coreUserId: "core-111"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
folders: [{ name: "母婴优质达人" }]
|
||||||
|
});
|
||||||
|
expect(promptFavoriteFolderName).toHaveBeenCalledWith({ title: "新建收藏夹" });
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-sces-favorite-row-action="button"]')?.dataset.scesFavoriteState
|
||||||
|
).toBe("saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a creator once in all favorites and in its selected folder", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
const firstFolder = await repository.createFolder("母婴优质达人");
|
||||||
|
const secondFolder = await repository.createFolder("品牌合作");
|
||||||
|
await repository.setCreatorFolderIds(
|
||||||
|
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" },
|
||||||
|
[firstFolder.id, secondFolder.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorites-tab="button"]');
|
||||||
|
expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1);
|
||||||
|
|
||||||
|
click(`[data-sces-favorites-folder-id="${secondFolder.id}"]`);
|
||||||
|
expect(document.querySelectorAll('[data-sces-favorites-author-id="111"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits selected favorites without scanning or exporting the market range", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
const folder = await repository.createFolder("母婴优质达人");
|
||||||
|
await repository.setCreatorFolderIds(
|
||||||
|
{ authorId: "111", authorName: "达人 A", coreUserId: "core-111" },
|
||||||
|
[folder.id]
|
||||||
|
);
|
||||||
|
const promptBatchName = vi.fn(() => "收藏夹批次");
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
const nextPage = document.querySelector('[data-testid="next-page"]') as HTMLButtonElement;
|
||||||
|
const onNextPage = vi.fn();
|
||||||
|
nextPage.addEventListener("click", onNextPage);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
getAuthState: async () => authenticatedTestState(),
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
promptBatchName,
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorites-tab="button"]');
|
||||||
|
const selected = document.querySelector(
|
||||||
|
'[data-sces-favorites-select-author-id="111"]'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
selected.click();
|
||||||
|
click('[data-sces-favorites-drawer="import-selected"]');
|
||||||
|
await waitForMockCall(submitBatch);
|
||||||
|
|
||||||
|
expect(promptBatchName).toHaveBeenCalledTimes(1);
|
||||||
|
expect(submitBatch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authors: [{ authorId: "111", authorName: "达人 A", authorUid: "core-111" }],
|
||||||
|
batchName: "收藏夹批次"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onNextPage).not.toHaveBeenCalled();
|
||||||
|
expect(repository.getStoredState()).toMatchObject({
|
||||||
|
creators: [{ authorId: "111" }]
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
|
||||||
|
).toBe("批次提交成功");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not prompt or submit a full folder when favorite import confirmation is declined", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
const folder = await repository.createFolder("母婴优质达人");
|
||||||
|
await repository.setCreatorFolderIds({ authorId: "111", authorName: "达人 A" }, [folder.id]);
|
||||||
|
const promptBatchName = vi.fn(() => "不应调用");
|
||||||
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
confirmFavoriteImport: () => false,
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
getAuthState: async () => authenticatedTestState(),
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
promptBatchName,
|
||||||
|
submitBatch,
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorites-tab="button"]');
|
||||||
|
click(`[data-sces-favorites-folder-id="${folder.id}"]`);
|
||||||
|
click('[data-sces-favorites-drawer="import-folder"]');
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(promptBatchName).not.toHaveBeenCalled();
|
||||||
|
expect(submitBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("honors favorite folder deletion confirmation", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
const folder = await repository.createFolder("母婴优质达人");
|
||||||
|
let shouldDelete = false;
|
||||||
|
const confirmFavoriteDelete = vi.fn(() => shouldDelete);
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
confirmFavoriteDelete,
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorites-tab="button"]');
|
||||||
|
click(`[data-sces-favorites-folder-delete="${folder.id}"]`);
|
||||||
|
await flush();
|
||||||
|
expect(repository.getStoredState()).toMatchObject({ folders: [{ id: folder.id }] });
|
||||||
|
|
||||||
|
shouldDelete = true;
|
||||||
|
click(`[data-sces-favorites-folder-delete="${folder.id}"]`);
|
||||||
|
await waitForCondition(
|
||||||
|
() => document.querySelector(`[data-sces-favorites-folder-delete="${folder.id}"]`) === null
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(repository.getStoredState()).toMatchObject({ folders: [] });
|
||||||
|
expect(confirmFavoriteDelete).toHaveBeenLastCalledWith(
|
||||||
|
"确定删除收藏夹“母婴优质达人”吗?"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a favorite mutation error without disabling CSV export", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
repository.createFolder = async () => {
|
||||||
|
throw new Error();
|
||||||
|
};
|
||||||
|
const buildCsv = vi.fn(() => "csv-output");
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
buildCsv,
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
onCsvReady: vi.fn(),
|
||||||
|
promptFavoriteFolderName: () => "母婴优质达人",
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await controller.ready;
|
||||||
|
click('[data-sces-favorite-row-action="button"]');
|
||||||
|
click('[data-sces-favorite-row-picker="create-folder"]');
|
||||||
|
await waitForCondition(
|
||||||
|
() =>
|
||||||
|
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent ===
|
||||||
|
"收藏夹操作失败,请稍后重试"
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportButton = document.querySelector(
|
||||||
|
'[data-plugin-export="button"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
expect(exportButton.disabled).toBe(false);
|
||||||
|
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||||
|
dispatchChange('[data-plugin-export-range="select"]');
|
||||||
|
removeDefaultSpreadMetricFilter();
|
||||||
|
click('[data-plugin-export="button"]');
|
||||||
|
await waitForMockCall(buildCsv, 40, 50);
|
||||||
|
expect(buildCsv).toHaveBeenCalledWith(expect.any(Array));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the market toolbar available when favorites storage cannot be read", async () => {
|
||||||
|
document.body.innerHTML = buildRealMarketFixture([
|
||||||
|
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
|
||||||
|
]);
|
||||||
|
const repository = createTestFavoritesRepository();
|
||||||
|
repository.read = async () => {
|
||||||
|
throw new Error("storage unavailable");
|
||||||
|
};
|
||||||
|
|
||||||
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
|
const controller = trackController(createMarketController({
|
||||||
|
document,
|
||||||
|
favoritesRepository: repository,
|
||||||
|
loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" }),
|
||||||
|
window
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(controller.ready).resolves.toBeUndefined();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-sces-favorites-drawer="error"]')?.textContent
|
||||||
|
).toBe("收藏夹暂不可用");
|
||||||
|
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearLocalStorage(): void {
|
function clearLocalStorage(): void {
|
||||||
@@ -5970,6 +6260,39 @@ function trackController<T extends { dispose?: () => void }>(controller: T): T {
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTestFavoritesRepository(initial: unknown = undefined) {
|
||||||
|
let storedValue = initial;
|
||||||
|
let nextFolderId = 0;
|
||||||
|
const storage: FavoritesStorage = {
|
||||||
|
async get() {
|
||||||
|
return storedValue;
|
||||||
|
},
|
||||||
|
async set(value) {
|
||||||
|
storedValue = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const repository = createFavoritesRepository({
|
||||||
|
createId: () => `test-folder-${++nextFolderId}`,
|
||||||
|
now: () => "2026-07-17T00:00:00.000Z",
|
||||||
|
storage
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign(repository, {
|
||||||
|
getStoredState() {
|
||||||
|
return storedValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticatedTestState() {
|
||||||
|
return {
|
||||||
|
isAuthenticated: true,
|
||||||
|
resource: "https://talent-search.intelligrow.cn",
|
||||||
|
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function expectButtonDisabled(selector: string, expected: boolean) {
|
function expectButtonDisabled(selector: string, expected: boolean) {
|
||||||
const element = document.querySelector(selector) as HTMLButtonElement | null;
|
const element = document.querySelector(selector) as HTMLButtonElement | null;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -6019,6 +6342,20 @@ async function waitForMockCall(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForCondition(
|
||||||
|
condition: () => boolean | Promise<boolean>,
|
||||||
|
maxAttempts = 40
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
if (await condition()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await flushWithTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Timed out waiting for condition");
|
||||||
|
}
|
||||||
|
|
||||||
function createDeferred<T>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user