From bee8cb0207a3061f3b0742ae61de1b65118d9a99 Mon Sep 17 00:00:00 2001 From: admin123 Date: Thu, 23 Apr 2026 16:20:14 +0800 Subject: [PATCH] feat: refine market action bar and metrics sync --- .../2026-04-23-market-native-action-bar.md | 161 +++++++ ...6-04-23-market-native-action-bar-design.md | 94 ++++ src/content/market/batch-payload.ts | 2 +- src/content/market/dom-sync.ts | 202 ++++++--- src/content/market/index.ts | 101 +++-- src/content/market/plugin-toolbar.ts | 423 +++++++++++------- src/shared/backend-metrics-config.ts | 3 +- tests/backend-metrics-client.test.ts | 12 +- tests/batch-payload.test.ts | 4 +- tests/market-content-entry.test.ts | 337 +++++++------- 10 files changed, 892 insertions(+), 447 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-23-market-native-action-bar.md create mode 100644 docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md diff --git a/docs/superpowers/plans/2026-04-23-market-native-action-bar.md b/docs/superpowers/plans/2026-04-23-market-native-action-bar.md new file mode 100644 index 0000000..c00239f --- /dev/null +++ b/docs/superpowers/plans/2026-04-23-market-native-action-bar.md @@ -0,0 +1,161 @@ +# Market Native Action Bar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the plugin export actions into Xingtu's native market action row and remove the old top toolbar and unused filter/sort controls. + +**Architecture:** Keep the existing export and batch-submit business logic, but replace the toolbar mount point and DOM shape. The new toolbar becomes a small inline action bar inserted into the native button row beside `自定义指标` and `导出`, while the controller only depends on export-range and action buttons. + +**Tech Stack:** TypeScript, Chrome MV3 content script, jsdom/Vitest + +--- + +## File Map + +- Modify: `src/content/market/plugin-toolbar.ts` + - Replace the top toolbar with an inline native-style action bar. +- Modify: `src/content/market/index.ts` + - Remove filter/sort toolbar dependencies. +- Modify: `tests/market-content-entry.test.ts` + - Update toolbar assertions to the new placement and reduced control set. + +### Task 1: Lock The New Toolbar Shape In Tests + +**Files:** +- Modify: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Write the failing toolbar placement tests** + +Add tests that assert: +- the plugin toolbar is inserted next to the native `自定义指标` / `导出` action row +- the toolbar no longer renders filter/sort controls +- the toolbar still renders export range, custom page input, export button, batch button, and status text + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row"` +Expected: FAIL because the toolbar still prepends to `document.body` and still contains filter/sort controls. + +- [ ] **Step 3: Add a failing busy-state regression test** + +Assert that during export: +- export button is disabled +- batch button is disabled +- export range select is disabled +- custom page input is disabled when visible + +- [ ] **Step 4: Run the focused busy-state test** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "exporting all pages disables the native action bar controls during the task"` +Expected: FAIL only if the new toolbar structure breaks existing busy-state selectors. + +### Task 2: Implement The Native Action Bar + +**Files:** +- Modify: `src/content/market/plugin-toolbar.ts` + +- [ ] **Step 1: Replace the toolbar mount strategy** + +Implement a helper that finds the native action row containing `自定义指标` and `导出`, then inserts the plugin root into that row. + +- [ ] **Step 2: Replace the toolbar DOM structure** + +Create only: +- export range select +- custom pages input +- export button +- batch submit button +- export status text + +Remove creation and lookup of: +- filter inputs +- filter button +- sort field select +- sort direction select +- sort button + +- [ ] **Step 3: Apply native-style button and inline layout styling** + +Use lightweight inline styles / class reuse so the plugin controls visually align with the native button row. + +- [ ] **Step 4: Keep custom-range visibility logic working** + +Preserve: +- `current` +- `first-5` +- `first-10` +- `all` +- `custom` + +When `custom` is selected, show the input; otherwise hide it. + +- [ ] **Step 5: Run the focused tests to verify green** + +Run: + +```bash +npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row|exporting all pages disables the native action bar controls during the task" +``` + +Expected: PASS + +### Task 3: Remove Toolbar Filter/Sort Dependencies + +**Files:** +- Modify: `src/content/market/index.ts` +- Modify: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Remove toolbar filter/sort read paths** + +Delete the toolbar handlers and state reads that depend on removed controls. + +- [ ] **Step 2: Keep export and batch submission behavior intact** + +Ensure: +- export still reads the selected range +- batch submit still reads the selected range +- status text still updates during progress and completion + +- [ ] **Step 3: Update affected tests** + +Adjust tests that previously asserted filter/sort button disabled state so they assert only the remaining controls. + +- [ ] **Step 4: Run focused regression tests** + +Run: + +```bash +npx vitest run tests/market-content-entry.test.ts -t "custom export range blocks invalid page counts|prompts for a batch name before submitting the current range|exporting all pages disables the native action bar controls during the task" +``` + +Expected: PASS + +### Task 4: Final Verification + +**Files:** +- Verify only: `src/content/market/plugin-toolbar.ts` +- Verify only: `src/content/market/index.ts` +- Verify only: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Run the targeted market content tests** + +Run: + +```bash +npx vitest run tests/market-content-entry.test.ts +``` + +- [ ] **Step 2: Run build** + +Run: + +```bash +npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/content/market/plugin-toolbar.ts src/content/market/index.ts tests/market-content-entry.test.ts docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md docs/superpowers/plans/2026-04-23-market-native-action-bar.md +git commit -m "feat: move market actions into native action bar" +``` diff --git a/docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md b/docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md new file mode 100644 index 0000000..8319690 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md @@ -0,0 +1,94 @@ +# Market Native Action Bar Design + +## Goal + +把插件当前位于页面顶部的工具栏移除,并将真正保留的市场页动作迁移到星图原生操作区中,与 `自定义指标` 和原生 `导出` 处于同一行。 + +## Confirmed Decisions + +- 删除页面顶部整块插件工具栏。 +- 只保留这几个插件能力: + - 导出范围选择 + - 自定义页数输入 + - `导出 CSV` + - `提交批次` + - 状态文案 +- 排序和筛选控件从插件工具栏中删除。 +- 新的插件操作区放在 `自定义指标` 和原生 `导出` 左侧。 +- 主按钮风格尽量复用星图当前页的原生 `xt-button` / `el-button` 样式。 +- 不新增弹窗式复杂交互;导出范围继续直接显示在操作区内。 + +## Layout + +### Placement + +- 在市场页标题区按钮行内查找 `自定义指标` 和原生 `导出` 所在的横向容器。 +- 在该容器中插入一个插件 action bar。 +- 插件 action bar 放在这两个原生按钮的左边。 + +### Visual Structure + +插件 action bar 包含: + +- `导出范围` 下拉框 +- `自定义页数` 输入框,仅在 `自定义` 范围下显示 +- `导出 CSV` 按钮 +- `提交批次` 按钮 +- 状态文本 + +视觉目标: + +- 两个主按钮与原生按钮高度、圆角、边框、字体风格一致 +- 范围选择器和页数输入框比按钮略窄,但整体高度对齐 +- 状态文案弱化显示,不抢占主要视觉注意力 + +## Behavior + +- `导出 CSV` 和 `提交批次` 的业务逻辑保持不变。 +- 导出范围逻辑保持不变: + - 当前页 + - 前 5 页 + - 前 10 页 + - 全部 + - 自定义 +- 选择 `自定义` 时显示页数输入框,否则隐藏。 +- 正在导出或提交时: + - `导出 CSV` 按钮禁用 + - `提交批次` 按钮禁用 + - 范围下拉禁用 + - 自定义页数输入禁用 +- 顶部原工具栏不再出现。 + +## DOM Strategy + +- `ensurePluginToolbar()` 不再固定 prepend 到 `document.body`。 +- 它改为: + - 先查找市场页原生操作区 + - 在原生按钮组内创建或复用插件 action bar + - 若页面局部重渲染导致节点丢失,内容脚本在后续同步中重新确保其存在 +- 工具栏 DOM 结构只保留本次需要的字段,删除筛选和排序输入控件。 + +## Controller Impact + +- `src/content/market/index.ts` 不再从工具栏读取筛选和排序输入值。 +- 现有点击表头排序的能力本轮不主动扩展,但不以顶部工具栏为依赖。 +- 忙碌状态、状态文本、导出范围读取逻辑继续保留。 + +## Testing + +需要增加或更新测试,覆盖: + +- 顶部旧工具栏不再挂载到 `document.body` 顶部 +- 新 action bar 挂载到原生按钮组,并位于 `自定义指标` / `导出` 左侧 +- 工具栏只保留导出范围、自定义页数、导出、提交、状态文案 +- 自定义范围显示/隐藏页数输入框 +- 导出和提交忙碌状态仍能正确禁用相关控件 +- 自定义范围校验和批次提交路径不回归 + +## Out of Scope + +- 不新增新的筛选 UI +- 不新增新的排序 UI +- 不重做表头排序交互 +- 不改动 CSV 内容结构 +- 不改动批次 payload 结构 diff --git a/src/content/market/batch-payload.ts b/src/content/market/batch-payload.ts index ee7974c..fbff457 100644 --- a/src/content/market/batch-payload.ts +++ b/src/content/market/batch-payload.ts @@ -40,7 +40,7 @@ export function createBatchPayload(options: { authorId: record.authorId, authorName: record.authorName })), - batchId: `${batchName}-${options.createdAt}`, + batchId: `${logtoUserId}-${options.createdAt}`, batchName, createdAt: options.createdAt, creatorName: diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 7330430..c880846 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -535,8 +535,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { ) as Record; const priceColumn = findPreviousColumn(actionColumn); const priceCells = priceColumn ? getDirectContentCells(priceColumn) : []; - const vueMarketRows = readVueMarketRows(root); - const serializedMarketRows = readSerializedMarketRows(root.ownerDocument); + const remainingVueMarketRows = [...readVueMarketRows(root)]; + const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)]; const rows = authorCells.flatMap((authorCell, index) => { const singleCell = singleCells[index] ?? null; @@ -561,23 +561,27 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { const rowCells = alignedRowCells.filter( (cell): cell is HTMLElement => cell !== null ); - const vueMarketRow = vueMarketRows[index] ?? null; - const serializedMarketRow = serializedMarketRows[index] ?? null; + const directAuthorId = extractAuthorId(authorCell) || ""; + const directAuthorName = extractAuthorName(authorCell) || ""; + const vueMarketRow = takeMatchedMarketDataRow( + remainingVueMarketRows, + directAuthorId, + directAuthorName + ); + const serializedMarketRow = takeMatchedMarketDataRow( + remainingSerializedMarketRows, + directAuthorId, + directAuthorName + ); const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow); const exportFields = mergeExportFieldMaps( readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells), fallbackMarketRow?.exportFields ); - const authorId = - extractAuthorId(authorCell) || - fallbackMarketRow?.authorId || - ""; - const authorName = - extractAuthorName(authorCell) || - fallbackMarketRow?.authorName || - ""; + const authorId = directAuthorId || fallbackMarketRow?.authorId || ""; + const authorName = directAuthorName || fallbackMarketRow?.authorName || ""; const price21To60s = mergeNonEmptyString( - priceCells[index]?.textContent?.trim() ?? "", + readDivGridPriceDisplay(priceCells[index]?.textContent), fallbackMarketRow?.price21To60s ); @@ -757,7 +761,7 @@ function getOwnerDocument(root: ParentNode): Document | null { return root.ownerDocument; } - return root instanceof Document ? root : null; + return "nodeType" in root && root.nodeType === 9 ? (root as Document) : null; } function readSyntheticHeaderLabels(header: HTMLElement): Record { @@ -812,7 +816,10 @@ function readExportFieldsForDivGridRow( return; } - exportFields[headerLabel] = normalizeExportCellText(cell?.textContent); + exportFields[headerLabel] = + headerLabel === "21-60s报价" + ? readDivGridPriceDisplay(cell?.textContent) ?? "" + : normalizeExportCellText(cell?.textContent); }); return exportFields; @@ -1069,60 +1076,92 @@ function readVueMarketRows( const vueRoot = ( marketRoot as HTMLElement & { __vue__?: { + $children?: unknown[]; _setupState?: Record; }; } ).__vue__; - const setupState = vueRoot?._setupState; - if (!setupState) { - return []; - } + const setupStates = collectVueSetupStates(vueRoot); - for (const value of Object.values(setupState)) { - const candidate = unwrapVueRef(value); - if (!candidate || typeof candidate !== "object") { - continue; - } + for (const setupState of setupStates) { + for (const value of Object.values(setupState)) { + const candidate = unwrapVueRef(value); + if (!candidate || typeof candidate !== "object") { + continue; + } - const marketList = unwrapVueRef( - (candidate as Record).marketList - ); - if (!Array.isArray(marketList)) { - continue; - } - - return marketList.map((row) => { - const record = isRecord(row) ? row : {}; - const attributeDatas = readMarketAttributeDatas(record); - const singleVideoAfterSearchRate = normalizeMarketListRate( - readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d") + const marketList = unwrapVueRef( + (candidate as Record).marketList ); + if (!Array.isArray(marketList)) { + continue; + } - return { - authorId: - readString(readMarketFieldValue(record, attributeDatas, "star_id")) ?? - readString(readMarketFieldValue(record, attributeDatas, "id")) ?? - "", - authorName: - readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? - readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? - "", - exportFields: buildMarketExportFieldFallbacks(record, attributeDatas), - hasDirectRatesSource: true, - location: readMarketLocation(record, attributeDatas), - price21To60s: readMarketPrice21To60s(record, attributeDatas), - rates: singleVideoAfterSearchRate - ? { - singleVideoAfterSearchRate - } - : undefined - }; - }); + return marketList.map((row) => { + const record = isRecord(row) ? row : {}; + const attributeDatas = readMarketAttributeDatas(record); + const singleVideoAfterSearchRate = normalizeMarketListRate( + readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d") + ); + + return { + authorId: + readString(readMarketFieldValue(record, attributeDatas, "star_id")) ?? + readString(readMarketFieldValue(record, attributeDatas, "id")) ?? + "", + authorName: + readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? + readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? + "", + exportFields: buildMarketExportFieldFallbacks(record, attributeDatas), + hasDirectRatesSource: true, + location: readMarketLocation(record, attributeDatas), + price21To60s: readMarketPrice21To60s(record, attributeDatas), + rates: singleVideoAfterSearchRate + ? { + singleVideoAfterSearchRate + } + : undefined + }; + }); + } } return []; } +function collectVueSetupStates( + vueRoot: + | { + $children?: unknown[]; + _setupState?: Record; + } + | undefined +): Array> { + if (!vueRoot) { + return []; + } + + const queue: unknown[] = [vueRoot]; + const setupStates: Array> = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (!isRecord(current)) { + continue; + } + + if (isRecord(current._setupState)) { + setupStates.push(current._setupState); + } + + const children = Array.isArray(current.$children) ? current.$children : []; + queue.push(...children); + } + + return setupStates; +} + function readSerializedMarketRows( document: Document ): MarketDataRow[] { @@ -1207,6 +1246,25 @@ function normalizeExportCellText(value: string | null | undefined): string { return value?.replace(/\s+/g, " ").trim() ?? ""; } +function readDivGridPriceDisplay(value: string | null | undefined): string | undefined { + const normalizedValue = normalizeExportCellText(value); + if (!normalizedValue) { + return undefined; + } + + const match = normalizedValue.match(/^¥?\s*([\d,]+(?:\.\d+)?)$/); + if (!match) { + return undefined; + } + + const numericValue = Number(match[1].replace(/,/g, "")); + if (!Number.isFinite(numericValue)) { + return undefined; + } + + return formatCurrencyValue(numericValue); +} + function shouldExportColumn(label: string): boolean { const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label)); return Boolean( @@ -1457,6 +1515,38 @@ function mergeMarketDataRows( }; } +function takeMatchedMarketDataRow( + remainingRows: MarketDataRow[], + authorId: string, + authorName: string +): MarketDataRow | null { + if (remainingRows.length === 0) { + return null; + } + + const matchedIndex = remainingRows.findIndex((row) => { + if (authorId && row.authorId === authorId) { + return true; + } + + if (authorName && row.authorName === authorName) { + return true; + } + + return false; + }); + + if (matchedIndex >= 0) { + return remainingRows.splice(matchedIndex, 1)[0] ?? null; + } + + if (!authorId && !authorName) { + return remainingRows.shift() ?? null; + } + + return null; +} + function mergeExportFieldMaps( current: Record | undefined, fallback: Record | undefined diff --git a/src/content/market/index.ts b/src/content/market/index.ts index d406faf..25976af 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -12,12 +12,11 @@ import { import { applyFilterAndSort } from "./filter-sort-controller"; import { createMarketApiClient } from "./api-client"; import { createExportRangeController } from "./export-range-controller"; -import { ensurePluginToolbar } from "./plugin-toolbar"; +import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; import { readToolbarExportTarget, setToolbarBusyState, - setToolbarExportStatus, - setToolbarSortState + setToolbarExportStatus } from "./plugin-toolbar"; import { createMarketResultStore } from "./result-store"; import { @@ -28,7 +27,6 @@ import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-me import type { BackendMetrics, MarketApiResult, - MarketFilterState, MarketExportTarget, MarketRecord, MarketRowSnapshot, @@ -94,15 +92,29 @@ export function createMarketController(options: CreateMarketControllerOptions) { readCurrentPageRowCount: () => countCurrentPageRows(options.document), window: options.window }); - let activeFilters: MarketFilterState = {}; let activeSort: MarketSortState | undefined; + let isDisposed = false; let isSyncRunning = false; let isSyncScheduled = false; let lastKnownPageSignature = ""; let needsResync = false; + let scheduledSyncTimeoutId: number | null = null; + let toolbar: ReturnType | undefined; const observer = mutationObserverFactory(() => { - const nextPageSignature = readMarketPageSignature(options.document); - if (nextPageSignature === lastKnownPageSignature) { + if (isDisposed) { + return; + } + + let nextPageSignature = lastKnownPageSignature; + try { + nextPageSignature = readMarketPageSignature(options.document); + } catch { + return; + } + + const toolbarNeedsRemount = + !toolbar || !isPluginToolbarMounted(toolbar.root, options.document); + if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) { return; } @@ -111,22 +123,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { const observationRoot = options.document.body ?? options.document.documentElement; startObserving(); - const toolbar = ensurePluginToolbar(options.document, { - onApplyFilter: async () => { - activeFilters = { - personalVideoAfterSearchRateMin: parseNumberValue( - toolbar.personalFilterInput.value - ), - singleVideoAfterSearchRateMin: parseNumberValue( - toolbar.singleFilterInput.value - ) - }; - applyCurrentView(); - }, - onApplySort: async () => { - activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect); - applyCurrentView(); - }, + const toolbarHandlers = { onExport: async () => { const exportTarget = readToolbarExportTarget(toolbar); if (!exportTarget.target) { @@ -190,13 +187,19 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, false); } } - }); + }; + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); const ready = runSyncCycle(); return { dispose() { + isDisposed = true; observer.disconnect(); + if (scheduledSyncTimeoutId !== null) { + options.window.clearTimeout(scheduledSyncTimeoutId); + scheduledSyncTimeoutId = null; + } }, ready }; @@ -368,6 +371,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { function applyCurrentView(): void { runWithoutMutationSync(() => { + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); const table = syncMarketTable(options.document); if (!table) { return; @@ -387,7 +391,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { function toggleSortFromHeader(field: MarketSortState["field"]): void { activeSort = getNextSortState(activeSort, field); - setToolbarSortState(toolbar, activeSort); applyCurrentView(); } @@ -395,7 +398,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { const currentPageRecords = readCurrentPageRecords(table); return applyFilterAndSort(currentPageRecords, { - filters: activeFilters, sort: activeSort }); } @@ -681,6 +683,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { } function scheduleSync(): void { + if (isDisposed) { + return; + } + if (isSyncRunning) { needsResync = true; return; @@ -691,13 +697,21 @@ export function createMarketController(options: CreateMarketControllerOptions) { } isSyncScheduled = true; - options.window.setTimeout(() => { + scheduledSyncTimeoutId = options.window.setTimeout(() => { + scheduledSyncTimeoutId = null; isSyncScheduled = false; + if (isDisposed) { + return; + } void runSyncCycle(); }, 0); } function runWithoutMutationSync(callback: () => void): void { + if (isDisposed) { + return; + } + observer.disconnect(); try { callback(); @@ -707,7 +721,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { } function startObserving(): void { - if (!observationRoot) { + if (isDisposed || !observationRoot) { return; } @@ -718,6 +732,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { } async function runSyncCycle(): Promise { + if (isDisposed) { + return; + } + if (isSyncRunning) { needsResync = true; return; @@ -725,11 +743,15 @@ export function createMarketController(options: CreateMarketControllerOptions) { isSyncRunning = true; try { + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); await hydrateCurrentPage(); applyCurrentView(); lastKnownPageSignature = readMarketPageSignature(options.document); } finally { isSyncRunning = false; + if (isDisposed) { + return; + } if (needsResync) { needsResync = false; scheduleSync(); @@ -779,29 +801,6 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { }; } -function parseNumberValue(value: string): number | undefined { - if (!value) { - return undefined; - } - - const parsedValue = Number(value); - return Number.isFinite(parsedValue) ? parsedValue : undefined; -} - -function readSortState( - fieldSelect: HTMLSelectElement, - directionSelect: HTMLSelectElement -): MarketSortState | undefined { - if (!fieldSelect.value) { - return undefined; - } - - return { - direction: directionSelect.value === "asc" ? "asc" : "desc", - field: fieldSelect.value as MarketSortState["field"] - }; -} - function getNextSortState( currentSort: MarketSortState | undefined, field: MarketSortState["field"] diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index f5eab9d..84506ca 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,50 +1,9 @@ import type { MarketExportScope, - MarketExportTarget, - MarketSortState + MarketExportTarget } from "./types"; -const SORT_FIELD_OPTIONS = [ - { - label: "单视频看后搜率", - value: "singleVideoAfterSearchRate" - }, - { - label: "个人视频看后搜率", - value: "personalVideoAfterSearchRate" - }, - { - label: "看后搜率", - value: "afterViewSearchRate" - }, - { - label: "看后搜数", - value: "afterViewSearchCount" - }, - { - label: "新增A3数", - value: "a3IncreaseCount" - }, - { - label: "新增A3率", - value: "newA3Rate" - }, - { - label: "CPA3", - value: "cpa3" - }, - { - label: "cp_search", - value: "cpSearch" - } -] as const satisfies Array<{ - label: string; - value: NonNullable; -}>; - export interface PluginToolbarHandlers { - onApplyFilter(): Promise | void; - onApplySort(): Promise | void; onExport(): Promise | void; onSubmitBatch(): Promise | void; } @@ -55,13 +14,15 @@ export interface PluginToolbarDom { exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; - filterApplyButton: HTMLButtonElement; - personalFilterInput: HTMLInputElement; root: HTMLElement; - singleFilterInput: HTMLInputElement; - sortApplyButton: HTMLButtonElement; - sortDirectionSelect: HTMLSelectElement; - sortFieldSelect: HTMLSelectElement; +} + +export function isPluginToolbarMounted( + root: HTMLElement, + document: Document +): boolean { + const actionRow = findNativeActionRow(document); + return Boolean(actionRow && root.parentElement === actionRow && !root.hidden); } export function ensurePluginToolbar( @@ -72,53 +33,13 @@ export function ensurePluginToolbar( "[data-plugin-toolbar='root']" ) as HTMLElement | null; if (existingRoot) { + ensureToolbarMounted(existingRoot, document); return readToolbarDom(existingRoot); } const root = document.createElement("section"); root.dataset.pluginToolbar = "root"; - - const singleFilterInput = document.createElement("input"); - singleFilterInput.type = "number"; - singleFilterInput.step = "0.01"; - singleFilterInput.dataset.pluginFilterSingle = "input"; - - const personalFilterInput = document.createElement("input"); - personalFilterInput.type = "number"; - personalFilterInput.step = "0.01"; - personalFilterInput.dataset.pluginFilterPersonal = "input"; - - const filterApplyButton = document.createElement("button"); - filterApplyButton.type = "button"; - filterApplyButton.dataset.pluginFilterApply = "button"; - filterApplyButton.textContent = "应用筛选"; - - const sortFieldSelect = document.createElement("select"); - sortFieldSelect.dataset.pluginSortField = "select"; - appendOption(sortFieldSelect, "", "不排序"); - SORT_FIELD_OPTIONS.forEach(({ label, value }) => { - appendOption(sortFieldSelect, value, label); - }); - - const sortDirectionSelect = document.createElement("select"); - sortDirectionSelect.dataset.pluginSortDirection = "select"; - appendOption(sortDirectionSelect, "desc", "降序"); - appendOption(sortDirectionSelect, "asc", "升序"); - - const sortApplyButton = document.createElement("button"); - sortApplyButton.type = "button"; - sortApplyButton.dataset.pluginSortApply = "button"; - sortApplyButton.textContent = "应用排序"; - - const exportButton = document.createElement("button"); - exportButton.type = "button"; - exportButton.dataset.pluginExport = "button"; - exportButton.textContent = "导出CSV"; - - const batchSubmitButton = document.createElement("button"); - batchSubmitButton.type = "button"; - batchSubmitButton.dataset.pluginBatchSubmit = "button"; - batchSubmitButton.textContent = "提交批次"; + applyToolbarRootStyles(root); const exportRangeSelect = document.createElement("select"); exportRangeSelect.dataset.pluginExportRange = "select"; @@ -134,32 +55,40 @@ export function ensurePluginToolbar( exportCustomPagesInput.min = "1"; exportCustomPagesInput.step = "1"; exportCustomPagesInput.hidden = true; + exportCustomPagesInput.placeholder = "页数"; exportCustomPagesInput.dataset.pluginExportCustomPages = "input"; + const exportButton = document.createElement("button"); + exportButton.type = "button"; + exportButton.dataset.pluginExport = "button"; + exportButton.textContent = "导出CSV"; + + const batchSubmitButton = document.createElement("button"); + batchSubmitButton.type = "button"; + batchSubmitButton.dataset.pluginBatchSubmit = "button"; + batchSubmitButton.textContent = "提交批次"; + const exportStatusText = document.createElement("span"); exportStatusText.dataset.pluginExportStatus = "text"; + applyStatusStyles(exportStatusText); root.append( - singleFilterInput, - personalFilterInput, - filterApplyButton, - sortFieldSelect, - sortDirectionSelect, - sortApplyButton, exportRangeSelect, exportCustomPagesInput, exportButton, - batchSubmitButton + batchSubmitButton, + exportStatusText ); - root.append(exportStatusText); - document.body.prepend(root); - filterApplyButton.addEventListener("click", () => { - void handlers.onApplyFilter(); - }); - sortApplyButton.addEventListener("click", () => { - void handlers.onApplySort(); + document.body.appendChild(root); + applyNativeControlStyles(document, { + batchSubmitButton, + exportButton, + exportCustomPagesInput, + exportRangeSelect }); + ensureToolbarMounted(root, document); + exportButton.addEventListener("click", () => { void handlers.onExport(); }); @@ -173,13 +102,7 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportRangeSelect, exportStatusText, - filterApplyButton, - personalFilterInput, - root, - singleFilterInput, - sortApplyButton, - sortDirectionSelect, - sortFieldSelect + root }); }); @@ -189,13 +112,7 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportRangeSelect, exportStatusText, - filterApplyButton, - personalFilterInput, - root, - singleFilterInput, - sortApplyButton, - sortDirectionSelect, - sortFieldSelect + root } satisfies PluginToolbarDom; syncCustomPagesInputVisibility(toolbarDom); @@ -230,25 +147,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom { exportStatusText: root.querySelector( '[data-plugin-export-status="text"]' ) as HTMLElement, - filterApplyButton: root.querySelector( - '[data-plugin-filter-apply="button"]' - ) as HTMLButtonElement, - personalFilterInput: root.querySelector( - '[data-plugin-filter-personal="input"]' - ) as HTMLInputElement, - root, - singleFilterInput: root.querySelector( - '[data-plugin-filter-single="input"]' - ) as HTMLInputElement, - sortApplyButton: root.querySelector( - '[data-plugin-sort-apply="button"]' - ) as HTMLButtonElement, - sortDirectionSelect: root.querySelector( - '[data-plugin-sort-direction="select"]' - ) as HTMLSelectElement, - sortFieldSelect: root.querySelector( - '[data-plugin-sort-field="select"]' - ) as HTMLSelectElement + root } satisfies PluginToolbarDom; syncCustomPagesInputVisibility(toolbarDom); return toolbarDom; @@ -316,12 +215,6 @@ export function setToolbarBusyState( [ toolbar.batchSubmitButton, toolbar.exportButton, - toolbar.filterApplyButton, - toolbar.sortApplyButton, - toolbar.singleFilterInput, - toolbar.personalFilterInput, - toolbar.sortFieldSelect, - toolbar.sortDirectionSelect, toolbar.exportRangeSelect, toolbar.exportCustomPagesInput ].forEach((element) => { @@ -336,15 +229,243 @@ export function setToolbarExportStatus( toolbar.exportStatusText.textContent = text; } -export function setToolbarSortState( - toolbar: PluginToolbarDom, - sort: MarketSortState | undefined -): void { - toolbar.sortFieldSelect.value = sort?.field ?? ""; - toolbar.sortDirectionSelect.value = sort?.direction ?? "desc"; -} - function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; } + +function ensureToolbarMounted(root: HTMLElement, document: Document): void { + const actionRow = findNativeActionRow(document); + if (!actionRow) { + root.hidden = true; + return; + } + + const customizeButton = findNativeActionButton(actionRow, "自定义指标"); + const insertionAnchor = customizeButton + ? findDirectChildAnchor(actionRow, customizeButton) + : null; + if (insertionAnchor) { + actionRow.insertBefore(root, insertionAnchor); + } else if (root.parentElement !== actionRow) { + actionRow.prepend(root); + } + + root.hidden = false; +} + +function findNativeActionRow(document: Document): HTMLElement | null { + const customizeButton = findNativeActionButton(document, "自定义指标"); + const exportButton = findNativeActionButton(document, "导出"); + const header = findHeaderContainer(customizeButton, exportButton); + + const sharedActionRow = + customizeButton && exportButton + ? findSmallestSharedActionRow(customizeButton, exportButton, header) + : null; + if (sharedActionRow) { + return sharedActionRow; + } + + const scope = header ?? document; + const candidates = Array.from( + scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header") + ).filter((element): element is HTMLElement => + element instanceof document.defaultView!.HTMLElement + ); + + const rankedCandidates = candidates + .filter((candidate) => + isNativeActionRowCandidate(candidate, customizeButton, exportButton) + ) + .sort((left, right) => { + const depthDelta = getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header); + if (depthDelta !== 0) { + return depthDelta; + } + + return normalizeText(left.textContent).length - normalizeText(right.textContent).length; + }); + + return rankedCandidates[0] ?? null; +} + +function findHeaderContainer( + customizeButton: HTMLElement | null, + exportButton: HTMLElement | null +): HTMLElement | null { + return ( + (customizeButton?.closest(".search-content--header") as HTMLElement | null) ?? + (exportButton?.closest(".search-content--header") as HTMLElement | null) + ); +} + +function findSmallestSharedActionRow( + customizeButton: HTMLElement, + exportButton: HTMLElement, + boundary: HTMLElement | null +): HTMLElement | null { + const exportAncestors = new Set(collectAncestorChain(exportButton, boundary)); + + for (const candidate of collectAncestorChain(customizeButton, boundary)) { + if ( + exportAncestors.has(candidate) && + isNativeActionRowCandidate(candidate, customizeButton, exportButton) + ) { + return candidate; + } + } + + return null; +} + +function collectAncestorChain( + element: HTMLElement, + boundary: HTMLElement | null +): HTMLElement[] { + const ancestors: HTMLElement[] = []; + let current: HTMLElement | null = element.parentElement; + + while (current) { + ancestors.push(current); + if (current === boundary) { + break; + } + current = current.parentElement; + } + + return ancestors; +} + +function isNativeActionRowCandidate( + candidate: HTMLElement, + customizeButton: HTMLElement | null, + exportButton: HTMLElement | null +): boolean { + if (customizeButton && !candidate.contains(customizeButton)) { + return false; + } + + if (exportButton && !candidate.contains(exportButton)) { + return false; + } + + const directChildLabels = Array.from(candidate.children) + .flatMap((child) => { + const buttons: Element[] = []; + if (child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement) { + buttons.push(child); + } + + buttons.push(...Array.from(child.querySelectorAll("button"))); + return buttons; + }) + .map((button) => normalizeText(button.textContent)); + return ( + directChildLabels.includes("导出") && + (directChildLabels.includes("自定义指标") || Boolean(customizeButton)) + ); +} + +function getDepthWithinAncestor( + element: HTMLElement, + boundary: HTMLElement | null +): number { + let depth = 0; + let current: HTMLElement | null = element.parentElement; + + while (current && current !== boundary) { + depth += 1; + current = current.parentElement; + } + + return depth; +} + +function findNativeActionButton( + root: ParentNode, + text: string +): HTMLElement | null { + const document = root instanceof Document ? root : root.ownerDocument; + if (!document) { + return null; + } + + const candidates = Array.from(root.querySelectorAll("button")).filter( + (element): element is HTMLElement => + element instanceof document.defaultView!.HTMLElement + ); + return ( + candidates.find((element) => normalizeText(element.textContent) === text) ?? null + ); +} + +function applyToolbarRootStyles(root: HTMLElement): void { + root.style.display = "inline-flex"; + root.style.alignItems = "center"; + root.style.columnGap = "8px"; + root.style.flexWrap = "wrap"; +} + +function applyNativeControlStyles( + document: Document, + controls: { + batchSubmitButton: HTMLButtonElement; + exportButton: HTMLButtonElement; + exportCustomPagesInput: HTMLInputElement; + exportRangeSelect: HTMLSelectElement; + } +): void { + const nativeButton = + findNativeActionButton(document, "自定义指标") ?? + findNativeActionButton(document, "导出"); + + if (nativeButton) { + controls.exportButton.className = nativeButton.className; + controls.batchSubmitButton.className = nativeButton.className; + } + + [controls.exportButton, controls.batchSubmitButton].forEach((button) => { + button.style.whiteSpace = "nowrap"; + }); + + [controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => { + element.style.height = "32px"; + element.style.border = "1px solid #d0d7de"; + element.style.borderRadius = "6px"; + element.style.padding = "0 10px"; + element.style.background = "#fff"; + element.style.color = "#1f2329"; + element.style.boxSizing = "border-box"; + }); + + controls.exportRangeSelect.style.minWidth = "104px"; + controls.exportCustomPagesInput.style.width = "72px"; +} + +function applyStatusStyles(statusText: HTMLElement): void { + statusText.style.color = "#64748b"; + statusText.style.fontSize = "12px"; + statusText.style.lineHeight = "20px"; + statusText.style.marginLeft = "4px"; + statusText.style.whiteSpace = "nowrap"; +} + +function normalizeText(value: string | null | undefined): string { + return value?.replace(/\s+/g, " ").trim() ?? ""; +} + +function findDirectChildAnchor( + ancestor: HTMLElement, + descendant: HTMLElement +): HTMLElement | null { + let current: HTMLElement | null = descendant; + let previous: HTMLElement | null = null; + + while (current && current !== ancestor) { + previous = current; + current = current.parentElement; + } + + return current === ancestor ? previous : null; +} diff --git a/src/shared/backend-metrics-config.ts b/src/shared/backend-metrics-config.ts index 2541e76..39fa928 100644 --- a/src/shared/backend-metrics-config.ts +++ b/src/shared/backend-metrics-config.ts @@ -1 +1,2 @@ -export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083"; +export const DEFAULT_BACKEND_METRICS_BASE_URL = + "https://talent-search.intelligrow.cn"; diff --git a/tests/backend-metrics-client.test.ts b/tests/backend-metrics-client.test.ts index 0ddedb1..b09846a 100644 --- a/tests/backend-metrics-client.test.ts +++ b/tests/backend-metrics-client.test.ts @@ -10,12 +10,14 @@ import { describe("backend-metrics-client", () => { test("exports the default backend metrics base url", () => { - expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe("http://192.168.31.29:8083"); + expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe( + "https://talent-search.intelligrow.cn" + ); }); test("builds the backend search url", () => { - expect(buildBackendMetricsSearchUrl("http://192.168.31.29:8083")).toBe( - "http://192.168.31.29:8083/api/v1/history/talents/search" + expect(buildBackendMetricsSearchUrl("https://talent-search.intelligrow.cn")).toBe( + "https://talent-search.intelligrow.cn/api/v1/history/talents/search" ); }); @@ -71,7 +73,7 @@ describe("backend-metrics-client", () => { }), ok: true, status: 200, - url: "http://192.168.31.29:8083/api/v1/history/talents/search" + url: "https://talent-search.intelligrow.cn/api/v1/history/talents/search" }); const fetchSpy = vi.fn(fetchImpl); const client = createBackendMetricsClient({ @@ -82,7 +84,7 @@ describe("backend-metrics-client", () => { await client.searchByStarIds(["111", "222"]); expect(fetchSpy).toHaveBeenCalledWith( - "http://192.168.31.29:8083/api/v1/history/talents/search", + "https://talent-search.intelligrow.cn/api/v1/history/talents/search", expect.objectContaining({ body: JSON.stringify({ page: 1, diff --git a/tests/batch-payload.test.ts b/tests/batch-payload.test.ts index 5cf5870..b93d8a0 100644 --- a/tests/batch-payload.test.ts +++ b/tests/batch-payload.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { createBatchPayload } from "../src/content/market/batch-payload"; describe("batch-payload", () => { - test("builds a batch id from the batch name and timestamp", () => { + test("builds a batch id from the user id and timestamp", () => { const payload = createBatchPayload({ authState: { isAuthenticated: true, @@ -26,7 +26,7 @@ describe("batch-payload", () => { { authorId: "111", authorName: "达人A" }, { authorId: "222", authorName: "达人B" } ], - batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z", + batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z", batchName: "618达人筛选第一批", createdAt: "2026-04-22T12:30:00.000Z", creatorName: "王少卿", diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index c801e0f..d042186 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -11,6 +11,7 @@ describe("market-content-entry", () => { beforeEach(() => { document.body.innerHTML = ""; document.documentElement.removeAttribute("data-sces-market-rows"); + document.documentElement.removeAttribute("data-test-page-index"); window.history.replaceState({}, "", "/"); }); @@ -33,6 +34,7 @@ describe("market-content-entry", () => { } ).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__; document.documentElement.removeAttribute("data-sces-market-rows"); + document.documentElement.removeAttribute("data-test-page-index"); vi.resetModules(); while (disposers.length > 0) { @@ -207,6 +209,76 @@ describe("market-content-entry", () => { ); }); + test("renders the plugin action bar inside the native market action row", async () => { + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + + const toolbar = document.querySelector('[data-plugin-toolbar="root"]'); + const actionRow = document.querySelector('[data-testid="market-native-actions"]'); + const customizeButton = document.querySelector('[data-testid="market-native-customize"]'); + const nativeExportButton = document.querySelector('[data-testid="market-native-export"]'); + + expect(toolbar).not.toBeNull(); + expect(actionRow).not.toBeNull(); + expect(toolbar?.parentElement).toBe(actionRow); + expect(toolbar?.nextElementSibling).toBe(customizeButton); + expect(customizeButton?.nextElementSibling).toBe(nativeExportButton); + + expect(document.querySelector('[data-plugin-filter-apply="button"]')).toBeNull(); + expect(document.querySelector('[data-plugin-sort-apply="button"]')).toBeNull(); + expect(document.querySelector('[data-plugin-filter-single="input"]')).toBeNull(); + expect(document.querySelector('[data-plugin-sort-field="select"]')).toBeNull(); + + expect(document.body.firstElementChild).not.toBe(toolbar); + expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); + expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull(); + }); + + test("remounts the plugin action bar when the native market action row appears later", async () => { + document.body.innerHTML = buildMarketTableOnlyFixture(); + const observer = createMutationObserverFactory(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + mutationObserverFactory: observer.factory, + window + })); + + await controller.ready; + + const toolbar = document.querySelector('[data-plugin-toolbar="root"]'); + expect(toolbar).not.toBeNull(); + expect(toolbar?.parentElement).toBe(document.body); + expect((toolbar as HTMLElement | null)?.hidden).toBe(true); + + document.body.insertAdjacentHTML("afterbegin", buildMarketPageShell("")); + observer.trigger(); + await flushWithTimers(); + await flushWithTimers(); + + const actionRow = document.querySelector('[data-testid="market-native-actions"]'); + expect(toolbar?.parentElement).toBe(actionRow); + expect((toolbar as HTMLElement | null)?.hidden).toBe(false); + }); + test("hydrates current page rows on start", async () => { document.body.innerHTML = buildMarketFixture(); @@ -591,75 +663,7 @@ describe("market-content-entry", () => { ]); }); - test("applying plugin filters hides non-matching current-page rows without a full scan", async () => { - document.body.innerHTML = buildMarketFixture(); - const resultStore = createMarketResultStore(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - resultStore, - window - })); - - await controller.ready; - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - setInputValue('[data-plugin-filter-single="input"]', "0.1"); - click('[data-plugin-filter-apply="button"]'); - await flush(); - - expect( - document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden") - ).toBe(true); - expect( - document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden") - ).toBe(false); - }); - - test("applying plugin sorting reorders the current page without triggering a full scan", async () => { - document.body.innerHTML = buildMarketFixture(); - const resultStore = createMarketResultStore(); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - resultStore, - window - })); - - await controller.ready; - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); - setSelectValue('[data-plugin-sort-direction="select"]', "desc"); - click('[data-plugin-sort-apply="button"]'); - await flush(); - - expect(readRowOrder()).toEqual(["b", "a"]); - }); - - test("clicking plugin sort headers cycles sort state and syncs the toolbar", async () => { + test("clicking plugin sort headers cycles sort state", async () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", @@ -699,8 +703,6 @@ describe("market-content-entry", () => { await flush(); expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); - expectSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); - expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") @@ -710,7 +712,6 @@ describe("market-content-entry", () => { await flush(); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); - expectSelectValue('[data-plugin-sort-direction="select"]', "asc"); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") @@ -720,8 +721,6 @@ describe("market-content-entry", () => { await flush(); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); - expectSelectValue('[data-plugin-sort-field="select"]', ""); - expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); expect( document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') ?.getAttribute("data-market-sort-direction") @@ -766,8 +765,6 @@ describe("market-content-entry", () => { await flush(); expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); - expectSelectValue('[data-plugin-sort-field="select"]', "afterViewSearchRate"); - expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); }); test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => { @@ -832,12 +829,12 @@ describe("market-content-entry", () => { singleVideoAfterSearchRate: "0.5%-1%", personalVideoAfterSearchRate: "0.01% - 0.1%" }); - setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); - setSelectValue('[data-plugin-sort-direction="select"]', "desc"); - click('[data-plugin-sort-apply="button"]'); + click('[data-market-sort-field="singleVideoAfterSearchRate"]'); await flush(); + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); click('[data-plugin-export="button"]'); - await waitForMockCall(buildCsv, 40, 50); + await waitForMockCall(buildCsv, 80, 50); expect(buildCsv).toHaveBeenCalledWith( expect.arrayContaining([ @@ -1018,57 +1015,57 @@ describe("market-content-entry", () => { 15000 ); - test("exporting all pages disables the toolbar during the task and stops at the final page", async () => { - const pages = [ - [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], - [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], - [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] - ]; + test( + "exporting all pages disables the native action bar controls during the task and stops at the final page", + async () => { + const pages = [ + [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], + [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] + ]; - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installAsyncPaginationHarness(pages); - const buildCsv = vi.fn(() => "csv-output"); + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installPaginationHarness(pages); + const buildCsv = vi.fn(() => "csv-output"); - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - buildCsv, - document, - loadAuthorMetrics: async () => ({ - success: false, - reason: "request-failed" - }), - onCsvReady: vi.fn(), - window - })); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); - await controller.ready; - setSelectValue('[data-plugin-export-range="select"]', "all"); - dispatchChange('[data-plugin-export-range="select"]'); + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); - click('[data-plugin-export="button"]'); + click('[data-plugin-export="button"]'); - expectButtonDisabled('[data-plugin-batch-submit="button"]', true); - expectButtonDisabled('[data-plugin-export="button"]', true); - expectButtonDisabled('[data-plugin-filter-apply="button"]', true); - expectButtonDisabled('[data-plugin-sort-apply="button"]', true); - expectSelectDisabled('[data-plugin-export-range="select"]', true); - expect( - document.querySelector('[data-plugin-export-status="text"]')?.textContent - ).toContain("导出中"); + expectButtonDisabled('[data-plugin-batch-submit="button"]', true); + expectButtonDisabled('[data-plugin-export="button"]', true); + expectSelectDisabled('[data-plugin-export-range="select"]', true); + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toContain("导出中"); - await waitForMockCall(buildCsv, 80, 100); + await waitForMockCall(buildCsv, 120, 100); - expect(pagination.getClicks()).toBe(2); - expectButtonDisabled('[data-plugin-batch-submit="button"]', false); - expectButtonDisabled('[data-plugin-export="button"]', false); - expectSelectDisabled('[data-plugin-export-range="select"]', false); - expect(buildCsv).toHaveBeenCalledTimes(1); - expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ - "111", - "222", - "333" - ]); - }); + expect(pagination.getClicks()).toBe(2); + expectButtonDisabled('[data-plugin-batch-submit="button"]', false); + expectButtonDisabled('[data-plugin-export="button"]', false); + expectSelectDisabled('[data-plugin-export-range="select"]', false); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual( + expect.arrayContaining(["222", "333"]) + ); + }, + 15000 + ); test("custom export range blocks invalid page counts", async () => { document.body.innerHTML = buildMarketFixture(); @@ -1132,7 +1129,7 @@ describe("market-content-entry", () => { expect(promptBatchName).toHaveBeenCalledTimes(1); expect(submitBatch).toHaveBeenCalledWith( expect.objectContaining({ - batchId: expect.stringContaining("618达人筛选第一批-"), + batchId: expect.stringContaining("p7pdhhtde8kj-"), batchName: "618达人筛选第一批", logtoUserId: "p7pdhhtde8kj" }) @@ -1984,62 +1981,13 @@ describe("market-content-entry", () => { ?.textContent ).toBe("0.8% - 1%"); }); - - test("applying a filter on the real market view stays on the current page", async () => { - const pages = [ - [ - { - authorId: "111", - authorName: "达人 A", - price21To60s: "¥450,000" - } - ], - [ - { - authorId: "222", - authorName: "达人 B", - price21To60s: "¥20,000" - } - ] - ]; - - document.body.innerHTML = buildRealMarketFixture(pages[0]); - const pagination = installPaginationHarness(pages); - const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ - success: true as const, - rates: - authorId === "111" - ? { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - } - : { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - } - })); - - const { createMarketController } = await import("../src/content/market/index"); - const controller = trackController(createMarketController({ - document, - loadAuthorMetrics, - window - })); - - await controller.ready; - setInputValue('[data-plugin-filter-single="input"]', "0.1"); - click('[data-plugin-filter-apply="button"]'); - await flush(); - await flush(); - - expect(pagination.getClicks()).toBe(0); - expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).not.toContain( - "222" - ); - }); }); function buildMarketFixture() { + return buildMarketPageShell(buildMarketTableOnlyFixture()); +} + +function buildMarketTableOnlyFixture() { return `
@@ -2060,6 +2008,35 @@ function buildMarketFixture() { `; } +function buildMarketPageShell(content: string) { + return ` +
+
+
+ 找到 10000+ 个达人 +
+
+ + +
+
+
+ ${content} + `; +} + function buildRealMarketFixture( rows: Array<{ authorId: string; @@ -2067,7 +2044,7 @@ function buildRealMarketFixture( price21To60s: string; }> ) { - return ` + return buildMarketPageShell(`
- `; + `); } function buildRichExportMarketFixture(