feat: refine market action bar and metrics sync

This commit is contained in:
admin123 2026-04-23 16:20:14 +08:00
parent 24e8a3ba9a
commit bee8cb0207
10 changed files with 892 additions and 447 deletions

View 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"
```

View File

@ -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 结构

View File

@ -40,7 +40,7 @@ export function createBatchPayload(options: {
authorId: record.authorId, authorId: record.authorId,
authorName: record.authorName authorName: record.authorName
})), })),
batchId: `${batchName}-${options.createdAt}`, batchId: `${logtoUserId}-${options.createdAt}`,
batchName, batchName,
createdAt: options.createdAt, createdAt: options.createdAt,
creatorName: creatorName:

View File

@ -535,8 +535,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
) as Record<BackendMetricField, HTMLElement[]>; ) as Record<BackendMetricField, HTMLElement[]>;
const priceColumn = findPreviousColumn(actionColumn); const priceColumn = findPreviousColumn(actionColumn);
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : []; const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
const vueMarketRows = readVueMarketRows(root); const remainingVueMarketRows = [...readVueMarketRows(root)];
const serializedMarketRows = readSerializedMarketRows(root.ownerDocument); const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
const rows = authorCells.flatMap((authorCell, index) => { const rows = authorCells.flatMap((authorCell, index) => {
const singleCell = singleCells[index] ?? null; const singleCell = singleCells[index] ?? null;
@ -561,23 +561,27 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const rowCells = alignedRowCells.filter( const rowCells = alignedRowCells.filter(
(cell): cell is HTMLElement => cell !== null (cell): cell is HTMLElement => cell !== null
); );
const vueMarketRow = vueMarketRows[index] ?? null; const directAuthorId = extractAuthorId(authorCell) || "";
const serializedMarketRow = serializedMarketRows[index] ?? null; const directAuthorName = extractAuthorName(authorCell) || "";
const vueMarketRow = takeMatchedMarketDataRow(
remainingVueMarketRows,
directAuthorId,
directAuthorName
);
const serializedMarketRow = takeMatchedMarketDataRow(
remainingSerializedMarketRows,
directAuthorId,
directAuthorName
);
const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow); const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow);
const exportFields = mergeExportFieldMaps( const exportFields = mergeExportFieldMaps(
readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells), readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells),
fallbackMarketRow?.exportFields fallbackMarketRow?.exportFields
); );
const authorId = const authorId = directAuthorId || fallbackMarketRow?.authorId || "";
extractAuthorId(authorCell) || const authorName = directAuthorName || fallbackMarketRow?.authorName || "";
fallbackMarketRow?.authorId ||
"";
const authorName =
extractAuthorName(authorCell) ||
fallbackMarketRow?.authorName ||
"";
const price21To60s = mergeNonEmptyString( const price21To60s = mergeNonEmptyString(
priceCells[index]?.textContent?.trim() ?? "", readDivGridPriceDisplay(priceCells[index]?.textContent),
fallbackMarketRow?.price21To60s fallbackMarketRow?.price21To60s
); );
@ -757,7 +761,7 @@ function getOwnerDocument(root: ParentNode): Document | null {
return root.ownerDocument; 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> { function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
@ -812,7 +816,10 @@ function readExportFieldsForDivGridRow(
return; return;
} }
exportFields[headerLabel] = normalizeExportCellText(cell?.textContent); exportFields[headerLabel] =
headerLabel === "21-60s报价"
? readDivGridPriceDisplay(cell?.textContent) ?? ""
: normalizeExportCellText(cell?.textContent);
}); });
return exportFields; return exportFields;
@ -1069,60 +1076,92 @@ function readVueMarketRows(
const vueRoot = ( const vueRoot = (
marketRoot as HTMLElement & { marketRoot as HTMLElement & {
__vue__?: { __vue__?: {
$children?: unknown[];
_setupState?: Record<string, unknown>; _setupState?: Record<string, unknown>;
}; };
} }
).__vue__; ).__vue__;
const setupState = vueRoot?._setupState; const setupStates = collectVueSetupStates(vueRoot);
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) { for (const setupState of setupStates) {
const candidate = unwrapVueRef(value); for (const value of Object.values(setupState)) {
if (!candidate || typeof candidate !== "object") { const candidate = unwrapVueRef(value);
continue; if (!candidate || typeof candidate !== "object") {
} continue;
}
const marketList = unwrapVueRef( const marketList = unwrapVueRef(
(candidate as Record<string, unknown>).marketList (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")
); );
if (!Array.isArray(marketList)) {
continue;
}
return { return marketList.map((row) => {
authorId: const record = isRecord(row) ? row : {};
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ?? const attributeDatas = readMarketAttributeDatas(record);
readString(readMarketFieldValue(record, attributeDatas, "id")) ?? const singleVideoAfterSearchRate = normalizeMarketListRate(
"", readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
authorName: );
readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? return {
"", authorId:
exportFields: buildMarketExportFieldFallbacks(record, attributeDatas), readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
hasDirectRatesSource: true, readString(readMarketFieldValue(record, attributeDatas, "id")) ??
location: readMarketLocation(record, attributeDatas), "",
price21To60s: readMarketPrice21To60s(record, attributeDatas), authorName:
rates: singleVideoAfterSearchRate readString(readMarketFieldValue(record, attributeDatas, "nickname")) ??
? { readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ??
singleVideoAfterSearchRate "",
} exportFields: buildMarketExportFieldFallbacks(record, attributeDatas),
: undefined hasDirectRatesSource: true,
}; location: readMarketLocation(record, attributeDatas),
}); price21To60s: readMarketPrice21To60s(record, attributeDatas),
rates: singleVideoAfterSearchRate
? {
singleVideoAfterSearchRate
}
: undefined
};
});
}
} }
return []; 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( function readSerializedMarketRows(
document: Document document: Document
): MarketDataRow[] { ): MarketDataRow[] {
@ -1207,6 +1246,25 @@ function normalizeExportCellText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? ""; 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 { function shouldExportColumn(label: string): boolean {
const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label)); const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label));
return Boolean( 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( function mergeExportFieldMaps(
current: Record<string, string> | undefined, current: Record<string, string> | undefined,
fallback: Record<string, string> | undefined fallback: Record<string, string> | undefined

View File

@ -12,12 +12,11 @@ import {
import { applyFilterAndSort } from "./filter-sort-controller"; import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client"; import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller"; import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar } from "./plugin-toolbar"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import { import {
readToolbarExportTarget, readToolbarExportTarget,
setToolbarBusyState, setToolbarBusyState,
setToolbarExportStatus, setToolbarExportStatus
setToolbarSortState
} from "./plugin-toolbar"; } from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store"; import { createMarketResultStore } from "./result-store";
import { import {
@ -28,7 +27,6 @@ import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-me
import type { import type {
BackendMetrics, BackendMetrics,
MarketApiResult, MarketApiResult,
MarketFilterState,
MarketExportTarget, MarketExportTarget,
MarketRecord, MarketRecord,
MarketRowSnapshot, MarketRowSnapshot,
@ -94,15 +92,29 @@ export function createMarketController(options: CreateMarketControllerOptions) {
readCurrentPageRowCount: () => countCurrentPageRows(options.document), readCurrentPageRowCount: () => countCurrentPageRows(options.document),
window: options.window window: options.window
}); });
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined; let activeSort: MarketSortState | undefined;
let isDisposed = false;
let isSyncRunning = false; let isSyncRunning = false;
let isSyncScheduled = false; let isSyncScheduled = false;
let lastKnownPageSignature = ""; let lastKnownPageSignature = "";
let needsResync = false; let needsResync = false;
let scheduledSyncTimeoutId: number | null = null;
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
const observer = mutationObserverFactory(() => { const observer = mutationObserverFactory(() => {
const nextPageSignature = readMarketPageSignature(options.document); if (isDisposed) {
if (nextPageSignature === lastKnownPageSignature) { return;
}
let nextPageSignature = lastKnownPageSignature;
try {
nextPageSignature = readMarketPageSignature(options.document);
} catch {
return;
}
const toolbarNeedsRemount =
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
return; return;
} }
@ -111,22 +123,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const observationRoot = options.document.body ?? options.document.documentElement; const observationRoot = options.document.body ?? options.document.documentElement;
startObserving(); startObserving();
const toolbar = ensurePluginToolbar(options.document, { const toolbarHandlers = {
onApplyFilter: async () => {
activeFilters = {
personalVideoAfterSearchRateMin: parseNumberValue(
toolbar.personalFilterInput.value
),
singleVideoAfterSearchRateMin: parseNumberValue(
toolbar.singleFilterInput.value
)
};
applyCurrentView();
},
onApplySort: async () => {
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
applyCurrentView();
},
onExport: async () => { onExport: async () => {
const exportTarget = readToolbarExportTarget(toolbar); const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) { if (!exportTarget.target) {
@ -190,13 +187,19 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, false); setToolbarBusyState(toolbar, false);
} }
} }
}); };
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const ready = runSyncCycle(); const ready = runSyncCycle();
return { return {
dispose() { dispose() {
isDisposed = true;
observer.disconnect(); observer.disconnect();
if (scheduledSyncTimeoutId !== null) {
options.window.clearTimeout(scheduledSyncTimeoutId);
scheduledSyncTimeoutId = null;
}
}, },
ready ready
}; };
@ -368,6 +371,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function applyCurrentView(): void { function applyCurrentView(): void {
runWithoutMutationSync(() => { runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const table = syncMarketTable(options.document); const table = syncMarketTable(options.document);
if (!table) { if (!table) {
return; return;
@ -387,7 +391,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function toggleSortFromHeader(field: MarketSortState["field"]): void { function toggleSortFromHeader(field: MarketSortState["field"]): void {
activeSort = getNextSortState(activeSort, field); activeSort = getNextSortState(activeSort, field);
setToolbarSortState(toolbar, activeSort);
applyCurrentView(); applyCurrentView();
} }
@ -395,7 +398,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const currentPageRecords = readCurrentPageRecords(table); const currentPageRecords = readCurrentPageRecords(table);
return applyFilterAndSort(currentPageRecords, { return applyFilterAndSort(currentPageRecords, {
filters: activeFilters,
sort: activeSort sort: activeSort
}); });
} }
@ -681,6 +683,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
function scheduleSync(): void { function scheduleSync(): void {
if (isDisposed) {
return;
}
if (isSyncRunning) { if (isSyncRunning) {
needsResync = true; needsResync = true;
return; return;
@ -691,13 +697,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
isSyncScheduled = true; isSyncScheduled = true;
options.window.setTimeout(() => { scheduledSyncTimeoutId = options.window.setTimeout(() => {
scheduledSyncTimeoutId = null;
isSyncScheduled = false; isSyncScheduled = false;
if (isDisposed) {
return;
}
void runSyncCycle(); void runSyncCycle();
}, 0); }, 0);
} }
function runWithoutMutationSync(callback: () => void): void { function runWithoutMutationSync(callback: () => void): void {
if (isDisposed) {
return;
}
observer.disconnect(); observer.disconnect();
try { try {
callback(); callback();
@ -707,7 +721,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
function startObserving(): void { function startObserving(): void {
if (!observationRoot) { if (isDisposed || !observationRoot) {
return; return;
} }
@ -718,6 +732,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
async function runSyncCycle(): Promise<void> { async function runSyncCycle(): Promise<void> {
if (isDisposed) {
return;
}
if (isSyncRunning) { if (isSyncRunning) {
needsResync = true; needsResync = true;
return; return;
@ -725,11 +743,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
isSyncRunning = true; isSyncRunning = true;
try { try {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
await hydrateCurrentPage(); await hydrateCurrentPage();
applyCurrentView(); applyCurrentView();
lastKnownPageSignature = readMarketPageSignature(options.document); lastKnownPageSignature = readMarketPageSignature(options.document);
} finally { } finally {
isSyncRunning = false; isSyncRunning = false;
if (isDisposed) {
return;
}
if (needsResync) { if (needsResync) {
needsResync = false; needsResync = false;
scheduleSync(); 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( function getNextSortState(
currentSort: MarketSortState | undefined, currentSort: MarketSortState | undefined,
field: MarketSortState["field"] field: MarketSortState["field"]

View File

@ -1,50 +1,9 @@
import type { import type {
MarketExportScope, MarketExportScope,
MarketExportTarget, MarketExportTarget
MarketSortState
} from "./types"; } 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 { export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void; onExport(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void; onSubmitBatch(): Promise<void> | void;
} }
@ -55,13 +14,15 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement; exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement; exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement; exportStatusText: HTMLElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement; root: HTMLElement;
singleFilterInput: HTMLInputElement; }
sortApplyButton: HTMLButtonElement;
sortDirectionSelect: HTMLSelectElement; export function isPluginToolbarMounted(
sortFieldSelect: HTMLSelectElement; root: HTMLElement,
document: Document
): boolean {
const actionRow = findNativeActionRow(document);
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
} }
export function ensurePluginToolbar( export function ensurePluginToolbar(
@ -72,53 +33,13 @@ export function ensurePluginToolbar(
"[data-plugin-toolbar='root']" "[data-plugin-toolbar='root']"
) as HTMLElement | null; ) as HTMLElement | null;
if (existingRoot) { if (existingRoot) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot); return readToolbarDom(existingRoot);
} }
const root = document.createElement("section"); const root = document.createElement("section");
root.dataset.pluginToolbar = "root"; root.dataset.pluginToolbar = "root";
applyToolbarRootStyles(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 = "提交批次";
const exportRangeSelect = document.createElement("select"); const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select"; exportRangeSelect.dataset.pluginExportRange = "select";
@ -134,32 +55,40 @@ export function ensurePluginToolbar(
exportCustomPagesInput.min = "1"; exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1"; exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true; exportCustomPagesInput.hidden = true;
exportCustomPagesInput.placeholder = "页数";
exportCustomPagesInput.dataset.pluginExportCustomPages = "input"; 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"); const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text"; exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText);
root.append( root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportRangeSelect, exportRangeSelect,
exportCustomPagesInput, exportCustomPagesInput,
exportButton, exportButton,
batchSubmitButton batchSubmitButton,
exportStatusText
); );
root.append(exportStatusText);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => { document.body.appendChild(root);
void handlers.onApplyFilter(); applyNativeControlStyles(document, {
}); batchSubmitButton,
sortApplyButton.addEventListener("click", () => { exportButton,
void handlers.onApplySort(); exportCustomPagesInput,
exportRangeSelect
}); });
ensureToolbarMounted(root, document);
exportButton.addEventListener("click", () => { exportButton.addEventListener("click", () => {
void handlers.onExport(); void handlers.onExport();
}); });
@ -173,13 +102,7 @@ export function ensurePluginToolbar(
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
exportStatusText, exportStatusText,
filterApplyButton, root
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
}); });
}); });
@ -189,13 +112,7 @@ export function ensurePluginToolbar(
exportCustomPagesInput, exportCustomPagesInput,
exportRangeSelect, exportRangeSelect,
exportStatusText, exportStatusText,
filterApplyButton, root
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
@ -230,25 +147,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector( exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]' '[data-plugin-export-status="text"]'
) as HTMLElement, ) as HTMLElement,
filterApplyButton: root.querySelector( root
'[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
} satisfies PluginToolbarDom; } satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom); syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom; return toolbarDom;
@ -316,12 +215,6 @@ export function setToolbarBusyState(
[ [
toolbar.batchSubmitButton, toolbar.batchSubmitButton,
toolbar.exportButton, toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,
toolbar.singleFilterInput,
toolbar.personalFilterInput,
toolbar.sortFieldSelect,
toolbar.sortDirectionSelect,
toolbar.exportRangeSelect, toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput toolbar.exportCustomPagesInput
].forEach((element) => { ].forEach((element) => {
@ -336,15 +229,243 @@ export function setToolbarExportStatus(
toolbar.exportStatusText.textContent = text; 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 { function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden = toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom"; 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;
}

View File

@ -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";

View File

@ -10,12 +10,14 @@ import {
describe("backend-metrics-client", () => { describe("backend-metrics-client", () => {
test("exports the default backend metrics base url", () => { 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", () => { test("builds the backend search url", () => {
expect(buildBackendMetricsSearchUrl("http://192.168.31.29:8083")).toBe( expect(buildBackendMetricsSearchUrl("https://talent-search.intelligrow.cn")).toBe(
"http://192.168.31.29:8083/api/v1/history/talents/search" "https://talent-search.intelligrow.cn/api/v1/history/talents/search"
); );
}); });
@ -71,7 +73,7 @@ describe("backend-metrics-client", () => {
}), }),
ok: true, ok: true,
status: 200, 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 fetchSpy = vi.fn(fetchImpl);
const client = createBackendMetricsClient({ const client = createBackendMetricsClient({
@ -82,7 +84,7 @@ describe("backend-metrics-client", () => {
await client.searchByStarIds(["111", "222"]); await client.searchByStarIds(["111", "222"]);
expect(fetchSpy).toHaveBeenCalledWith( 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({ expect.objectContaining({
body: JSON.stringify({ body: JSON.stringify({
page: 1, page: 1,

View File

@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload"; import { createBatchPayload } from "../src/content/market/batch-payload";
describe("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({ const payload = createBatchPayload({
authState: { authState: {
isAuthenticated: true, isAuthenticated: true,
@ -26,7 +26,7 @@ describe("batch-payload", () => {
{ authorId: "111", authorName: "达人A" }, { authorId: "111", authorName: "达人A" },
{ authorId: "222", authorName: "达人B" } { authorId: "222", authorName: "达人B" }
], ],
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z", batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批", batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z", createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿", creatorName: "王少卿",

View File

@ -11,6 +11,7 @@ describe("market-content-entry", () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ""; document.body.innerHTML = "";
document.documentElement.removeAttribute("data-sces-market-rows"); document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-test-page-index");
window.history.replaceState({}, "", "/"); window.history.replaceState({}, "", "/");
}); });
@ -33,6 +34,7 @@ describe("market-content-entry", () => {
} }
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__; ).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
document.documentElement.removeAttribute("data-sces-market-rows"); document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-test-page-index");
vi.resetModules(); vi.resetModules();
while (disposers.length > 0) { 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 () => { test("hydrates current page rows on start", async () => {
document.body.innerHTML = buildMarketFixture(); 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 () => { test("clicking plugin sort headers cycles sort state", 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 () => {
document.body.innerHTML = buildRealMarketFixture([ document.body.innerHTML = buildRealMarketFixture([
{ {
authorId: "111", authorId: "111",
@ -699,8 +703,6 @@ describe("market-content-entry", () => {
await flush(); await flush();
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]);
expectSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
expectSelectValue('[data-plugin-sort-direction="select"]', "desc");
expect( expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction") ?.getAttribute("data-market-sort-direction")
@ -710,7 +712,6 @@ describe("market-content-entry", () => {
await flush(); await flush();
expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]);
expectSelectValue('[data-plugin-sort-direction="select"]', "asc");
expect( expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction") ?.getAttribute("data-market-sort-direction")
@ -720,8 +721,6 @@ describe("market-content-entry", () => {
await flush(); await flush();
expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]);
expectSelectValue('[data-plugin-sort-field="select"]', "");
expectSelectValue('[data-plugin-sort-direction="select"]', "desc");
expect( expect(
document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]')
?.getAttribute("data-market-sort-direction") ?.getAttribute("data-market-sort-direction")
@ -766,8 +765,6 @@ describe("market-content-entry", () => {
await flush(); await flush();
expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); 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 () => { 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%", singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%" personalVideoAfterSearchRate: "0.01% - 0.1%"
}); });
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); click('[data-market-sort-field="singleVideoAfterSearchRate"]');
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
click('[data-plugin-sort-apply="button"]');
await flush(); await flush();
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export="button"]'); click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 40, 50); await waitForMockCall(buildCsv, 80, 50);
expect(buildCsv).toHaveBeenCalledWith( expect(buildCsv).toHaveBeenCalledWith(
expect.arrayContaining([ expect.arrayContaining([
@ -1018,57 +1015,57 @@ describe("market-content-entry", () => {
15000 15000
); );
test("exporting all pages disables the toolbar during the task and stops at the final page", async () => { test(
const pages = [ "exporting all pages disables the native action bar controls during the task and stops at the final page",
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], async () => {
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], const pages = [
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] [{ 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]); document.body.innerHTML = buildRealMarketFixture(pages[0]);
const pagination = installAsyncPaginationHarness(pages); const pagination = installPaginationHarness(pages);
const buildCsv = vi.fn(() => "csv-output"); const buildCsv = vi.fn(() => "csv-output");
const { createMarketController } = await import("../src/content/market/index"); const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({ const controller = trackController(createMarketController({
buildCsv, buildCsv,
document, document,
loadAuthorMetrics: async () => ({ loadAuthorMetrics: async () => ({
success: false, success: false,
reason: "request-failed" reason: "request-failed"
}), }),
onCsvReady: vi.fn(), onCsvReady: vi.fn(),
window window
})); }));
await controller.ready; await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all"); setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]'); 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-batch-submit="button"]', true);
expectButtonDisabled('[data-plugin-export="button"]', true); expectButtonDisabled('[data-plugin-export="button"]', true);
expectButtonDisabled('[data-plugin-filter-apply="button"]', true); expectSelectDisabled('[data-plugin-export-range="select"]', true);
expectButtonDisabled('[data-plugin-sort-apply="button"]', true); expect(
expectSelectDisabled('[data-plugin-export-range="select"]', true); document.querySelector('[data-plugin-export-status="text"]')?.textContent
expect( ).toContain("导出中");
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("导出中");
await waitForMockCall(buildCsv, 80, 100); await waitForMockCall(buildCsv, 120, 100);
expect(pagination.getClicks()).toBe(2); expect(pagination.getClicks()).toBe(2);
expectButtonDisabled('[data-plugin-batch-submit="button"]', false); expectButtonDisabled('[data-plugin-batch-submit="button"]', false);
expectButtonDisabled('[data-plugin-export="button"]', false); expectButtonDisabled('[data-plugin-export="button"]', false);
expectSelectDisabled('[data-plugin-export-range="select"]', false); expectSelectDisabled('[data-plugin-export-range="select"]', false);
expect(buildCsv).toHaveBeenCalledTimes(1); expect(buildCsv).toHaveBeenCalledTimes(1);
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(
"111", expect.arrayContaining(["222", "333"])
"222", );
"333" },
]); 15000
}); );
test("custom export range blocks invalid page counts", async () => { test("custom export range blocks invalid page counts", async () => {
document.body.innerHTML = buildMarketFixture(); document.body.innerHTML = buildMarketFixture();
@ -1132,7 +1129,7 @@ describe("market-content-entry", () => {
expect(promptBatchName).toHaveBeenCalledTimes(1); expect(promptBatchName).toHaveBeenCalledTimes(1);
expect(submitBatch).toHaveBeenCalledWith( expect(submitBatch).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
batchId: expect.stringContaining("618达人筛选第一批-"), batchId: expect.stringContaining("p7pdhhtde8kj-"),
batchName: "618达人筛选第一批", batchName: "618达人筛选第一批",
logtoUserId: "p7pdhhtde8kj" logtoUserId: "p7pdhhtde8kj"
}) })
@ -1984,62 +1981,13 @@ describe("market-content-entry", () => {
?.textContent ?.textContent
).toBe("0.8% - 1%"); ).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() { function buildMarketFixture() {
return buildMarketPageShell(buildMarketTableOnlyFixture());
}
function buildMarketTableOnlyFixture() {
return ` return `
<div data-market-table> <div data-market-table>
<div data-market-header> <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( function buildRealMarketFixture(
rows: Array<{ rows: Array<{
authorId: string; authorId: string;
@ -2067,7 +2044,7 @@ function buildRealMarketFixture(
price21To60s: string; price21To60s: string;
}> }>
) { ) {
return ` return buildMarketPageShell(`
<div class="base-author-list" data-testid="market-root"> <div class="base-author-list" data-testid="market-root">
<div class="section-wrapper sticky-header hide-scrollbar"> <div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;"> <div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
@ -2129,7 +2106,7 @@ function buildRealMarketFixture(
</div> </div>
</div> </div>
<button data-testid="next-page" type="button"></button> <button data-testid="next-page" type="button"></button>
`; `);
} }
function buildRichExportMarketFixture( function buildRichExportMarketFixture(