feat: refine market action bar and metrics sync
This commit is contained in:
parent
24e8a3ba9a
commit
bee8cb0207
161
docs/superpowers/plans/2026-04-23-market-native-action-bar.md
Normal file
161
docs/superpowers/plans/2026-04-23-market-native-action-bar.md
Normal file
@ -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"
|
||||
```
|
||||
@ -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 结构
|
||||
@ -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:
|
||||
|
||||
@ -535,8 +535,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
) as Record<BackendMetricField, HTMLElement[]>;
|
||||
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<string, string> {
|
||||
@ -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<string, unknown>;
|
||||
};
|
||||
}
|
||||
).__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<string, unknown>).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<string, unknown>).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<string, unknown>;
|
||||
}
|
||||
| undefined
|
||||
): Array<Record<string, unknown>> {
|
||||
if (!vueRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue: unknown[] = [vueRoot];
|
||||
const setupStates: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, string> | undefined,
|
||||
fallback: Record<string, string> | undefined
|
||||
|
||||
@ -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<typeof ensurePluginToolbar> | 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<void> {
|
||||
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"]
|
||||
|
||||
@ -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<MarketSortState["field"]>;
|
||||
}>;
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onApplyFilter(): Promise<void> | void;
|
||||
onApplySort(): Promise<void> | void;
|
||||
onExport(): Promise<void> | void;
|
||||
onSubmitBatch(): Promise<void> | 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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: "王少卿",
|
||||
|
||||
@ -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 `
|
||||
<div data-market-table>
|
||||
<div data-market-header>
|
||||
@ -2060,6 +2008,35 @@ function buildMarketFixture() {
|
||||
`;
|
||||
}
|
||||
|
||||
function buildMarketPageShell(content: string) {
|
||||
return `
|
||||
<div class="search-content--header xt-space xt-space--medium" style="justify-content: flex-start; align-items: stretch; flex-direction: column;">
|
||||
<div class="xt-space xt-space--medium" style="justify-content: space-between; align-items: center; flex-direction: row;">
|
||||
<div class="xt-space xt-space--medium">
|
||||
<span>找到 10000+ 个达人</span>
|
||||
</div>
|
||||
<div class="xt-space xt-space--medium" data-testid="market-native-actions" style="justify-content: flex-start; align-items: center; flex-direction: row;">
|
||||
<button
|
||||
data-testid="market-native-customize"
|
||||
type="button"
|
||||
class="el-button el-button--default el-button--medium xt-button xt-button--default"
|
||||
>
|
||||
<span>自定义指标</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="market-native-export"
|
||||
type="button"
|
||||
class="el-button el-button--default el-button--medium xt-button xt-button--default"
|
||||
>
|
||||
<span>导出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${content}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildRealMarketFixture(
|
||||
rows: Array<{
|
||||
authorId: string;
|
||||
@ -2067,7 +2044,7 @@ function buildRealMarketFixture(
|
||||
price21To60s: string;
|
||||
}>
|
||||
) {
|
||||
return `
|
||||
return buildMarketPageShell(`
|
||||
<div class="base-author-list" data-testid="market-root">
|
||||
<div class="section-wrapper sticky-header hide-scrollbar">
|
||||
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
||||
@ -2129,7 +2106,7 @@ function buildRealMarketFixture(
|
||||
</div>
|
||||
</div>
|
||||
<button data-testid="next-page" type="button">下一页</button>
|
||||
`;
|
||||
`);
|
||||
}
|
||||
|
||||
function buildRichExportMarketFixture(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user