Compare commits
8 Commits
c7ae2fbfcb
...
3e2d7b36f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e2d7b36f2 | |||
| b3bcc2af45 | |||
| fb45f0cea8 | |||
| bee8cb0207 | |||
| 24e8a3ba9a | |||
| 233de28713 | |||
| a51c6f7bf2 | |||
| 2f77199920 |
@ -0,0 +1,79 @@
|
|||||||
|
# Market Backend Metrics CSV 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:** Extend CSV export so it appends the six backend metrics already stored on each market record.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing export flow intact and modify only the CSV column definition layer. Reuse `MarketRecord.backendMetrics` as the sole source for the six new CSV columns so the exported data matches what the plugin already loaded into memory.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, existing market CSV exporter, Vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify: `src/content/market/csv-exporter.ts`
|
||||||
|
- Append six backend metrics columns after existing CSV columns.
|
||||||
|
- Modify: `tests/csv-exporter.test.ts`
|
||||||
|
- Add failing tests for backend metric headers and values.
|
||||||
|
|
||||||
|
### Task 1: Backend Metrics CSV Columns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/csv-exporter.test.ts`
|
||||||
|
- Modify: `src/content/market/csv-exporter.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing CSV exporter tests**
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
- the six backend metric headers appended after current columns
|
||||||
|
- backend metric values exported from `record.backendMetrics`
|
||||||
|
- blank cells when `backendMetrics` is absent
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/csv-exporter.test.ts`
|
||||||
|
Expected: FAIL because the exporter does not include backend metric columns yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the minimal exporter change**
|
||||||
|
|
||||||
|
Append these six columns:
|
||||||
|
- `看后搜率`
|
||||||
|
- `看后搜数`
|
||||||
|
- `新增A3数`
|
||||||
|
- `新增A3率`
|
||||||
|
- `CPA3`
|
||||||
|
- `cp_search`
|
||||||
|
|
||||||
|
Each column reads from:
|
||||||
|
- `record.backendMetrics?.afterViewSearchRate`
|
||||||
|
- `record.backendMetrics?.afterViewSearchCount`
|
||||||
|
- `record.backendMetrics?.a3IncreaseCount`
|
||||||
|
- `record.backendMetrics?.newA3Rate`
|
||||||
|
- `record.backendMetrics?.cpa3`
|
||||||
|
- `record.backendMetrics?.cpSearch`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/csv-exporter.test.ts`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- full test suite passes
|
||||||
|
- build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/content/market/csv-exporter.ts tests/csv-exporter.test.ts docs/superpowers/specs/2026-04-22-market-backend-metrics-csv-design.md docs/superpowers/plans/2026-04-22-market-backend-metrics-csv.md
|
||||||
|
git commit -m "feat: export backend metrics in csv"
|
||||||
|
```
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
# Market Scrollable Plugin Columns 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 plugin-generated market columns out of the right sticky area and into the horizontally scrollable middle area.
|
||||||
|
|
||||||
|
**Architecture:** Keep Xingtu's native left and right sticky sections intact. Add a dedicated non-sticky plugin section for the injected columns and continue rendering row state through the existing `MarketRowDom` abstraction.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Vitest, jsdom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Lock The Intended Layout In Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/market-dom-sync.test.ts`
|
||||||
|
- Modify: `tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for the layout boundary**
|
||||||
|
|
||||||
|
Add assertions that:
|
||||||
|
- the right sticky header/body widths remain native after plugin columns are added
|
||||||
|
- plugin columns live in a dedicated non-sticky section instead of the right sticky section
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal layout changes**
|
||||||
|
|
||||||
|
Update the div-grid sync path so plugin columns are inserted into a separate scrollable section.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run the same tests**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/market-dom-sync.test.ts tests/market-content-entry.test.ts src/content/market/dom-sync.ts
|
||||||
|
git commit -m "feat: move market plugin columns into scrollable section"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Verify No Export Regression
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify only: `src/content/market/index.ts`
|
||||||
|
- Verify only: `src/content/market/export-range-controller.ts`
|
||||||
|
- Verify only: `tests/full-scan-controller.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run export-related regression tests**
|
||||||
|
|
||||||
|
Run: `npm test -- tests/full-scan-controller.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Report any unrelated failures separately**
|
||||||
|
|
||||||
|
Do not broaden the scope unless a new failure is caused by the layout change.
|
||||||
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,63 @@
|
|||||||
|
# Market Backend Metrics CSV Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Extend the existing CSV export so it includes the six backend metrics already shown in the plugin UI.
|
||||||
|
|
||||||
|
## Confirmed Decisions
|
||||||
|
|
||||||
|
- Reuse the current export flow.
|
||||||
|
- Do not add a separate backend request for CSV export.
|
||||||
|
- Read backend metrics directly from the in-memory `MarketRecord`.
|
||||||
|
- Append the six backend metrics columns after the existing CSV columns.
|
||||||
|
- Keep the existing CSV columns and ordering unchanged.
|
||||||
|
- Use these exact CSV headers:
|
||||||
|
- `看后搜率`
|
||||||
|
- `看后搜数`
|
||||||
|
- `新增A3数`
|
||||||
|
- `新增A3率`
|
||||||
|
- `CPA3`
|
||||||
|
- `cp_search`
|
||||||
|
- If a record has no backend metrics, export empty strings for these six columns.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `src/content/market/csv-exporter.ts` remains the single place that defines CSV column layout.
|
||||||
|
- The exporter will keep current base columns and Xingtu rate columns, then append six backend metrics columns.
|
||||||
|
- No UI changes.
|
||||||
|
- No batch submission changes.
|
||||||
|
- No popup or config changes.
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
Each exported row will read from:
|
||||||
|
|
||||||
|
- existing fields:
|
||||||
|
- `authorId`
|
||||||
|
- `authorName`
|
||||||
|
- `location`
|
||||||
|
- `price21To60s`
|
||||||
|
- `rates.singleVideoAfterSearchRate`
|
||||||
|
- `rates.personalVideoAfterSearchRate`
|
||||||
|
- new backend metrics fields:
|
||||||
|
- `backendMetrics.afterViewSearchRate`
|
||||||
|
- `backendMetrics.afterViewSearchCount`
|
||||||
|
- `backendMetrics.a3IncreaseCount`
|
||||||
|
- `backendMetrics.newA3Rate`
|
||||||
|
- `backendMetrics.cpa3`
|
||||||
|
- `backendMetrics.cpSearch`
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- Missing backend metrics: export blank cells
|
||||||
|
- Existing rate formatting behavior remains unchanged
|
||||||
|
- Backend loading state does not alter CSV structure; it only affects whether the cells contain values or blanks
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add tests for:
|
||||||
|
|
||||||
|
- backend metric headers appended to CSV
|
||||||
|
- backend metric values exported correctly
|
||||||
|
- empty backend metric cells when metrics are absent
|
||||||
|
- no regression in current base/rate export behavior
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Market Scrollable Plugin Columns Design
|
||||||
|
|
||||||
|
**Goal:** Keep the plugin-generated columns visible without letting them cover Xingtu's native middle columns.
|
||||||
|
|
||||||
|
**Problem:** The current implementation injects plugin columns into the right sticky section. That expands the sticky width and causes native middle columns such as `预期播放量` and `互动率` to be visually covered.
|
||||||
|
|
||||||
|
**Approved Direction:** Keep plugin columns always visible, but move them out of the right sticky area. The plugin columns should live in the horizontally scrollable middle area so they scroll together with the native table columns.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- Preserve the native left sticky author section.
|
||||||
|
- Preserve the native right sticky section for Xingtu's own right-side columns, especially `21-60s报价` and `操作`.
|
||||||
|
- Insert plugin columns as a separate non-sticky section immediately before the right sticky section.
|
||||||
|
- Let the plugin section participate in the same horizontal flow as the middle columns so users reach it through horizontal scrolling.
|
||||||
|
|
||||||
|
### DOM Sync Responsibilities
|
||||||
|
|
||||||
|
- `syncDivGridRoot()` remains responsible for locating the author section, middle columns, and right sticky section.
|
||||||
|
- Plugin header cells and plugin body columns should no longer be inserted into `actionHeader.parentElement` / `actionColumn.parentElement`.
|
||||||
|
- A dedicated plugin section should be created or reused under the header row and body row.
|
||||||
|
- Row alignment logic should still read plugin cells row-by-row for rendering, visibility, and ordering.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Export, filtering, sorting, and row hydration should keep working unchanged.
|
||||||
|
- Only column placement changes.
|
||||||
|
- Existing synthetic table mode is unaffected.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add regression coverage proving the right sticky section width stays at the native width.
|
||||||
|
- Add regression coverage proving plugin columns render in a separate non-sticky section.
|
||||||
|
- Keep existing export and hydration behavior green.
|
||||||
@ -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 结构
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
} from "../shared/auth-messages";
|
} from "../shared/auth-messages";
|
||||||
import { createBatchSubmitClient } from "../shared/batch-submit-client";
|
import { createBatchSubmitClient } from "../shared/batch-submit-client";
|
||||||
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
|
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
|
||||||
|
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../shared/batch-submit-config";
|
||||||
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
|
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
|
||||||
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
|
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
|
||||||
|
|
||||||
@ -81,7 +82,7 @@ export function registerBackgroundMessageHandler(
|
|||||||
authClient: createLogtoAuthClient()
|
authClient: createLogtoAuthClient()
|
||||||
});
|
});
|
||||||
submitBatch ??= createBatchSubmitClient({
|
submitBatch ??= createBatchSubmitClient({
|
||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: DEFAULT_BATCH_SUBMIT_BASE_URL,
|
||||||
getAccessToken: () => authController!.getAccessToken(),
|
getAccessToken: () => authController!.getAccessToken(),
|
||||||
sendMessage: () =>
|
sendMessage: () =>
|
||||||
Promise.reject(new Error("background batch submit does not use sendMessage"))
|
Promise.reject(new Error("background batch submit does not use sendMessage"))
|
||||||
|
|||||||
@ -109,7 +109,7 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
|
|||||||
data.personal_avg_search_after_view_rate
|
data.personal_avg_search_after_view_rate
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
|
if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
reason: "missing-rate"
|
reason: "missing-rate"
|
||||||
@ -119,8 +119,8 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
rates: {
|
rates: {
|
||||||
singleVideoAfterSearchRate,
|
...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}),
|
||||||
personalVideoAfterSearchRate
|
...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -43,9 +43,40 @@ const RATE_COLUMNS: CsvColumn[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||||
|
{
|
||||||
|
header: "看后搜率",
|
||||||
|
readValue: (record: MarketRecord) =>
|
||||||
|
record.backendMetrics?.afterViewSearchRate ?? ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "看后搜数",
|
||||||
|
readValue: (record: MarketRecord) =>
|
||||||
|
record.backendMetrics?.afterViewSearchCount ?? ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "新增A3数",
|
||||||
|
readValue: (record: MarketRecord) =>
|
||||||
|
record.backendMetrics?.a3IncreaseCount ?? ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "新增A3率",
|
||||||
|
readValue: (record: MarketRecord) =>
|
||||||
|
record.backendMetrics?.newA3Rate ?? ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "CPA3",
|
||||||
|
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "cp_search",
|
||||||
|
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||||
const baseColumns = buildBaseColumns(records);
|
const baseColumns = buildBaseColumns(records);
|
||||||
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
|
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = records.map((record) =>
|
const rowLines = records.map((record) =>
|
||||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||||
@ -57,10 +88,11 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
|||||||
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
const orderedHeaders: string[] = [];
|
const orderedHeaders: string[] = [];
|
||||||
const seenHeaders = new Set<string>();
|
const seenHeaders = new Set<string>();
|
||||||
|
const excludedHeaders = new Set(["代表视频"]);
|
||||||
|
|
||||||
records.forEach((record) => {
|
records.forEach((record) => {
|
||||||
Object.keys(record.exportFields ?? {}).forEach((header) => {
|
Object.keys(record.exportFields ?? {}).forEach((header) => {
|
||||||
if (seenHeaders.has(header)) {
|
if (seenHeaders.has(header) || excludedHeaders.has(header)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ interface ExportRangeControllerOptions {
|
|||||||
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
|
||||||
prepareCurrentPageForExport(): Promise<void>;
|
prepareCurrentPageForExport(): Promise<void>;
|
||||||
readCurrentPageRecords(): MarketRecord[];
|
readCurrentPageRecords(): MarketRecord[];
|
||||||
|
readCurrentPageRowCount(): number;
|
||||||
window: Window;
|
window: Window;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,13 +27,10 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
|||||||
currentPage,
|
currentPage,
|
||||||
totalPages: target.mode === "count" ? target.pageCount : undefined
|
totalPages: target.mode === "count" ? target.pageCount : undefined
|
||||||
});
|
});
|
||||||
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
|
const currentPageRecords = await preparePageRecords(expectedMinimumRowCount);
|
||||||
if (!currentPageReady) {
|
if (!currentPageRecords) {
|
||||||
throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`);
|
throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await options.prepareCurrentPageForExport();
|
|
||||||
const currentPageRecords = options.readCurrentPageRecords();
|
|
||||||
currentPageRecords.forEach((record) => {
|
currentPageRecords.forEach((record) => {
|
||||||
const existingRecord = mergedRecords.get(record.authorId);
|
const existingRecord = mergedRecords.get(record.authorId);
|
||||||
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
|
||||||
@ -63,6 +61,33 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function preparePageRecords(
|
||||||
|
expectedMinimumRowCount: number | undefined
|
||||||
|
): Promise<MarketRecord[] | null> {
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
const currentPageReady = await waitForCurrentPageReady();
|
||||||
|
if (!currentPageReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await options.prepareCurrentPageForExport();
|
||||||
|
const currentPageRecords = options.readCurrentPageRecords();
|
||||||
|
if (
|
||||||
|
currentPageRecords.length > 0 &&
|
||||||
|
(
|
||||||
|
typeof expectedMinimumRowCount !== "number" ||
|
||||||
|
expectedMinimumRowCount <= 0 ||
|
||||||
|
isCurrentPageTerminal() ||
|
||||||
|
currentPageRecords.length >= expectedMinimumRowCount
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return currentPageRecords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForPageChange(previousSignature: string): Promise<boolean> {
|
async function waitForPageChange(previousSignature: string): Promise<boolean> {
|
||||||
const previousPageState = parsePageSignature(previousSignature);
|
const previousPageState = parsePageSignature(previousSignature);
|
||||||
|
|
||||||
@ -82,9 +107,7 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForCurrentPageReady(
|
async function waitForCurrentPageReady(): Promise<boolean> {
|
||||||
expectedMinimumRowCount: number | undefined
|
|
||||||
): Promise<boolean> {
|
|
||||||
let stableAttemptCount = 0;
|
let stableAttemptCount = 0;
|
||||||
let lastReadyFingerprint = "";
|
let lastReadyFingerprint = "";
|
||||||
|
|
||||||
@ -101,17 +124,6 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
typeof expectedMinimumRowCount === "number" &&
|
|
||||||
expectedMinimumRowCount > 0 &&
|
|
||||||
!pageState.isTerminalPage &&
|
|
||||||
pageState.rowCount < expectedMinimumRowCount
|
|
||||||
) {
|
|
||||||
stableAttemptCount = 0;
|
|
||||||
lastReadyFingerprint = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const readyFingerprint = [
|
const readyFingerprint = [
|
||||||
pageState.pageToken,
|
pageState.pageToken,
|
||||||
pageState.authorIds,
|
pageState.authorIds,
|
||||||
@ -146,9 +158,13 @@ export function createExportRangeController(options: ExportRangeControllerOption
|
|||||||
authorIds: pageSignature.authorIds,
|
authorIds: pageSignature.authorIds,
|
||||||
isTerminalPage: isPageControlDisabled(nextPageControl),
|
isTerminalPage: isPageControlDisabled(nextPageControl),
|
||||||
pageToken: pageSignature.pageToken,
|
pageToken: pageSignature.pageToken,
|
||||||
rowCount: options.readCurrentPageRecords().length
|
rowCount: options.readCurrentPageRowCount()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCurrentPageTerminal(): boolean {
|
||||||
|
return isPageControlDisabled(findNextPageControl(options.document));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePageSignature(signature: string): {
|
function parsePageSignature(signature: string): {
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import {
|
|||||||
parseRateLowerBound
|
parseRateLowerBound
|
||||||
} from "../../shared/rate-normalizer";
|
} from "../../shared/rate-normalizer";
|
||||||
import type {
|
import type {
|
||||||
|
AfterSearchRates,
|
||||||
|
BackendMetrics,
|
||||||
|
MarketSortField,
|
||||||
MarketFilterState,
|
MarketFilterState,
|
||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketSortState
|
MarketSortState
|
||||||
@ -67,13 +70,26 @@ function compareRecords(
|
|||||||
rightRecord: MarketRecord,
|
rightRecord: MarketRecord,
|
||||||
sort: MarketSortState
|
sort: MarketSortState
|
||||||
): number {
|
): number {
|
||||||
const leftValue = leftRecord.rates?.[sort.field];
|
if (isRateSortField(sort.field)) {
|
||||||
const rightValue = rightRecord.rates?.[sort.field];
|
return compareRateSortRecords(leftRecord, rightRecord, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRateSortRecords(
|
||||||
|
leftRecord: MarketRecord,
|
||||||
|
rightRecord: MarketRecord,
|
||||||
|
sort: MarketSortState
|
||||||
|
): number {
|
||||||
|
const field = sort.field as keyof Required<AfterSearchRates>;
|
||||||
|
const leftValue = leftRecord.rates?.[field];
|
||||||
|
const rightValue = rightRecord.rates?.[field];
|
||||||
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
|
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
|
||||||
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
|
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
|
||||||
|
|
||||||
if (leftLowerBound == null && rightLowerBound == null) {
|
if (leftLowerBound == null && rightLowerBound == null) {
|
||||||
return 0;
|
return compareRecordIdentity(leftRecord, rightRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (leftLowerBound == null) {
|
if (leftLowerBound == null) {
|
||||||
@ -91,5 +107,72 @@ function compareRecords(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tieBreak = compareRateValues(leftValue, rightValue);
|
const tieBreak = compareRateValues(leftValue, rightValue);
|
||||||
return sort.direction === "asc" ? tieBreak : -tieBreak;
|
if (tieBreak !== 0) {
|
||||||
|
return sort.direction === "asc" ? tieBreak : -tieBreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareRecordIdentity(leftRecord, rightRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBackendMetricRecords(
|
||||||
|
leftRecord: MarketRecord,
|
||||||
|
rightRecord: MarketRecord,
|
||||||
|
sort: MarketSortState
|
||||||
|
): number {
|
||||||
|
const field = sort.field as keyof Required<BackendMetrics>;
|
||||||
|
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
|
||||||
|
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
|
||||||
|
|
||||||
|
if (leftValue == null && rightValue == null) {
|
||||||
|
return compareRecordIdentity(leftRecord, rightRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftValue == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightValue == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftValue !== rightValue) {
|
||||||
|
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compareRecordIdentity(leftRecord, rightRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBackendMetricValue(value: string | null | undefined): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(normalizedValue);
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRateSortField(
|
||||||
|
field: MarketSortField
|
||||||
|
): field is keyof Required<AfterSearchRates> {
|
||||||
|
return (
|
||||||
|
field === "singleVideoAfterSearchRate" ||
|
||||||
|
field === "personalVideoAfterSearchRate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareRecordIdentity(
|
||||||
|
leftRecord: MarketRecord,
|
||||||
|
rightRecord: MarketRecord
|
||||||
|
): number {
|
||||||
|
const authorIdCompare = leftRecord.authorId.localeCompare(rightRecord.authorId);
|
||||||
|
if (authorIdCompare !== 0) {
|
||||||
|
return authorIdCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftRecord.authorName.localeCompare(rightRecord.authorName);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,16 @@ import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
|||||||
import {
|
import {
|
||||||
applyRowOrder,
|
applyRowOrder,
|
||||||
applyRowVisibility,
|
applyRowVisibility,
|
||||||
|
readMarketPageSignature,
|
||||||
renderMarketRowState,
|
renderMarketRowState,
|
||||||
|
syncPluginSortHeaders,
|
||||||
syncMarketTable,
|
syncMarketTable,
|
||||||
type MarketRowDom
|
type MarketRowDom
|
||||||
} from "./dom-sync";
|
} from "./dom-sync";
|
||||||
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,
|
||||||
@ -25,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,
|
||||||
@ -88,40 +89,41 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
},
|
},
|
||||||
prepareCurrentPageForExport: prepareCurrentPageForExport,
|
prepareCurrentPageForExport: prepareCurrentPageForExport,
|
||||||
readCurrentPageRecords: () => getVisibleOrderedRecords(),
|
readCurrentPageRecords: () => getVisibleOrderedRecords(),
|
||||||
|
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 needsResync = false;
|
let needsResync = false;
|
||||||
|
let scheduledSyncTimeoutId: number | null = null;
|
||||||
|
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||||
const observer = mutationObserverFactory(() => {
|
const observer = mutationObserverFactory(() => {
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextPageSignature = lastKnownPageSignature;
|
||||||
|
try {
|
||||||
|
nextPageSignature = readMarketPageSignature(options.document);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarNeedsRemount =
|
||||||
|
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
||||||
|
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
});
|
});
|
||||||
const observationRoot = options.document.body ?? options.document.documentElement;
|
const observationRoot = options.document.body ?? options.document.documentElement;
|
||||||
if (observationRoot) {
|
startObserving();
|
||||||
observer.observe(observationRoot, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
@ -185,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
|
||||||
};
|
};
|
||||||
@ -209,7 +217,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
for (const rowDom of table.rows) {
|
for (const rowDom of table.rows) {
|
||||||
const rowSnapshot = readRowSnapshot(rowDom);
|
const rowSnapshot = readRowSnapshot(rowDom);
|
||||||
if (!rowSnapshot.authorId) {
|
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,21 +370,34 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyCurrentView(): void {
|
function applyCurrentView(): void {
|
||||||
const table = syncMarketTable(options.document);
|
runWithoutMutationSync(() => {
|
||||||
if (!table) {
|
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
|
||||||
return;
|
const table = syncMarketTable(options.document);
|
||||||
}
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const records = getVisibleOrderedRecords(table);
|
syncPluginSortHeaders(options.document, {
|
||||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
activeSort,
|
||||||
applyRowOrder(table, records.map((record) => record.authorId));
|
onToggleSort: toggleSortFromHeader
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = getVisibleOrderedRecords(table);
|
||||||
|
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||||
|
applyRowOrder(table, records.map((record) => record.authorId));
|
||||||
|
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
||||||
|
activeSort = getNextSortState(activeSort, field);
|
||||||
|
applyCurrentView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
|
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
|
||||||
const currentPageRecords = readCurrentPageRecords(table);
|
const currentPageRecords = readCurrentPageRecords(table);
|
||||||
|
|
||||||
return applyFilterAndSort(currentPageRecords, {
|
return applyFilterAndSort(currentPageRecords, {
|
||||||
filters: activeFilters,
|
|
||||||
sort: activeSort
|
sort: activeSort
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -397,6 +418,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
async function prepareCurrentPageForExport(): Promise<void> {
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await harvestCurrentPageForExport();
|
await harvestCurrentPageForExport();
|
||||||
|
await runSyncCycle();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function harvestCurrentPageForExport(): Promise<void> {
|
async function harvestCurrentPageForExport(): Promise<void> {
|
||||||
@ -445,29 +467,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return table.rows
|
return table.rows
|
||||||
.map((rowDom) => {
|
.map((rowDom) => {
|
||||||
const rowSnapshot = readRowSnapshot(rowDom);
|
const rowSnapshot = readRowSnapshot(rowDom);
|
||||||
if (!rowSnapshot.authorId) {
|
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
|
||||||
|
const authorName =
|
||||||
|
mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "";
|
||||||
|
const location = mergeStringValue(existingRecord?.location, rowSnapshot.location);
|
||||||
|
const price21To60s = mergeStringValue(
|
||||||
|
existingRecord?.price21To60s,
|
||||||
|
rowSnapshot.price21To60s
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...existingRecord,
|
...existingRecord,
|
||||||
...rowSnapshot,
|
...rowSnapshot,
|
||||||
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
|
authorName,
|
||||||
backendMetrics: mergeFieldMap(
|
backendMetrics: mergeFieldMap(
|
||||||
existingRecord?.backendMetrics,
|
existingRecord?.backendMetrics,
|
||||||
rowSnapshot.backendMetrics
|
rowSnapshot.backendMetrics
|
||||||
),
|
),
|
||||||
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
|
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
|
||||||
exportFields: mergeFieldMap(
|
exportFields: withExportFieldFallbacks(
|
||||||
existingRecord?.exportFields,
|
mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields),
|
||||||
rowSnapshot.exportFields
|
{
|
||||||
),
|
authorName,
|
||||||
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
|
location,
|
||||||
price21To60s: mergeStringValue(
|
price21To60s
|
||||||
existingRecord?.price21To60s,
|
}
|
||||||
rowSnapshot.price21To60s
|
|
||||||
),
|
),
|
||||||
|
location,
|
||||||
|
price21To60s,
|
||||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||||
status: existingRecord?.status ?? "idle"
|
status: existingRecord?.status ?? "idle"
|
||||||
} satisfies MarketRecord;
|
} satisfies MarketRecord;
|
||||||
@ -488,27 +518,42 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenElements = new Set<HTMLElement>();
|
const candidateScores = new Map<HTMLElement, { depth: number; scrollRange: number }>();
|
||||||
const candidateRoots = table.rows
|
const candidateRoots = table.rows
|
||||||
.map((rowDom) => rowDom.row)
|
.map((rowDom) => rowDom.row)
|
||||||
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
|
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
|
||||||
|
|
||||||
for (const rootElement of candidateRoots) {
|
for (const rootElement of candidateRoots) {
|
||||||
let currentElement = rootElement.parentElement;
|
let currentElement = rootElement.parentElement;
|
||||||
|
let depth = 0;
|
||||||
while (currentElement) {
|
while (currentElement) {
|
||||||
if (
|
if (isScrollableContainer(currentElement)) {
|
||||||
!seenElements.has(currentElement) &&
|
const scrollRange = currentElement.scrollHeight - currentElement.clientHeight;
|
||||||
isScrollableContainer(currentElement)
|
const existingScore = candidateScores.get(currentElement);
|
||||||
) {
|
if (!existingScore || depth < existingScore.depth) {
|
||||||
return currentElement;
|
candidateScores.set(currentElement, {
|
||||||
|
depth,
|
||||||
|
scrollRange
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seenElements.add(currentElement);
|
depth += 1;
|
||||||
currentElement = currentElement.parentElement;
|
currentElement = currentElement.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => {
|
||||||
|
const [, leftScore] = left;
|
||||||
|
const [, rightScore] = right;
|
||||||
|
if (rightScore.scrollRange !== leftScore.scrollRange) {
|
||||||
|
return rightScore.scrollRange - leftScore.scrollRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftScore.depth - rightScore.depth;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rankedCandidates[0]?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScrollableContainer(element: HTMLElement): boolean {
|
function isScrollableContainer(element: HTMLElement): boolean {
|
||||||
@ -529,8 +574,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
||||||
let previousFingerprint = "";
|
let previousFingerprint = "";
|
||||||
let stablePassCount = 0;
|
let stablePassCount = 0;
|
||||||
|
let fingerprintStableSince = 0;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 9; attempt += 1) {
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||||
await waitForDomSettled();
|
await waitForDomSettled();
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@ -555,21 +601,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
} else {
|
} else {
|
||||||
previousFingerprint = hydrationSnapshot.fingerprint;
|
previousFingerprint = hydrationSnapshot.fingerprint;
|
||||||
stablePassCount = 1;
|
stablePassCount = 1;
|
||||||
|
fingerprintStableSince = options.window.Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
|
const stableForMs = options.window.Date.now() - fingerprintStableSince;
|
||||||
|
if (
|
||||||
|
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||||
|
hydrationSnapshot.blankExportFieldCount === 0 &&
|
||||||
|
stablePassCount >= 2
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||||
|
hydrationSnapshot.blankExportFieldCount > 0 &&
|
||||||
|
stablePassCount >= 2 &&
|
||||||
|
stableForMs >= 500
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readVisibleRowHydrationSnapshot(): {
|
function readVisibleRowHydrationSnapshot(): {
|
||||||
|
blankExportFieldCount: number;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
missingDefaultFieldCount: number;
|
missingDefaultFieldCount: number;
|
||||||
} {
|
} {
|
||||||
const table = syncMarketTable(options.document);
|
const table = syncMarketTable(options.document);
|
||||||
if (!table || table.rows.length === 0) {
|
if (!table || table.rows.length === 0) {
|
||||||
return {
|
return {
|
||||||
|
blankExportFieldCount: 0,
|
||||||
fingerprint: "",
|
fingerprint: "",
|
||||||
missingDefaultFieldCount: 0
|
missingDefaultFieldCount: 0
|
||||||
};
|
};
|
||||||
@ -580,6 +643,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||||
(value) => typeof value === "string" && value.trim().length > 0
|
(value) => typeof value === "string" && value.trim().length > 0
|
||||||
).length;
|
).length;
|
||||||
|
const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
|
||||||
|
(value) => typeof value !== "string" || value.trim().length === 0
|
||||||
|
).length;
|
||||||
|
const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]);
|
||||||
const hasRepresentativeVideo = hasTextValue(
|
const hasRepresentativeVideo = hasTextValue(
|
||||||
rowSnapshot.exportFields?.["代表视频"]
|
rowSnapshot.exportFields?.["代表视频"]
|
||||||
);
|
);
|
||||||
@ -587,11 +654,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
hasTextValue(rowSnapshot.price21To60s) ||
|
hasTextValue(rowSnapshot.price21To60s) ||
|
||||||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
|
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
|
||||||
const missingDefaultFieldCount =
|
const missingDefaultFieldCount =
|
||||||
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
|
Number(!hasAuthorField) +
|
||||||
|
Number(!hasRepresentativeVideo) +
|
||||||
|
Number(!hasPriceField);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
rowSnapshot.authorId,
|
rowSnapshot.authorId,
|
||||||
populatedFieldCount,
|
populatedFieldCount,
|
||||||
|
`blank:${blankExportFieldCount}`,
|
||||||
|
hasAuthorField ? "author" : "no-author",
|
||||||
hasRepresentativeVideo ? "video" : "no-video",
|
hasRepresentativeVideo ? "video" : "no-video",
|
||||||
hasPriceField ? "price" : "no-price",
|
hasPriceField ? "price" : "no-price",
|
||||||
`missing:${missingDefaultFieldCount}`
|
`missing:${missingDefaultFieldCount}`
|
||||||
@ -599,6 +670,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
blankExportFieldCount: parts.reduce((count, part) => {
|
||||||
|
const match = part.match(/:blank:(\d+):/);
|
||||||
|
return count + Number(match?.[1] ?? 0);
|
||||||
|
}, 0),
|
||||||
fingerprint: parts.join("|"),
|
fingerprint: parts.join("|"),
|
||||||
missingDefaultFieldCount: parts.reduce((count, part) => {
|
missingDefaultFieldCount: parts.reduce((count, part) => {
|
||||||
const match = part.match(/missing:(\d+)$/);
|
const match = part.match(/missing:(\d+)$/);
|
||||||
@ -608,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;
|
||||||
@ -618,13 +697,45 @@ 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 {
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.disconnect();
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} finally {
|
||||||
|
startObserving();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startObserving(): void {
|
||||||
|
if (isDisposed || !observationRoot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer.observe(observationRoot, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runSyncCycle(): Promise<void> {
|
async function runSyncCycle(): Promise<void> {
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isSyncRunning) {
|
if (isSyncRunning) {
|
||||||
needsResync = true;
|
needsResync = true;
|
||||||
return;
|
return;
|
||||||
@ -632,10 +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);
|
||||||
} finally {
|
} finally {
|
||||||
isSyncRunning = false;
|
isSyncRunning = false;
|
||||||
|
if (isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (needsResync) {
|
if (needsResync) {
|
||||||
needsResync = false;
|
needsResync = false;
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
@ -658,7 +774,19 @@ function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
|
|||||||
|
|
||||||
return table.rows
|
return table.rows
|
||||||
.map((rowDom) => readRowSnapshot(rowDom))
|
.map((rowDom) => readRowSnapshot(rowDom))
|
||||||
.filter((row): row is MarketRowSnapshot => Boolean(row.authorId));
|
.filter(
|
||||||
|
(row): row is MarketRowSnapshot =>
|
||||||
|
Boolean(row.authorId) && hasTextValue(row.authorName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCurrentPageRows(document: Document): number {
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||||
@ -667,32 +795,31 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
|||||||
authorName: rowDom.authorName,
|
authorName: rowDom.authorName,
|
||||||
exportFields: rowDom.exportFields,
|
exportFields: rowDom.exportFields,
|
||||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||||
|
location: rowDom.location,
|
||||||
price21To60s: rowDom.price21To60s,
|
price21To60s: rowDom.price21To60s,
|
||||||
rates: rowDom.rates
|
rates: rowDom.rates
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNumberValue(value: string): number | undefined {
|
function getNextSortState(
|
||||||
if (!value) {
|
currentSort: MarketSortState | undefined,
|
||||||
return undefined;
|
field: MarketSortState["field"]
|
||||||
}
|
|
||||||
|
|
||||||
const parsedValue = Number(value);
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSortState(
|
|
||||||
fieldSelect: HTMLSelectElement,
|
|
||||||
directionSelect: HTMLSelectElement
|
|
||||||
): MarketSortState | undefined {
|
): MarketSortState | undefined {
|
||||||
if (!fieldSelect.value) {
|
if (!currentSort || currentSort.field !== field) {
|
||||||
return undefined;
|
return {
|
||||||
|
direction: "desc",
|
||||||
|
field
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (currentSort.direction === "desc") {
|
||||||
direction: directionSelect.value === "asc" ? "asc" : "desc",
|
return {
|
||||||
field: fieldSelect.value as MarketSortState["field"]
|
direction: "asc",
|
||||||
};
|
field
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
function mergeFieldMap<T extends Record<string, string | undefined>>(
|
||||||
@ -805,6 +932,46 @@ function mergeStringValue(
|
|||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withExportFieldFallbacks(
|
||||||
|
exportFields: Record<string, string | undefined> | undefined,
|
||||||
|
fallbackValues: {
|
||||||
|
authorName: string;
|
||||||
|
location: string | undefined;
|
||||||
|
price21To60s: string | undefined;
|
||||||
|
}
|
||||||
|
): Record<string, string | undefined> | undefined {
|
||||||
|
if (!exportFields) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextExportFields = {
|
||||||
|
...exportFields
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
"达人信息" in nextExportFields &&
|
||||||
|
!hasTextValue(nextExportFields["达人信息"]) &&
|
||||||
|
hasTextValue(fallbackValues.authorName)
|
||||||
|
) {
|
||||||
|
nextExportFields["达人信息"] = fallbackValues.authorName;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"地区" in nextExportFields &&
|
||||||
|
!hasTextValue(nextExportFields["地区"]) &&
|
||||||
|
hasTextValue(fallbackValues.location)
|
||||||
|
) {
|
||||||
|
nextExportFields["地区"] = fallbackValues.location;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"21-60s报价" in nextExportFields &&
|
||||||
|
!hasTextValue(nextExportFields["21-60s报价"]) &&
|
||||||
|
hasTextValue(fallbackValues.price21To60s)
|
||||||
|
) {
|
||||||
|
nextExportFields["21-60s报价"] = fallbackValues.price21To60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextExportFields;
|
||||||
|
}
|
||||||
|
|
||||||
function hasTextValue(value: string | undefined): boolean {
|
function hasTextValue(value: string | undefined): boolean {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import type { MarketExportScope, MarketExportTarget } from "./types";
|
import type {
|
||||||
|
MarketExportScope,
|
||||||
|
MarketExportTarget
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -13,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(
|
||||||
@ -30,52 +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, "", "不排序");
|
|
||||||
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
|
|
||||||
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
|
|
||||||
|
|
||||||
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";
|
||||||
@ -91,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();
|
||||||
});
|
});
|
||||||
@ -130,13 +102,7 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
filterApplyButton,
|
root
|
||||||
personalFilterInput,
|
|
||||||
root,
|
|
||||||
singleFilterInput,
|
|
||||||
sortApplyButton,
|
|
||||||
sortDirectionSelect,
|
|
||||||
sortFieldSelect
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -146,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);
|
||||||
|
|
||||||
@ -187,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;
|
||||||
@ -273,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) => {
|
||||||
@ -297,3 +233,279 @@ 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 primaryButton =
|
||||||
|
findButtonContainingText(document, "发布任务") ??
|
||||||
|
findButtonContainingText(document, "+发布任务");
|
||||||
|
const nativeButton =
|
||||||
|
primaryButton ??
|
||||||
|
findNativeActionButton(document, "自定义指标") ??
|
||||||
|
findNativeActionButton(document, "导出");
|
||||||
|
|
||||||
|
if (nativeButton) {
|
||||||
|
controls.exportButton.className = nativeButton.className;
|
||||||
|
controls.batchSubmitButton.className = nativeButton.className;
|
||||||
|
}
|
||||||
|
|
||||||
|
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
|
||||||
|
applyPrimaryButtonStyles(button, Boolean(primaryButton));
|
||||||
|
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 applyPrimaryButtonStyles(
|
||||||
|
button: HTMLButtonElement,
|
||||||
|
isUsingNativePrimaryButtonClass: boolean
|
||||||
|
): void {
|
||||||
|
if (!isUsingNativePrimaryButtonClass) {
|
||||||
|
button.style.backgroundColor = "#fe346e";
|
||||||
|
button.style.border = "1px solid #fe346e";
|
||||||
|
button.style.borderRadius = "8px";
|
||||||
|
button.style.color = "#ffffff";
|
||||||
|
button.style.height = "32px";
|
||||||
|
button.style.padding = "0 15px";
|
||||||
|
} else {
|
||||||
|
button.style.color = "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
button.style.boxSizing = "border-box";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 findButtonContainingText(
|
||||||
|
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).includes(text)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -12,6 +12,10 @@ export interface BackendMetrics {
|
|||||||
newA3Rate?: string;
|
newA3Rate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarketSortField =
|
||||||
|
| keyof Required<AfterSearchRates>
|
||||||
|
| keyof Required<BackendMetrics>;
|
||||||
|
|
||||||
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
|
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
|
||||||
|
|
||||||
export interface MarketRowSnapshot {
|
export interface MarketRowSnapshot {
|
||||||
@ -49,7 +53,7 @@ export type MarketExportTarget =
|
|||||||
|
|
||||||
export interface MarketSortState {
|
export interface MarketSortState {
|
||||||
direction: "asc" | "desc";
|
direction: "asc" | "desc";
|
||||||
field: keyof Required<AfterSearchRates>;
|
field: MarketSortField;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarketApiFailureReason =
|
export type MarketApiFailureReason =
|
||||||
@ -60,7 +64,7 @@ export type MarketApiFailureReason =
|
|||||||
|
|
||||||
export type MarketApiSuccessResult = {
|
export type MarketApiSuccessResult = {
|
||||||
success: true;
|
success: true;
|
||||||
rates: Required<AfterSearchRates>;
|
rates: AfterSearchRates;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarketApiFailureResult = {
|
export type MarketApiFailureResult = {
|
||||||
|
|||||||
@ -76,11 +76,13 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
|
a3IncreaseCount: formatDecimalValue(
|
||||||
|
readAverageA3IncreaseCount(row)
|
||||||
|
),
|
||||||
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
|
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
|
||||||
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
|
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
|
||||||
cpSearch: formatDecimalValue(row.cp_search),
|
cpSearch: formatDecimalValue(row.cp_search),
|
||||||
cpa3: formatDecimalValue(row.cpa3),
|
cpa3: formatDecimalValue(readCpa3Value(row)),
|
||||||
newA3Rate: formatRateValue(row.avg_new_a3_rate),
|
newA3Rate: formatRateValue(row.avg_new_a3_rate),
|
||||||
starId: row.star_id
|
starId: row.star_id
|
||||||
}
|
}
|
||||||
@ -88,6 +90,84 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAverageA3IncreaseCount(row: Record<string, unknown>): number | null {
|
||||||
|
const directAverage = readFiniteNumber(row.avg_a3_increase_cnt);
|
||||||
|
if (directAverage !== null) {
|
||||||
|
return directAverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNewA3 = readTotalNewA3Value(row);
|
||||||
|
const videoCount =
|
||||||
|
readFiniteNumber(row.video_count) ?? readNestedVideoCount(row.videos);
|
||||||
|
if (totalNewA3 === null || videoCount === null || videoCount <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalNewA3 / videoCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCpa3Value(row: Record<string, unknown>): number | null {
|
||||||
|
const directCpa3 = readFiniteNumber(row.cpa3);
|
||||||
|
if (directCpa3 !== null) {
|
||||||
|
return directCpa3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost = readFiniteNumber(row.total_estimated_video_cost);
|
||||||
|
const totalNewA3 = readTotalNewA3Value(row);
|
||||||
|
if (totalCost === null || totalNewA3 === null || totalNewA3 <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalCost / totalNewA3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTotalNewA3Value(row: Record<string, unknown>): number | null {
|
||||||
|
const derivedFromTotals = deriveTotalNewA3FromTotals(row);
|
||||||
|
if (derivedFromTotals !== null) {
|
||||||
|
return derivedFromTotals;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deriveTotalNewA3FromVideos(row.videos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTotalNewA3FromTotals(row: Record<string, unknown>): number | null {
|
||||||
|
const totalPlayCount = readFiniteNumber(row.total_play_cnt);
|
||||||
|
const averageNewA3Rate = readFiniteNumber(row.avg_new_a3_rate);
|
||||||
|
if (totalPlayCount === null || averageNewA3Rate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalPlayCount * averageNewA3Rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveTotalNewA3FromVideos(value: unknown): number | null {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let hasFiniteValue = false;
|
||||||
|
value.forEach((video) => {
|
||||||
|
if (!isRecord(video)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newA3 = readFiniteNumber(video.new_a3);
|
||||||
|
if (newA3 === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFiniteValue = true;
|
||||||
|
total += newA3;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasFiniteValue ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNestedVideoCount(value: unknown): number | null {
|
||||||
|
return Array.isArray(value) ? value.length : null;
|
||||||
|
}
|
||||||
|
|
||||||
function readResponseRows(payload: unknown): unknown[] | null {
|
function readResponseRows(payload: unknown): unknown[] | null {
|
||||||
if (!isRecord(payload) || payload.success !== true) {
|
if (!isRecord(payload) || payload.success !== true) {
|
||||||
return null;
|
return null;
|
||||||
@ -130,3 +210,8 @@ async function defaultFetch(input: string, init?: RequestInit) {
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFiniteNumber(value: unknown): number | null {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : 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";
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { BatchPayload } from "../content/market/batch-payload";
|
import type { BatchPayload } from "../content/market/batch-payload";
|
||||||
import { isAuthResponseMessage } from "./auth-messages";
|
import { isAuthResponseMessage } from "./auth-messages";
|
||||||
|
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "./batch-submit-config";
|
||||||
|
|
||||||
interface FetchResponseLike {
|
interface FetchResponseLike {
|
||||||
json(): Promise<unknown>;
|
json(): Promise<unknown>;
|
||||||
@ -16,11 +17,12 @@ type GetAccessTokenLike = () => Promise<string>;
|
|||||||
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
type SendMessageLike = (message: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
export function createBatchSubmitClient(options: {
|
export function createBatchSubmitClient(options: {
|
||||||
baseUrl: string;
|
baseUrl?: string;
|
||||||
fetchImpl?: FetchLike;
|
fetchImpl?: FetchLike;
|
||||||
getAccessToken?: GetAccessTokenLike;
|
getAccessToken?: GetAccessTokenLike;
|
||||||
sendMessage: SendMessageLike;
|
sendMessage: SendMessageLike;
|
||||||
}) {
|
}) {
|
||||||
|
const baseUrl = options.baseUrl ?? DEFAULT_BATCH_SUBMIT_BASE_URL;
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
const getAccessToken =
|
const getAccessToken =
|
||||||
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
|
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
|
||||||
@ -29,7 +31,7 @@ export function createBatchSubmitClient(options: {
|
|||||||
async submitBatch(payload: BatchPayload) {
|
async submitBatch(payload: BatchPayload) {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const response = await fetchImpl(
|
const response = await fetchImpl(
|
||||||
new URL("/api/mock/batches", options.baseUrl).toString(),
|
buildBatchSubmitUrl(baseUrl),
|
||||||
{
|
{
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
headers: {
|
headers: {
|
||||||
@ -48,11 +50,15 @@ export function createBatchSubmitClient(options: {
|
|||||||
throw new Error(`batch submit failed: ${response.status}`);
|
throw new Error(`batch submit failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return readBatchSubmitResponse(await response.json());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildBatchSubmitUrl(baseUrl: string): string {
|
||||||
|
return new URL("/api/v1/batch-status/batches", baseUrl).toString();
|
||||||
|
}
|
||||||
|
|
||||||
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
||||||
const response = await sendMessage({ type: "auth:get-access-token" });
|
const response = await sendMessage({ type: "auth:get-access-token" });
|
||||||
|
|
||||||
@ -67,3 +73,23 @@ async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
|
|||||||
|
|
||||||
return response.value.accessToken;
|
return response.value.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBatchSubmitResponse(payload: unknown): unknown {
|
||||||
|
if (!isRecord(payload)) {
|
||||||
|
throw new Error("batch submit response is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.success !== true) {
|
||||||
|
const message =
|
||||||
|
typeof payload.msg === "string" && payload.msg.trim()
|
||||||
|
? payload.msg
|
||||||
|
: "batch submit failed";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "data" in payload ? payload.data : payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|||||||
1
src/shared/batch-submit-config.ts
Normal file
1
src/shared/batch-submit-config.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.29:8083";
|
||||||
@ -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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -61,6 +63,40 @@ describe("backend-metrics-client", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("derives A3 count and CPA3 from the live aggregate response shape", () => {
|
||||||
|
expect(
|
||||||
|
mapBackendMetricsSearchResponse({
|
||||||
|
data: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
avg_after_view_search_cnt: 25982,
|
||||||
|
avg_after_view_search_rate: 0.0010872130261527625,
|
||||||
|
avg_new_a3_rate: 0.11075860229946684,
|
||||||
|
cp_search: 21.168501270110077,
|
||||||
|
cpe: 0.630604497471276,
|
||||||
|
cpm: 23.014670324994974,
|
||||||
|
star_id: "7021245050621263906",
|
||||||
|
total_estimated_video_cost: 1100000,
|
||||||
|
total_play_cnt: 47795601,
|
||||||
|
video_count: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
success: true
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
a3IncreaseCount: "2,646,886.98",
|
||||||
|
afterViewSearchCount: "25,982.00",
|
||||||
|
afterViewSearchRate: "0.11%",
|
||||||
|
cpSearch: "21.17",
|
||||||
|
cpa3: "0.21",
|
||||||
|
newA3Rate: "11.08%",
|
||||||
|
starId: "7021245050621263906"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("posts star ids with bearer auth when searching backend metrics", async () => {
|
test("posts star ids with bearer auth when searching backend metrics", async () => {
|
||||||
const fetchImpl = async (_input: string, init?: RequestInit) => ({
|
const fetchImpl = async (_input: string, init?: RequestInit) => ({
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@ -71,7 +107,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 +118,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: "王少卿",
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../src/shared/batch-submit-config";
|
||||||
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
||||||
|
|
||||||
describe("batch-submit-client", () => {
|
describe("batch-submit-client", () => {
|
||||||
|
test("exports the default batch submit base url", () => {
|
||||||
|
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.29:8083");
|
||||||
|
});
|
||||||
|
|
||||||
test("posts the batch payload with a Bearer token", async () => {
|
test("posts the batch payload with a Bearer token", async () => {
|
||||||
const sendMessage = vi.fn(async () => ({
|
const sendMessage = vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -12,7 +17,15 @@ describe("batch-submit-client", () => {
|
|||||||
const fetchImpl = vi.fn(async () => ({
|
const fetchImpl = vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ acceptedCount: 2, ok: true })
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
batch_id: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
|
||||||
|
status: true,
|
||||||
|
talent_count: 1
|
||||||
|
},
|
||||||
|
msg: "",
|
||||||
|
success: true
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const client = createBatchSubmitClient({
|
const client = createBatchSubmitClient({
|
||||||
@ -32,7 +45,7 @@ describe("batch-submit-client", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchImpl).toHaveBeenCalledWith(
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
"http://127.0.0.1:4319/api/mock/batches",
|
"http://127.0.0.1:4319/api/v1/batch-status/batches",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
authors: [{ authorId: "111", authorName: "达人A" }],
|
authors: [{ authorId: "111", authorName: "达人A" }],
|
||||||
@ -52,6 +65,38 @@ describe("batch-submit-client", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("throws when the batch submit api returns success false", async () => {
|
||||||
|
const client = createBatchSubmitClient({
|
||||||
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
fetchImpl: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: null,
|
||||||
|
msg: "duplicate batch id",
|
||||||
|
success: false
|
||||||
|
})
|
||||||
|
})),
|
||||||
|
sendMessage: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
type: "auth:token",
|
||||||
|
value: { accessToken: "abc123" }
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.submitBatch({
|
||||||
|
authors: [],
|
||||||
|
batchId: "批次A-2026-04-22T12:30:00.000Z",
|
||||||
|
batchName: "批次A",
|
||||||
|
createdAt: "2026-04-22T12:30:00.000Z",
|
||||||
|
creatorName: "王少卿",
|
||||||
|
logtoUserId: "p7pdhhtde8kj",
|
||||||
|
resource: "https://talent-search.intelligrow.cn"
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/duplicate batch id/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("throws on unauthorized responses", async () => {
|
test("throws on unauthorized responses", async () => {
|
||||||
const client = createBatchSubmitClient({
|
const client = createBatchSubmitClient({
|
||||||
baseUrl: "http://127.0.0.1:4319",
|
baseUrl: "http://127.0.0.1:4319",
|
||||||
|
|||||||
@ -15,7 +15,13 @@ describe("csv-exporter", () => {
|
|||||||
"地区",
|
"地区",
|
||||||
"21-60s报价",
|
"21-60s报价",
|
||||||
"单视频看后搜率",
|
"单视频看后搜率",
|
||||||
"个人视频看后搜率"
|
"个人视频看后搜率",
|
||||||
|
"看后搜率",
|
||||||
|
"看后搜数",
|
||||||
|
"新增A3数",
|
||||||
|
"新增A3率",
|
||||||
|
"CPA3",
|
||||||
|
"cp_search"
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -27,10 +33,19 @@ describe("csv-exporter", () => {
|
|||||||
authorName: "Alice",
|
authorName: "Alice",
|
||||||
exportFields: {
|
exportFields: {
|
||||||
达人信息: "Alice",
|
达人信息: "Alice",
|
||||||
|
代表视频: "示例视频",
|
||||||
粉丝数: "100w",
|
粉丝数: "100w",
|
||||||
"21-60s报价": "¥450,000"
|
"21-60s报价": "¥450,000"
|
||||||
},
|
},
|
||||||
status: "success",
|
status: "success",
|
||||||
|
backendMetrics: {
|
||||||
|
a3IncreaseCount: "78,366.22",
|
||||||
|
afterViewSearchCount: "9,689.96",
|
||||||
|
afterViewSearchRate: "0.36%",
|
||||||
|
cpSearch: "14.46",
|
||||||
|
cpa3: "1.79",
|
||||||
|
newA3Rate: "3.44%"
|
||||||
|
},
|
||||||
rates: {
|
rates: {
|
||||||
singleVideoAfterSearchRate: "0.5%-1%",
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
personalVideoAfterSearchRate: "1% - 3%"
|
personalVideoAfterSearchRate: "1% - 3%"
|
||||||
@ -40,11 +55,56 @@ describe("csv-exporter", () => {
|
|||||||
|
|
||||||
const [headerLine, rowLine] = csv.split("\n");
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
expect(headerLine).toBe(
|
expect(headerLine).toBe(
|
||||||
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
|
[
|
||||||
","
|
"达人信息",
|
||||||
)
|
"粉丝数",
|
||||||
|
"21-60s报价",
|
||||||
|
"单视频看后搜率",
|
||||||
|
"个人视频看后搜率",
|
||||||
|
"看后搜率",
|
||||||
|
"看后搜数",
|
||||||
|
"新增A3数",
|
||||||
|
"新增A3率",
|
||||||
|
"CPA3",
|
||||||
|
"cp_search"
|
||||||
|
].join(",")
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
|
expect(rowLine).toBe(
|
||||||
|
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits the representative video column from exported page fields", () => {
|
||||||
|
const csv = buildMarketCsv([
|
||||||
|
{
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "Alice",
|
||||||
|
exportFields: {
|
||||||
|
达人信息: "Alice",
|
||||||
|
代表视频: "示例视频",
|
||||||
|
粉丝数: "100w"
|
||||||
|
},
|
||||||
|
status: "success"
|
||||||
|
} satisfies MarketRecord
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
|
expect(headerLine).toBe(
|
||||||
|
[
|
||||||
|
"达人信息",
|
||||||
|
"粉丝数",
|
||||||
|
"单视频看后搜率",
|
||||||
|
"个人视频看后搜率",
|
||||||
|
"看后搜率",
|
||||||
|
"看后搜数",
|
||||||
|
"新增A3数",
|
||||||
|
"新增A3率",
|
||||||
|
"CPA3",
|
||||||
|
"cp_search"
|
||||||
|
].join(",")
|
||||||
|
);
|
||||||
|
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes commas and quotes", () => {
|
test("escapes commas and quotes", () => {
|
||||||
@ -77,7 +137,7 @@ describe("csv-exporter", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
const [, rowLine] = csv.split("\n");
|
||||||
expect(rowLine).toBe("123,Alice,,,,");
|
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses normalized display values in export rows", () => {
|
test("uses normalized display values in export rows", () => {
|
||||||
@ -97,4 +157,21 @@ describe("csv-exporter", () => {
|
|||||||
expect(rowLine).toContain("0.5% - 1%");
|
expect(rowLine).toContain("0.5% - 1%");
|
||||||
expect(rowLine).toContain("0.02% - 0.1%");
|
expect(rowLine).toContain("0.02% - 0.1%");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits empty backend metric cells when backend metrics are absent", () => {
|
||||||
|
const csv = buildMarketCsv([
|
||||||
|
{
|
||||||
|
authorId: "123",
|
||||||
|
authorName: "Alice",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
||||||
|
}
|
||||||
|
} satisfies MarketRecord
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [, rowLine] = csv.split("\n");
|
||||||
|
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -93,4 +93,95 @@ describe("filter-sort-controller", () => {
|
|||||||
|
|
||||||
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
|
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sorts by backend metric descending and keeps empty values at the end", () => {
|
||||||
|
const result = applyFilterAndSort(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...baseRecords[0],
|
||||||
|
backendMetrics: {
|
||||||
|
afterViewSearchRate: "0.36%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseRecords[1],
|
||||||
|
backendMetrics: {
|
||||||
|
afterViewSearchRate: "1.4%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseRecords[2]
|
||||||
|
],
|
||||||
|
{
|
||||||
|
sort: {
|
||||||
|
direction: "desc",
|
||||||
|
field: "afterViewSearchRate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps equal rate buckets in a deterministic order across repeated sorts", () => {
|
||||||
|
const records: MarketRecord[] = [
|
||||||
|
{
|
||||||
|
authorId: "b",
|
||||||
|
authorName: "Beta",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.5% - 1%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authorId: "a",
|
||||||
|
authorName: "Alpha",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.5% - 1%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authorId: "d",
|
||||||
|
authorName: "Delta",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.25% - 0.5%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authorId: "c",
|
||||||
|
authorName: "Gamma",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.25% - 0.5%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const firstResult = applyFilterAndSort(records, {
|
||||||
|
sort: {
|
||||||
|
direction: "desc",
|
||||||
|
field: "singleVideoAfterSearchRate"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const secondResult = applyFilterAndSort([...records].reverse(), {
|
||||||
|
sort: {
|
||||||
|
direction: "desc",
|
||||||
|
field: "singleVideoAfterSearchRate"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResult.map((record) => record.authorId)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d"
|
||||||
|
]);
|
||||||
|
expect(secondResult.map((record) => record.authorId)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d"
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,12 +33,10 @@ describe("market-api-client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a missing-rate failure when the payload omits a required field", () => {
|
test("returns a missing-rate failure when the payload omits both rate fields", () => {
|
||||||
expect(
|
expect(
|
||||||
mapAuthorAseInfoResponse({
|
mapAuthorAseInfoResponse({
|
||||||
data: {
|
data: {}
|
||||||
avg_search_after_view_rate: "<0.02%"
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
success: false,
|
success: false,
|
||||||
@ -46,6 +44,21 @@ describe("market-api-client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps a partially populated payload into partial rates", () => {
|
||||||
|
expect(
|
||||||
|
mapAuthorAseInfoResponse({
|
||||||
|
data: {
|
||||||
|
personal_avg_search_after_view_rate: "0.02 - 0.1%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
rates: {
|
||||||
|
personalVideoAfterSearchRate: "0.02% - 0.1%"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("returns a request-failed result for non-ok responses", async () => {
|
test("returns a request-failed result for non-ok responses", async () => {
|
||||||
const client = createMarketApiClient({
|
const client = createMarketApiClient({
|
||||||
fetchImpl: async () => ({
|
fetchImpl: async () => ({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -48,9 +48,22 @@ describe("market-dom-sync", () => {
|
|||||||
)
|
)
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-market-header-cell="backendMetrics"]')
|
document.querySelector('[data-market-header-cell="afterViewSearchRate"]')
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6);
|
expect(
|
||||||
|
document.querySelector('[data-market-header-cell="afterViewSearchCount"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-header-cell="a3IncreaseCount"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-header-cell="newA3Rate"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelector('[data-market-header-cell="cpa3"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('[data-market-header-cell="cpSearch"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders loading, success, missing, and failed states", () => {
|
test("renders loading, success, missing, and failed states", () => {
|
||||||
@ -87,12 +100,15 @@ describe("market-dom-sync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(alphaRow.singleCell.textContent).toBe("加载中...");
|
expect(alphaRow.singleCell.textContent).toBe("加载中...");
|
||||||
expect(alphaRow.backendMetricsCell.textContent).toBe("加载中...");
|
expect(alphaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载中...");
|
||||||
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
|
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
|
||||||
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
|
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
|
||||||
expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率");
|
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("0.36%");
|
||||||
expect(betaRow.backendMetricsCell.textContent).toContain("0.36%");
|
expect(betaRow.backendMetricsCells.afterViewSearchCount.textContent).toBe("9,689.96");
|
||||||
expect(betaRow.backendMetricsCell.textContent).toContain("CPA3");
|
expect(betaRow.backendMetricsCells.a3IncreaseCount.textContent).toBe("78,366.22");
|
||||||
|
expect(betaRow.backendMetricsCells.newA3Rate.textContent).toBe("3.44%");
|
||||||
|
expect(betaRow.backendMetricsCells.cpa3.textContent).toBe("1.79");
|
||||||
|
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("14.46");
|
||||||
|
|
||||||
renderMarketRowState(betaRow, {
|
renderMarketRowState(betaRow, {
|
||||||
authorId: "b",
|
authorId: "b",
|
||||||
@ -104,7 +120,8 @@ describe("market-dom-sync", () => {
|
|||||||
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据");
|
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("暂无数据");
|
||||||
|
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("暂无数据");
|
||||||
|
|
||||||
renderMarketRowState(betaRow, {
|
renderMarketRowState(betaRow, {
|
||||||
authorId: "b",
|
authorId: "b",
|
||||||
@ -114,7 +131,8 @@ describe("market-dom-sync", () => {
|
|||||||
});
|
});
|
||||||
expect(betaRow.singleCell.textContent).toBe("加载失败");
|
expect(betaRow.singleCell.textContent).toBe("加载失败");
|
||||||
expect(betaRow.personalCell.textContent).toBe("加载失败");
|
expect(betaRow.personalCell.textContent).toBe("加载失败");
|
||||||
expect(betaRow.backendMetricsCell.textContent).toBe("加载失败");
|
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载失败");
|
||||||
|
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("加载失败");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hides rows outside the visible author ids", () => {
|
test("hides rows outside the visible author ids", () => {
|
||||||
@ -152,27 +170,55 @@ describe("market-dom-sync", () => {
|
|||||||
throw new Error("Expected market table");
|
throw new Error("Expected market table");
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(readRightHeaderTexts()).toEqual([
|
expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
|
||||||
"21-60s报价",
|
expect(readPluginHeaderTexts()).toEqual([
|
||||||
"单视频看后搜率",
|
"单视频看后搜率",
|
||||||
"个人视频看后搜率",
|
"个人视频看后搜率",
|
||||||
"秒探指标",
|
"看后搜率",
|
||||||
"操作"
|
"看后搜数",
|
||||||
|
"新增A3数",
|
||||||
|
"新增A3率",
|
||||||
|
"CPA3",
|
||||||
|
"cp_search"
|
||||||
]);
|
]);
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector(".section-wrapper.sticky-header")
|
||||||
|
?.classList.contains("hide-scrollbar")
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
document.querySelector(".section-wrapper:not(.sticky-header)")?.classList.contains(
|
||||||
|
"hide-scrollbar"
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
|
||||||
|
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
document.querySelector('[data-testid="plugin-header"]') as HTMLElement
|
||||||
|
).style.position
|
||||||
|
).not.toBe("sticky");
|
||||||
|
const pluginHeaderCells = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="plugin-header"] > .header-cell')
|
||||||
|
) as HTMLElement[];
|
||||||
|
expect(pluginHeaderCells[0]?.style.width).toBe("160px");
|
||||||
|
expect(pluginHeaderCells[1]?.style.width).toBe("160px");
|
||||||
|
expect(pluginHeaderCells[0]?.style.whiteSpace).toBe("nowrap");
|
||||||
|
expect(pluginHeaderCells[1]?.style.whiteSpace).toBe("nowrap");
|
||||||
expect(
|
expect(
|
||||||
Number.parseFloat(
|
Number.parseFloat(
|
||||||
(
|
(
|
||||||
document.querySelector('[data-testid="right-header"]') as HTMLElement
|
document.querySelector('[data-testid="right-header"]') as HTMLElement
|
||||||
).style.width
|
).style.width
|
||||||
)
|
)
|
||||||
).toBeGreaterThan(350);
|
).toBe(350);
|
||||||
expect(
|
expect(
|
||||||
Number.parseFloat(
|
Number.parseFloat(
|
||||||
(
|
(
|
||||||
document.querySelector('[data-testid="right-section"]') as HTMLElement
|
document.querySelector('[data-testid="right-section"]') as HTMLElement
|
||||||
).style.width
|
).style.width
|
||||||
)
|
)
|
||||||
).toBeGreaterThan(350);
|
).toBe(350);
|
||||||
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
||||||
|
|
||||||
renderMarketRowState(table.rows[0], {
|
renderMarketRowState(table.rows[0], {
|
||||||
@ -194,13 +240,21 @@ describe("market-dom-sync", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(readRightRowTexts(0)).toEqual([
|
expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
|
||||||
"¥450,000",
|
expect(readPluginRowTexts(0)).toEqual([
|
||||||
"0.5% - 1%",
|
"0.5% - 1%",
|
||||||
"0.02% - 0.1%",
|
"0.02% - 0.1%",
|
||||||
"看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46",
|
"0.36%",
|
||||||
"下单"
|
"9,689.96",
|
||||||
|
"78,366.22",
|
||||||
|
"3.44%",
|
||||||
|
"1.79",
|
||||||
|
"14.46"
|
||||||
]);
|
]);
|
||||||
|
expect(table.rows[0].singleCell.style.width).toBe("160px");
|
||||||
|
expect(table.rows[0].personalCell.style.width).toBe("160px");
|
||||||
|
expect(table.rows[0].singleCell.style.whiteSpace).toBe("nowrap");
|
||||||
|
expect(table.rows[0].personalCell.style.whiteSpace).toBe("nowrap");
|
||||||
|
|
||||||
applyRowVisibility(table, new Set(["222"]));
|
applyRowVisibility(table, new Set(["222"]));
|
||||||
|
|
||||||
@ -211,7 +265,8 @@ describe("market-dom-sync", () => {
|
|||||||
applyRowOrder(table, ["222", "111"]);
|
applyRowOrder(table, ["222", "111"]);
|
||||||
|
|
||||||
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
||||||
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]);
|
expect(readRightRowTexts(0)).toEqual(["¥20,000", "下单"]);
|
||||||
|
expect(readPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
|
||||||
expect(table.rows[0].exportFields).toMatchObject({
|
expect(table.rows[0].exportFields).toMatchObject({
|
||||||
"21-60s报价": "¥450,000",
|
"21-60s报价": "¥450,000",
|
||||||
"代表视频": "代表视频A",
|
"代表视频": "代表视频A",
|
||||||
@ -219,6 +274,94 @@ describe("market-dom-sync", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps a single scroll hint across repeated syncs", () => {
|
||||||
|
document.body.innerHTML = buildRealMarketGridFixture();
|
||||||
|
|
||||||
|
expect(syncMarketTable(document)).not.toBeNull();
|
||||||
|
expect(syncMarketTable(document)).not.toBeNull();
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
|
||||||
|
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses native-like alignment styles for plugin cells", () => {
|
||||||
|
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
|
||||||
|
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginHeaderCell = document.querySelector(
|
||||||
|
'[data-testid="plugin-header"] [data-market-header-cell="singleVideoAfterSearchRate"]'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const pluginBodyCell = table.rows[0].singleCell;
|
||||||
|
|
||||||
|
expect(pluginHeaderCell?.style.display).toBe("flex");
|
||||||
|
expect(pluginHeaderCell?.style.alignItems).toBe("center");
|
||||||
|
expect(pluginBodyCell.style.display).toBe("flex");
|
||||||
|
expect(pluginBodyCell.style.alignItems).toBe("center");
|
||||||
|
expect(pluginBodyCell.style.paddingTop).toBe("12px");
|
||||||
|
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps native-like alignment styles after repeated syncs", () => {
|
||||||
|
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
|
||||||
|
|
||||||
|
expect(syncMarketTable(document)).not.toBeNull();
|
||||||
|
const secondTable = syncMarketTable(document);
|
||||||
|
if (!secondTable) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginBodyCell = secondTable.rows[0].singleCell;
|
||||||
|
expect(pluginBodyCell.style.display).toBe("flex");
|
||||||
|
expect(pluginBodyCell.style.alignItems).toBe("center");
|
||||||
|
expect(pluginBodyCell.style.paddingTop).toBe("12px");
|
||||||
|
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
|
||||||
|
expect(pluginBodyCell.hasAttribute("data-v-cell-scope")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps export field alignment when a row is missing the price cell", () => {
|
||||||
|
document.body.innerHTML = buildRealMarketGridFixtureWithMissingPriceCell();
|
||||||
|
|
||||||
|
const initialTable = syncMarketTable(document);
|
||||||
|
if (!initialTable) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarketRowState(initialTable.rows[1], {
|
||||||
|
authorId: "222",
|
||||||
|
authorName: "达人 B",
|
||||||
|
backendMetrics: {
|
||||||
|
a3IncreaseCount: "78,366.22",
|
||||||
|
afterViewSearchCount: "9,689.96",
|
||||||
|
afterViewSearchRate: "0.36%",
|
||||||
|
cpSearch: "14.46",
|
||||||
|
cpa3: "1.79",
|
||||||
|
newA3Rate: "3.44%"
|
||||||
|
},
|
||||||
|
backendMetricsStatus: "success",
|
||||||
|
status: "success",
|
||||||
|
rates: {
|
||||||
|
singleVideoAfterSearchRate: "0.5%-1%",
|
||||||
|
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("Expected market table after rerender");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(table.rows[1].exportFields).toMatchObject({
|
||||||
|
"21-60s报价": "",
|
||||||
|
"代表视频": "代表视频B",
|
||||||
|
"达人信息": "达人 B"
|
||||||
|
});
|
||||||
|
expect(table.rows[1].exportFields?.["21-60s报价"]).not.toContain("看后搜率");
|
||||||
|
});
|
||||||
|
|
||||||
test("falls back to the market vue state when the DOM has no author id", () => {
|
test("falls back to the market vue state when the DOM has no author id", () => {
|
||||||
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
||||||
attachMarketVueState([
|
attachMarketVueState([
|
||||||
@ -244,6 +387,146 @@ describe("market-dom-sync", () => {
|
|||||||
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fills blank export cells from the market vue state", () => {
|
||||||
|
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
|
||||||
|
attachMarketVueState([
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
avg_search_after_view_rate_30d: "0.003",
|
||||||
|
burst_text_rate: "1",
|
||||||
|
city: "温州",
|
||||||
|
content_theme_labels_180d: ["有趣剧情创作", "亲情剧集", "情感短剧"],
|
||||||
|
follower: "4550556",
|
||||||
|
gender: "2",
|
||||||
|
interact_rate_within_30d: "0.0572",
|
||||||
|
link_link_cnt_by_industry: "27029613",
|
||||||
|
nickname: "达人 A",
|
||||||
|
play_over_rate_within_30d: "0.263",
|
||||||
|
price_20_60: "155000",
|
||||||
|
prospective_20_60_cpm: "21.2362",
|
||||||
|
tags_relation: {
|
||||||
|
剧情搞笑: ["剧情"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_play_num: "7298854",
|
||||||
|
star_id: "111"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
avg_search_after_view_rate_30d: "0.003",
|
||||||
|
burst_text_rate: "0",
|
||||||
|
city: "杭州",
|
||||||
|
content_theme_labels_180d: ["搞笑剧情", "大学宿舍趣事", "校园生活"],
|
||||||
|
follower: "901234",
|
||||||
|
gender: "2",
|
||||||
|
interact_rate_within_30d: "0.072",
|
||||||
|
link_link_cnt_by_industry: "20773000",
|
||||||
|
nickname: "达人 B",
|
||||||
|
play_over_rate_within_30d: "0.35",
|
||||||
|
price_20_60: "38000",
|
||||||
|
prospective_20_60_cpm: "182.5",
|
||||||
|
tags_relation: {
|
||||||
|
剧情搞笑: ["剧情"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_play_num: "208000",
|
||||||
|
star_id: "222"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(table.rows[1].authorId).toBe("222");
|
||||||
|
expect(table.rows[1].price21To60s).toBe("¥38,000");
|
||||||
|
expect(table.rows[1].exportFields).toMatchObject({
|
||||||
|
"21-60s报价": "¥38,000",
|
||||||
|
互动率: "7.2%",
|
||||||
|
内容主题: "搞笑剧情 大学宿舍趣事 1+",
|
||||||
|
完播率: "35%",
|
||||||
|
爆文率: "-",
|
||||||
|
粉丝数: "90.1w",
|
||||||
|
达人信息: "达人 B 女 杭州",
|
||||||
|
达人类型: "剧情搞笑",
|
||||||
|
连接用户数: "2,077.3w",
|
||||||
|
预期CPM: "182.5",
|
||||||
|
预期播放量: "20.8w"
|
||||||
|
});
|
||||||
|
expect(table.rows[1].rates).toEqual({
|
||||||
|
singleVideoAfterSearchRate: "0.3%"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds market rows in nested vue children", () => {
|
||||||
|
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
|
||||||
|
attachNestedMarketVueState([
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
city: "杭州",
|
||||||
|
follower: "901234",
|
||||||
|
gender: "2",
|
||||||
|
interact_rate_within_30d: "0.072",
|
||||||
|
link_link_cnt_by_industry: "20773000",
|
||||||
|
nickname: "达人 B",
|
||||||
|
play_over_rate_within_30d: "0.35",
|
||||||
|
price_20_60: "38000",
|
||||||
|
prospective_20_60_cpm: "182.5",
|
||||||
|
tags_relation: {
|
||||||
|
剧情搞笑: ["剧情"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_play_num: "208000",
|
||||||
|
star_id: "222"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(table.rows[1].price21To60s).toBe("¥38,000");
|
||||||
|
expect(table.rows[1].exportFields).toMatchObject({
|
||||||
|
粉丝数: "90.1w",
|
||||||
|
预期播放量: "20.8w",
|
||||||
|
互动率: "7.2%"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers vue fallback when the price cell is polluted", () => {
|
||||||
|
document.body.innerHTML = buildRichMarketGridFixtureWithPollutedSecondPrice();
|
||||||
|
attachMarketVueState([
|
||||||
|
{
|
||||||
|
attribute_datas: {
|
||||||
|
city: "杭州",
|
||||||
|
follower: "901234",
|
||||||
|
gender: "2",
|
||||||
|
interact_rate_within_30d: "0.072",
|
||||||
|
link_link_cnt_by_industry: "20773000",
|
||||||
|
nickname: "达人 B",
|
||||||
|
play_over_rate_within_30d: "0.35",
|
||||||
|
price_20_60: "38000",
|
||||||
|
prospective_20_60_cpm: "182.5",
|
||||||
|
tags_relation: {
|
||||||
|
剧情搞笑: ["剧情"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expected_play_num: "208000",
|
||||||
|
star_id: "222"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const table = syncMarketTable(document);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error("Expected market table");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(table.rows[1].price21To60s).toBe("¥38,000");
|
||||||
|
expect(table.rows[1].exportFields?.["21-60s报价"]).toBe("¥38,000");
|
||||||
|
});
|
||||||
|
|
||||||
test("falls back to serialized market rows when vue state is unavailable", () => {
|
test("falls back to serialized market rows when vue state is unavailable", () => {
|
||||||
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
@ -270,6 +553,7 @@ describe("market-dom-sync", () => {
|
|||||||
expect(table.rows[0].rates).toEqual({
|
expect(table.rows[0].rates).toEqual({
|
||||||
singleVideoAfterSearchRate: "0.02%"
|
singleVideoAfterSearchRate: "0.02%"
|
||||||
});
|
});
|
||||||
|
expect(readMarketPageSignature(document)).toContain("::111|222");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("finds the real next-page button in Xingtu pagination", () => {
|
test("finds the real next-page button in Xingtu pagination", () => {
|
||||||
@ -298,6 +582,16 @@ describe("market-dom-sync", () => {
|
|||||||
expect(isPageControlDisabled(nextControl)).toBe(false);
|
expect(isPageControlDisabled(nextControl)).toBe(false);
|
||||||
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reads market page signature without mutating the page", () => {
|
||||||
|
document.body.innerHTML = buildRealMarketGridFixture();
|
||||||
|
|
||||||
|
const signature = readMarketPageSignature(document);
|
||||||
|
|
||||||
|
expect(signature).toContain("::111|222");
|
||||||
|
expect(document.querySelector('[data-testid="plugin-header"]')).toBeNull();
|
||||||
|
expect(document.querySelector('[data-testid="plugin-section"]')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildRealMarketGridFixture() {
|
function buildRealMarketGridFixture() {
|
||||||
@ -385,8 +679,167 @@ function buildRealMarketGridFixtureWithoutAuthorIds() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRealMarketGridFixtureWithMissingPriceCell() {
|
||||||
|
return `
|
||||||
|
<div class="base-author-list">
|
||||||
|
<div class="section-wrapper sticky-header hide-scrollbar">
|
||||||
|
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
||||||
|
<div class="header-cell" style="min-width: 310px;">达人信息</div>
|
||||||
|
</div>
|
||||||
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
||||||
|
<div class="header-cell" style="min-width: 190px;">代表视频</div>
|
||||||
|
</div>
|
||||||
|
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
||||||
|
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
|
||||||
|
<div class="header-cell" style="min-width: 200px;">操作</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-wrapper hide-scrollbar">
|
||||||
|
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
||||||
|
<div class="content-column" style="min-width: 310px;">
|
||||||
|
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
|
||||||
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
|
||||||
|
</div>
|
||||||
|
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
|
||||||
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
||||||
|
<div class="content-column" style="min-width: 190px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">代表视频A</div>
|
||||||
|
<div class="content-cell" style="height: 120px;">代表视频B</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
||||||
|
<div class="content-column" style="min-width: 150px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">¥450,000</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 200px;">
|
||||||
|
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;">下单</div>
|
||||||
|
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;">下单</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRealMarketGridFixtureWithScopedAttributes() {
|
||||||
|
return buildRealMarketGridFixture()
|
||||||
|
.replace(
|
||||||
|
'<div class="header-cell" style="min-width: 190px;">代表视频</div>',
|
||||||
|
'<div data-v-header-scope class="header-cell" style="min-width: 190px;">代表视频</div>'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<div class="content-cell" style="height: 120px;">代表视频A</div>',
|
||||||
|
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频A</div>'
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
'<div class="content-cell" style="height: 120px;">代表视频B</div>',
|
||||||
|
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频B</div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRichMarketGridFixtureWithBlankSecondRow() {
|
||||||
|
return `
|
||||||
|
<div class="base-author-list">
|
||||||
|
<div class="section-wrapper sticky-header hide-scrollbar">
|
||||||
|
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
||||||
|
<div class="header-cell" style="min-width: 310px;">达人信息</div>
|
||||||
|
</div>
|
||||||
|
<div class="middle-columns" style="width: 1210px; display: flex;">
|
||||||
|
<div class="header-cell" style="min-width: 190px;">代表视频</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">达人类型</div>
|
||||||
|
<div class="header-cell" style="min-width: 180px;">内容主题</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">连接用户数</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">粉丝数</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">预期CPM</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">预期播放量</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">互动率</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">完播率</div>
|
||||||
|
<div class="header-cell" style="min-width: 120px;">爆文率</div>
|
||||||
|
</div>
|
||||||
|
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
||||||
|
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
|
||||||
|
<div class="header-cell" style="min-width: 200px;">操作</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section-wrapper hide-scrollbar">
|
||||||
|
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
||||||
|
<div class="content-column" style="min-width: 310px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">
|
||||||
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
|
||||||
|
</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="middle-columns" style="width: 1210px; display: flex;">
|
||||||
|
<div class="content-column" style="min-width: 190px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">代表视频A</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">剧情搞笑</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 180px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">有趣剧情创作 亲情剧集 1+</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">2,703w</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">455.1w</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">21.2</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">729.9w</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">5.7%</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">26.3%</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 120px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">100%</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
||||||
|
<div class="content-column" style="min-width: 150px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">¥155,000</div>
|
||||||
|
<div class="content-cell" style="height: 120px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="content-column" style="min-width: 200px;">
|
||||||
|
<div class="content-cell" style="height: 120px;">下单</div>
|
||||||
|
<div class="content-cell" style="height: 120px;">下单</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRichMarketGridFixtureWithPollutedSecondPrice() {
|
||||||
|
return buildRichMarketGridFixtureWithBlankSecondRow().replace(
|
||||||
|
'<div class="content-cell" style="height: 120px;"></div>\n </div>\n <div class="content-column" style="min-width: 200px;">',
|
||||||
|
'<div class="content-cell" style="height: 120px;">看后搜率0.39%看后搜数2,248.33新增A3数0.00新增A3率0%CPA30.00cp_search20.01</div>\n </div>\n <div class="content-column" style="min-width: 200px;">'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function attachMarketVueState(
|
function attachMarketVueState(
|
||||||
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
|
marketList: Array<Record<string, unknown>>
|
||||||
) {
|
) {
|
||||||
const marketRoot = document.querySelector(".base-author-list");
|
const marketRoot = document.querySelector(".base-author-list");
|
||||||
if (!(marketRoot instanceof HTMLElement)) {
|
if (!(marketRoot instanceof HTMLElement)) {
|
||||||
@ -405,6 +858,40 @@ function attachMarketVueState(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachNestedMarketVueState(marketList: Array<Record<string, unknown>>) {
|
||||||
|
const marketRoot = document.querySelector(".base-author-list");
|
||||||
|
if (!(marketRoot instanceof HTMLElement)) {
|
||||||
|
throw new Error("Expected market root");
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(marketRoot, "__vue__", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
$children: [
|
||||||
|
{
|
||||||
|
$children: [
|
||||||
|
{
|
||||||
|
_setupState: {},
|
||||||
|
$children: [
|
||||||
|
{
|
||||||
|
_setupState: {
|
||||||
|
__$temp_1: {
|
||||||
|
marketList
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$children: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
_setupState: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
_setupState: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readRightHeaderTexts() {
|
function readRightHeaderTexts() {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
document.querySelectorAll('[data-testid="right-header"] > *'),
|
document.querySelectorAll('[data-testid="right-header"] > *'),
|
||||||
@ -412,18 +899,39 @@ function readRightHeaderTexts() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPluginHeaderTexts() {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="plugin-header"] > *'),
|
||||||
|
(cell) => cell.textContent?.trim() ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function readRightRowTexts(rowIndex: number) {
|
function readRightRowTexts(rowIndex: number) {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
||||||
(column) =>
|
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||||
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPluginRowTexts(rowIndex: number) {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
|
||||||
|
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScrollHintText() {
|
||||||
|
return (
|
||||||
|
document.querySelector('[data-testid="market-scroll-hint"]')?.textContent?.trim() ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAuthorNames() {
|
function readAuthorNames() {
|
||||||
return Array.from(
|
const authorColumn = document.querySelector(
|
||||||
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
|
'[data-testid="author-section"] .content-column'
|
||||||
(link) => link.textContent?.trim() ?? ""
|
);
|
||||||
|
return readVisualCells(authorColumn).map(
|
||||||
|
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,3 +948,22 @@ function readRightActionHiddenStates() {
|
|||||||
(cell) => (cell as HTMLElement).hidden
|
(cell) => (cell as HTMLElement).hidden
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVisualCells(root: Element | null): HTMLElement[] {
|
||||||
|
if (!root) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(root.querySelectorAll(":scope > .content-cell"))
|
||||||
|
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = Number(left.style.order || "0");
|
||||||
|
const rightOrder = Number(right.style.order || "0");
|
||||||
|
if (leftOrder !== rightOrder) {
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
|
||||||
|
return cells.indexOf(left) - cells.indexOf(right);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user