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,
|
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:
|
||||||
|
|||||||
@ -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,15 +1076,14 @@ 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 setupState of setupStates) {
|
||||||
for (const value of Object.values(setupState)) {
|
for (const value of Object.values(setupState)) {
|
||||||
const candidate = unwrapVueRef(value);
|
const candidate = unwrapVueRef(value);
|
||||||
if (!candidate || typeof candidate !== "object") {
|
if (!candidate || typeof candidate !== "object") {
|
||||||
@ -1119,10 +1125,43 @@ function readVueMarketRows(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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", () => {
|
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,
|
||||||
|
|||||||
@ -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: "王少卿",
|
||||||
|
|||||||
@ -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,7 +1015,9 @@ describe("market-content-entry", () => {
|
|||||||
15000
|
15000
|
||||||
);
|
);
|
||||||
|
|
||||||
test("exporting all pages disables the toolbar during the task and stops at the final page", async () => {
|
test(
|
||||||
|
"exporting all pages disables the native action bar controls during the task and stops at the final page",
|
||||||
|
async () => {
|
||||||
const pages = [
|
const pages = [
|
||||||
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
|
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
|
||||||
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
|
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
|
||||||
@ -1026,7 +1025,7 @@ describe("market-content-entry", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
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");
|
||||||
@ -1049,26 +1048,24 @@ describe("market-content-entry", () => {
|
|||||||
|
|
||||||
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);
|
|
||||||
expectButtonDisabled('[data-plugin-sort-apply="button"]', true);
|
|
||||||
expectSelectDisabled('[data-plugin-export-range="select"]', true);
|
expectSelectDisabled('[data-plugin-export-range="select"]', true);
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||||
).toContain("导出中");
|
).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(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user