Compare commits
No commits in common. "7da1bcf25571a371d9d54a034285464f3f7300ee" and "3e2d7b36f2e034a0fbd8674d88889fccdd7cd1dc" have entirely different histories.
7da1bcf255
...
3e2d7b36f2
@ -1,181 +0,0 @@
|
||||
# Market Selection Export 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:** Add cross-page creator selection checkboxes so CSV export and batch submission operate on selected creators within the chosen export range, with fallback to the full export range when no selected creators are present in that range.
|
||||
|
||||
**Architecture:** Extend the market table DOM sync layer with a leading checkbox column and a current-page header select-all control, then keep selection state in the content controller as a `Set<authorId>`. Reuse the existing export-range pipeline by filtering resolved `MarketRecord[]` after range collection and before CSV generation or batch payload creation.
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, existing DOM sync helpers, Vitest, jsdom
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Add checkbox column rendering, row checkbox references, header checkbox references, and checkbox state sync helpers.
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Add minimal selection-related DOM typing if needed.
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Store selected creator ids, respond to row and header checkbox changes, and filter export / batch records with fallback logic.
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
- Cover injected checkbox header and row controls for both synthetic and div-grid layouts.
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
- Cover per-row selection, current-page select-all, cross-page persistence, export filtering, fallback behavior, and batch submission filtering.
|
||||
|
||||
### Task 1: Checkbox Column DOM
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing DOM tests**
|
||||
|
||||
Add tests for:
|
||||
- synthetic tables inject a leading checkbox header cell and one checkbox cell per row
|
||||
- div-grid tables inject a leading checkbox column and current-page header checkbox
|
||||
- returned table DOM exposes row checkbox elements and the header checkbox
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: FAIL because the checkbox column and DOM references do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Write minimal DOM implementation**
|
||||
|
||||
Implement in `src/content/market/dom-sync.ts`:
|
||||
- a leading selection column key
|
||||
- row checkbox creation for synthetic rows
|
||||
- row checkbox column creation for div-grid rows
|
||||
- header checkbox creation for both layouts
|
||||
- `MarketRowDom` and `MarketTableDom` additions for checkbox references
|
||||
|
||||
Keep the new column visually narrow and place it before the author column.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-dom-sync.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/dom-sync.ts src/content/market/types.ts tests/market-dom-sync.test.ts
|
||||
git commit -m "feat: add market row selection column"
|
||||
```
|
||||
|
||||
### Task 2: Controller Selection State
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing controller tests**
|
||||
|
||||
Add tests for:
|
||||
- clicking a row checkbox stores that creator selection
|
||||
- selection survives a page change and re-render
|
||||
- header checkbox selects all visible creators on the current page
|
||||
- header checkbox clears all visible creators on the current page
|
||||
- header checkbox becomes indeterminate when only part of the current page is selected
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
|
||||
Expected: FAIL because the controller does not yet track selection or bind checkbox events.
|
||||
|
||||
- [ ] **Step 3: Write minimal controller implementation**
|
||||
|
||||
Implement in `src/content/market/index.ts`:
|
||||
- `selectedAuthorIds: Set<string>`
|
||||
- event listeners for row checkboxes and the header checkbox
|
||||
- current-page checkbox state sync during `applyCurrentView()`
|
||||
- current-page select-all behavior scoped only to visible rows from the current DOM table
|
||||
|
||||
Do not persist selection in `resultStore`. Keep it controller-local and keyed by `authorId`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selection"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: track selected market creators"
|
||||
```
|
||||
|
||||
### Task 3: Export and Batch Filtering
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing filtering tests**
|
||||
|
||||
Add tests for:
|
||||
- export uses only selected creators when the resolved export range contains selected creators
|
||||
- export falls back to the full resolved export range when none of the selected creators are inside that resolved range
|
||||
- batch submit uses the same selected-with-fallback record set
|
||||
- cross-page selections only apply when those selected creators are inside the chosen export range
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
|
||||
Expected: FAIL because export and batch submit still use the full resolved range.
|
||||
|
||||
- [ ] **Step 3: Write minimal filtering implementation**
|
||||
|
||||
Implement in `src/content/market/index.ts`:
|
||||
- a helper that filters resolved `MarketRecord[]` by `selectedAuthorIds`
|
||||
- fallback to the original resolved records when the filtered subset is empty
|
||||
- use this helper in both:
|
||||
- CSV export before `buildCsv(records)`
|
||||
- batch submit before `createBatchPayload(...)`
|
||||
|
||||
Keep the CSV exporter and batch payload builder unchanged. Only change the records passed into them.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `npm test -- tests/market-content-entry.test.ts -t "selected"`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run focused verification**
|
||||
|
||||
Run:
|
||||
- `npm test -- tests/market-dom-sync.test.ts`
|
||||
- `npm test -- tests/market-content-entry.test.ts`
|
||||
- `npm run build`
|
||||
|
||||
Expected:
|
||||
- market DOM sync tests pass
|
||||
- market content controller tests pass
|
||||
- build exits 0
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: filter market export by selected creators"
|
||||
```
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
- Scope the header checkbox to the current visible page only.
|
||||
- Recompute header checkbox state after every DOM re-sync and after every selection change.
|
||||
- Apply selection after export-range resolution, not before. This preserves current range semantics.
|
||||
- When no selected creators exist inside the resolved export range, keep current behavior and export / submit the whole resolved range.
|
||||
- Do not modify CSV headers or batch payload shape.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
|
||||
- [ ] `npm test -- tests/backend-metrics-client.test.ts tests/batch-submit-client.test.ts tests/background-index.test.ts tests/batch-payload.test.ts`
|
||||
- [ ] `npm run build`
|
||||
- [ ] Manual browser check:
|
||||
- select a few creators on page 1 and page 3
|
||||
- export `当前页` from page 1 and confirm only selected page 1 creators export
|
||||
- export `前5页` and confirm selected creators across pages are included
|
||||
- clear current page via header checkbox and confirm other-page selections remain
|
||||
@ -1,183 +0,0 @@
|
||||
# Market Selection Export Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add row-level selection checkboxes to the market table so users can selectively export CSV data and submit batches for chosen creators, while preserving the existing export range workflow.
|
||||
|
||||
## Confirmed Decisions
|
||||
|
||||
- Add one checkbox column before each creator row.
|
||||
- Add a header checkbox that selects or clears only the creators visible on the current page.
|
||||
- Selection state persists across pagination.
|
||||
- Selection affects both:
|
||||
- CSV export
|
||||
- batch submission
|
||||
- If the current export range contains any selected creators, export and submit only those selected creators within that range.
|
||||
- If the current export range contains no selected creators, fall back to all creators in the current export range.
|
||||
- Keep the existing export range selector and current toolbar layout.
|
||||
- Do not change the CSV column schema.
|
||||
- Do not change the batch payload shape. Only change which records are included.
|
||||
|
||||
## User Experience
|
||||
|
||||
### Table Controls
|
||||
|
||||
- Each creator row gets a checkbox at the far left.
|
||||
- The table header gets a tri-state checkbox:
|
||||
- unchecked: none of the current page creators are selected
|
||||
- indeterminate: some but not all current page creators are selected
|
||||
- checked: all current page creators are selected
|
||||
- Clicking the header checkbox toggles selection for the current visible page only.
|
||||
|
||||
### Export and Submit Behavior
|
||||
|
||||
- Export still starts from the current range selector:
|
||||
- current page
|
||||
- first 5 pages
|
||||
- custom pages
|
||||
- all pages
|
||||
- After range resolution:
|
||||
- if any creators in that resolved range are selected, use only those selected creators
|
||||
- if none are selected in that resolved range, use the full resolved range
|
||||
- Batch submit uses the same filtered creator set as CSV export.
|
||||
|
||||
### Status Feedback
|
||||
|
||||
- Keep the existing export status area.
|
||||
- Add lightweight selection feedback in the toolbar status text when helpful, for example:
|
||||
- `已勾选 7 位达人`
|
||||
- Do not add extra selection mode toggles or secondary panels.
|
||||
|
||||
## Data Model
|
||||
|
||||
Selection is UI state, not record data.
|
||||
|
||||
- Maintain selection in the controller as a `Set<string>` keyed by `authorId`.
|
||||
- Do not store selection inside the CSV exporter.
|
||||
- Do not mutate `MarketRecord` with persistent selection fields unless required for DOM wiring.
|
||||
- Resolve selection against `authorId` only so sorting, filtering, and row reordering do not break checkbox state.
|
||||
|
||||
## DOM Design
|
||||
|
||||
### Synthetic Table
|
||||
|
||||
- Insert a selectable header cell before the author column.
|
||||
- Insert one checkbox cell per row.
|
||||
- Expose the row checkbox and header checkbox through `MarketRowDom` and table DOM helpers.
|
||||
|
||||
### Div Grid Table
|
||||
|
||||
- Clone a narrow column before the native author column.
|
||||
- Render checkbox cells aligned to each creator row.
|
||||
- Render the header checkbox in the sticky header section.
|
||||
- Reuse the existing plugin section insertion pattern so checkbox layout survives page refresh and plugin re-sync.
|
||||
|
||||
## Controller Design
|
||||
|
||||
### New Responsibilities
|
||||
|
||||
- Track selected creator ids across page changes.
|
||||
- Re-apply checkbox state after every DOM re-sync.
|
||||
- Update header checkbox state after:
|
||||
- row checkbox changes
|
||||
- header checkbox changes
|
||||
- pagination changes
|
||||
- sorting and filtering changes
|
||||
|
||||
### Export Flow
|
||||
|
||||
Current flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- build CSV
|
||||
|
||||
New flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- filter records by selection-with-fallback rule
|
||||
- build CSV
|
||||
|
||||
### Batch Flow
|
||||
|
||||
Current flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- build batch payload
|
||||
- submit
|
||||
|
||||
New flow:
|
||||
- resolve export target
|
||||
- collect `MarketRecord[]`
|
||||
- filter records by selection-with-fallback rule
|
||||
- build batch payload from filtered records
|
||||
- submit
|
||||
|
||||
## Filtering Rule
|
||||
|
||||
Given the resolved export records:
|
||||
|
||||
1. Find the subset whose `authorId` exists in the selection set.
|
||||
2. If that subset is non-empty, use it.
|
||||
3. If that subset is empty, use the original resolved records.
|
||||
|
||||
This rule intentionally scopes selection to the chosen export range. For example:
|
||||
|
||||
- If the user selected creators on page 1 and page 3
|
||||
- then exports `当前页` while viewing page 1
|
||||
- only page 1 selected creators are used
|
||||
- page 3 selections remain stored for later exports
|
||||
|
||||
## File Impact
|
||||
|
||||
- Modify: `src/content/market/dom-sync.ts`
|
||||
- checkbox column insertion
|
||||
- row and header checkbox lookup
|
||||
- checkbox state sync helpers
|
||||
- Modify: `src/content/market/index.ts`
|
||||
- selection state storage
|
||||
- event handling
|
||||
- export and batch record filtering
|
||||
- Modify: `src/content/market/types.ts`
|
||||
- minimal DOM type additions if needed
|
||||
- Test: `tests/market-dom-sync.test.ts`
|
||||
- checkbox column rendering
|
||||
- header checkbox presence
|
||||
- Test: `tests/market-content-entry.test.ts`
|
||||
- row selection
|
||||
- current page header select all
|
||||
- cross-page selection persistence
|
||||
- export fallback when no selected creators are inside the resolved range
|
||||
- export filtering when selected creators exist in the resolved range
|
||||
- batch submit uses the same filtered set
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
### Risk: Selection breaks after reordering
|
||||
|
||||
Mitigation:
|
||||
- key selection only by `authorId`
|
||||
- never key by row index or DOM order
|
||||
|
||||
### Risk: Header checkbox selects hidden or unloaded rows
|
||||
|
||||
Mitigation:
|
||||
- limit header checkbox operations to current page visible row DOMs only
|
||||
|
||||
### Risk: User expects selection to always override export range
|
||||
|
||||
Mitigation:
|
||||
- keep selection constrained to the resolved export range
|
||||
- preserve current range selector semantics
|
||||
|
||||
### Risk: Selection UI adds visual clutter
|
||||
|
||||
Mitigation:
|
||||
- use a narrow first column
|
||||
- follow the host page checkbox look and spacing as closely as practical
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Bulk actions beyond export and batch submit
|
||||
- Selection persistence across browser reloads
|
||||
- Dedicated “selected only” mode in the toolbar
|
||||
- Server-side storage of selected creators
|
||||
@ -11,7 +11,6 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
const BACKEND_COLUMN_KEY = "backendMetrics";
|
||||
const SELECTION_COLUMN_KEY = "selection";
|
||||
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
|
||||
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
|
||||
const ACTION_HEADER_TEXT = "操作";
|
||||
@ -87,14 +86,12 @@ export interface MarketRowDom {
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
row: HTMLElement;
|
||||
selectionCheckbox: HTMLInputElement;
|
||||
singleCell: HTMLElement;
|
||||
visibilityTargets: HTMLElement[];
|
||||
orderTargets: RowOrderTarget[];
|
||||
}
|
||||
|
||||
export interface MarketTableDom {
|
||||
headerSelectionCheckbox: HTMLInputElement | null;
|
||||
rows: MarketRowDom[];
|
||||
}
|
||||
|
||||
@ -258,34 +255,6 @@ export function syncPluginSortHeaders(
|
||||
});
|
||||
}
|
||||
|
||||
export function syncMarketSelectionState(
|
||||
table: MarketTableDom,
|
||||
selectedAuthorIds: Set<string>
|
||||
): void {
|
||||
table.rows.forEach((rowDom) => {
|
||||
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
|
||||
rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId);
|
||||
});
|
||||
|
||||
if (!table.headerSelectionCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = table.rows.filter((rowDom) =>
|
||||
rowDom.visibilityTargets.some((target) => !target.hidden)
|
||||
);
|
||||
const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows;
|
||||
const selectedCount = scopedRows.filter((rowDom) =>
|
||||
selectedAuthorIds.has(rowDom.authorId)
|
||||
).length;
|
||||
|
||||
table.headerSelectionCheckbox.indeterminate =
|
||||
selectedCount > 0 && selectedCount < scopedRows.length;
|
||||
table.headerSelectionCheckbox.checked =
|
||||
scopedRows.length > 0 && selectedCount === scopedRows.length;
|
||||
table.headerSelectionCheckbox.disabled = scopedRows.length === 0;
|
||||
}
|
||||
|
||||
function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
const header = root.querySelector("[data-market-header]") as HTMLElement | null;
|
||||
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
|
||||
@ -294,8 +263,6 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
|
||||
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
|
||||
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
|
||||
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
|
||||
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
|
||||
@ -306,18 +273,14 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
|
||||
(rowElement) => {
|
||||
const row = rowElement as HTMLElement;
|
||||
const selectionCell = ensureSyntheticRowCell(row, SELECTION_COLUMN_KEY);
|
||||
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
|
||||
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
|
||||
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
|
||||
const backendMetricsCells = Object.fromEntries(
|
||||
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
|
||||
) as Record<BackendMetricField, HTMLElement>;
|
||||
const authorId = row.dataset.authorId ?? "";
|
||||
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
||||
|
||||
return {
|
||||
authorId,
|
||||
authorId: row.dataset.authorId ?? "",
|
||||
authorName:
|
||||
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||
"",
|
||||
@ -340,7 +303,6 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
?.textContent?.trim() ?? "",
|
||||
rates: undefined,
|
||||
row,
|
||||
selectionCheckbox,
|
||||
singleCell,
|
||||
visibilityTargets: [row]
|
||||
} satisfies MarketRowDom;
|
||||
@ -348,7 +310,6 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
|
||||
);
|
||||
|
||||
return {
|
||||
headerSelectionCheckbox,
|
||||
rows
|
||||
};
|
||||
}
|
||||
@ -474,16 +435,12 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
bodySection,
|
||||
getDirectChildIndex(headerSection, authorHeader)
|
||||
);
|
||||
const authorHeaderSection = getIndexedChild(
|
||||
headerSection,
|
||||
getDirectChildIndex(headerSection, authorHeader)
|
||||
);
|
||||
const rightSection = getIndexedChild(
|
||||
bodySection,
|
||||
getDirectChildIndex(headerSection, actionHeader)
|
||||
);
|
||||
|
||||
if (!authorSection || !authorHeaderSection || !rightSection) {
|
||||
if (!authorSection || !rightSection) {
|
||||
return null;
|
||||
}
|
||||
const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection;
|
||||
@ -505,19 +462,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
}
|
||||
|
||||
const rowCount = getDirectContentCells(authorColumn).length;
|
||||
const selectionHeaderCell = ensureDivHeaderCell(
|
||||
authorHeaderSection,
|
||||
authorHeader,
|
||||
SELECTION_COLUMN_KEY,
|
||||
""
|
||||
);
|
||||
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeaderCell);
|
||||
const selectionColumn = ensureDivBodyColumn(
|
||||
authorSection,
|
||||
authorColumn,
|
||||
SELECTION_COLUMN_KEY,
|
||||
rowCount
|
||||
);
|
||||
const headerTemplateCell =
|
||||
getDirectHeaderCells(middleHeaderSection).at(-1) ??
|
||||
findPreviousHeaderCell(actionHeader) ??
|
||||
@ -566,8 +510,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
) as Record<BackendMetricField, HTMLElement>;
|
||||
syncContainerWidth(pluginHeaderSection);
|
||||
syncContainerWidth(pluginBodySection);
|
||||
syncContainerWidth(authorHeaderSection);
|
||||
syncContainerWidth(authorSection);
|
||||
ensureVisibleHorizontalScroll(headerSection);
|
||||
ensureVisibleHorizontalScroll(bodySection);
|
||||
ensureScrollHint(root, headerSection);
|
||||
@ -583,7 +525,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
: []
|
||||
);
|
||||
const authorCells = getDirectContentCells(authorColumn);
|
||||
const selectionCells = getDirectContentCells(selectionColumn);
|
||||
const singleCells = getDirectContentCells(singleColumn);
|
||||
const personalCells = getDirectContentCells(personalColumn);
|
||||
const backendMetricCellsByField = Object.fromEntries(
|
||||
@ -598,7 +539,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
|
||||
|
||||
const rows = authorCells.flatMap((authorCell, index) => {
|
||||
const selectionCell = selectionCells[index] ?? null;
|
||||
const singleCell = singleCells[index] ?? null;
|
||||
const personalCell = personalCells[index] ?? null;
|
||||
const backendMetricsCells = Object.fromEntries(
|
||||
@ -608,14 +548,12 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
])
|
||||
) as Record<BackendMetricField, HTMLElement | null>;
|
||||
if (
|
||||
!selectionCell ||
|
||||
!singleCell ||
|
||||
!personalCell ||
|
||||
Object.values(backendMetricsCells).some((cell) => cell === null)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
|
||||
|
||||
const alignedRowCells = allBodyColumns.map(
|
||||
(column) => getDirectContentCells(column)[index] ?? null
|
||||
@ -646,7 +584,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
readDivGridPriceDisplay(priceCells[index]?.textContent),
|
||||
fallbackMarketRow?.price21To60s
|
||||
);
|
||||
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
|
||||
|
||||
return [
|
||||
{
|
||||
@ -675,7 +612,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
price21To60s,
|
||||
rates: fallbackMarketRow?.rates,
|
||||
row: authorCell,
|
||||
selectionCheckbox,
|
||||
singleCell,
|
||||
visibilityTargets: rowCells
|
||||
} satisfies MarketRowDom
|
||||
@ -683,7 +619,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
});
|
||||
|
||||
return {
|
||||
headerSelectionCheckbox,
|
||||
rows
|
||||
};
|
||||
}
|
||||
@ -705,11 +640,7 @@ function ensureSyntheticHeaderCell(
|
||||
const nextCell = header.ownerDocument.createElement("div");
|
||||
nextCell.dataset.marketHeaderCell = field;
|
||||
nextCell.textContent = label;
|
||||
if (field === SELECTION_COLUMN_KEY) {
|
||||
header.insertBefore(nextCell, header.firstChild);
|
||||
} else {
|
||||
header.appendChild(nextCell);
|
||||
}
|
||||
header.appendChild(nextCell);
|
||||
return nextCell;
|
||||
}
|
||||
|
||||
@ -724,11 +655,7 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
|
||||
|
||||
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
|
||||
nextCell.dataset.marketRowCell = field;
|
||||
if (field === SELECTION_COLUMN_KEY) {
|
||||
row.insertBefore(nextCell, row.firstChild);
|
||||
} else {
|
||||
row.appendChild(nextCell);
|
||||
}
|
||||
row.appendChild(nextCell);
|
||||
return nextCell;
|
||||
}
|
||||
|
||||
@ -800,12 +727,9 @@ function syncDivColumnCells(
|
||||
|
||||
const templateCell =
|
||||
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
|
||||
const nextCell =
|
||||
field === SELECTION_COLUMN_KEY
|
||||
? createBareContentCell(column.ownerDocument)
|
||||
: templateCell
|
||||
? cloneElementShallow(templateCell)
|
||||
: createBareContentCell(column.ownerDocument);
|
||||
const nextCell = templateCell
|
||||
? cloneElementShallow(templateCell)
|
||||
: createBareContentCell(column.ownerDocument);
|
||||
nextCell.dataset.marketRowCell = field;
|
||||
applyColumnWidth(nextCell, field);
|
||||
applyPluginContentCellStyles(nextCell);
|
||||
@ -832,54 +756,6 @@ function applyPluginContentCellStyles(cell: HTMLElement): void {
|
||||
cell.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function ensureSelectionHeaderControl(cell: HTMLElement): HTMLInputElement {
|
||||
cell.textContent = "";
|
||||
cell.style.gap = "6px";
|
||||
cell.style.justifyContent = "center";
|
||||
|
||||
const checkbox = ensureSelectionCheckbox(cell, "header");
|
||||
const label = cell.querySelector(
|
||||
'[data-market-selection-label="header"]'
|
||||
) as HTMLElement | null;
|
||||
if (label) {
|
||||
label.textContent = "全选";
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
const nextLabel = cell.ownerDocument.createElement("span");
|
||||
nextLabel.dataset.marketSelectionLabel = "header";
|
||||
nextLabel.textContent = "全选";
|
||||
nextLabel.style.fontSize = "12px";
|
||||
cell.appendChild(nextLabel);
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
function ensureSelectionRowControl(cell: HTMLElement): HTMLInputElement {
|
||||
cell.textContent = "";
|
||||
cell.style.justifyContent = "center";
|
||||
return ensureSelectionCheckbox(cell, "row");
|
||||
}
|
||||
|
||||
function ensureSelectionCheckbox(
|
||||
container: HTMLElement,
|
||||
kind: "header" | "row"
|
||||
): HTMLInputElement {
|
||||
const existingCheckbox = container.querySelector(
|
||||
`[data-market-selection-checkbox="${kind}"]`
|
||||
) as HTMLInputElement | null;
|
||||
if (existingCheckbox) {
|
||||
existingCheckbox.type = "checkbox";
|
||||
return existingCheckbox;
|
||||
}
|
||||
|
||||
const checkbox = container.ownerDocument.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.dataset.marketSelectionCheckbox = kind;
|
||||
checkbox.style.cursor = "pointer";
|
||||
container.appendChild(checkbox);
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
function getOwnerDocument(root: ParentNode): Document | null {
|
||||
if ("ownerDocument" in root && root.ownerDocument) {
|
||||
return root.ownerDocument;
|
||||
@ -1558,11 +1434,6 @@ function readRateCellText(value: string | undefined): string {
|
||||
}
|
||||
|
||||
function applyColumnWidth(element: HTMLElement, field: string): void {
|
||||
if (field === SELECTION_COLUMN_KEY) {
|
||||
element.style.minWidth = "56px";
|
||||
element.style.width = "56px";
|
||||
}
|
||||
|
||||
if (field === BACKEND_COLUMN_KEY) {
|
||||
element.style.minWidth = "240px";
|
||||
element.style.width = "240px";
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
readMarketPageSignature,
|
||||
renderMarketRowState,
|
||||
syncPluginSortHeaders,
|
||||
syncMarketSelectionState,
|
||||
syncMarketTable,
|
||||
type MarketRowDom
|
||||
} from "./dom-sync";
|
||||
@ -100,7 +99,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
let lastKnownPageSignature = "";
|
||||
let needsResync = false;
|
||||
let scheduledSyncTimeoutId: number | null = null;
|
||||
const selectedAuthorIds = new Set<string>();
|
||||
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
|
||||
const observer = mutationObserverFactory(() => {
|
||||
if (isDisposed) {
|
||||
@ -116,14 +114,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
const toolbarNeedsRemount =
|
||||
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
|
||||
const selectionControlsMissing =
|
||||
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
|
||||
!options.document.querySelector('[data-market-selection-checkbox="header"]');
|
||||
if (
|
||||
nextPageSignature === lastKnownPageSignature &&
|
||||
!toolbarNeedsRemount &&
|
||||
!selectionControlsMissing
|
||||
) {
|
||||
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -142,9 +133,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(exportTarget.target)
|
||||
);
|
||||
const records = await exportRecords(exportTarget.target);
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
setToolbarExportStatus(toolbar, "");
|
||||
} catch (error) {
|
||||
@ -175,9 +164,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(exportTarget.target, "提交中")
|
||||
);
|
||||
const records = await exportRecords(exportTarget.target, "提交中");
|
||||
const authState = await getAuthState();
|
||||
if (!authState.isAuthenticated) {
|
||||
throw new Error("请先登录插件");
|
||||
@ -398,78 +385,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const records = getVisibleOrderedRecords(table);
|
||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||
applyRowOrder(table, records.map((record) => record.authorId));
|
||||
bindSelectionControls(table);
|
||||
syncMarketSelectionState(table, selectedAuthorIds);
|
||||
lastKnownPageSignature = readMarketPageSignature(options.document);
|
||||
});
|
||||
}
|
||||
|
||||
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
table.rows.forEach((rowDom) => {
|
||||
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
|
||||
if (rowDom.selectionCheckbox.dataset.marketSelectionBound === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
rowDom.selectionCheckbox.dataset.marketSelectionBound = "true";
|
||||
rowDom.selectionCheckbox.addEventListener("change", () => {
|
||||
if (rowDom.selectionCheckbox.checked) {
|
||||
selectedAuthorIds.add(rowDom.authorId);
|
||||
} else {
|
||||
selectedAuthorIds.delete(rowDom.authorId);
|
||||
}
|
||||
|
||||
refreshSelectionControls();
|
||||
});
|
||||
});
|
||||
|
||||
if (!table.headerSelectionCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (table.headerSelectionCheckbox.dataset.marketSelectionBound === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
table.headerSelectionCheckbox.dataset.marketSelectionBound = "true";
|
||||
table.headerSelectionCheckbox.addEventListener("change", () => {
|
||||
const currentTable = syncMarketTable(options.document);
|
||||
if (!currentTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleRows = currentTable.rows.filter((rowDom) =>
|
||||
rowDom.visibilityTargets.some((target) => !target.hidden)
|
||||
);
|
||||
const scopedRows = visibleRows.length > 0 ? visibleRows : currentTable.rows;
|
||||
if (table.headerSelectionCheckbox?.checked) {
|
||||
scopedRows.forEach((rowDom) => {
|
||||
selectedAuthorIds.add(rowDom.authorId);
|
||||
});
|
||||
} else {
|
||||
scopedRows.forEach((rowDom) => {
|
||||
selectedAuthorIds.delete(rowDom.authorId);
|
||||
});
|
||||
}
|
||||
|
||||
refreshSelectionControls();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSelectionControls(): void {
|
||||
const table = syncMarketTable(options.document);
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
bindSelectionControls(table);
|
||||
syncMarketSelectionState(table, selectedAuthorIds);
|
||||
}
|
||||
|
||||
function toggleSortFromHeader(field: MarketSortState["field"]): void {
|
||||
activeSort = getNextSortState(activeSort, field);
|
||||
applyCurrentView();
|
||||
@ -496,17 +415,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return exportRangeController.exportRecords(target);
|
||||
}
|
||||
|
||||
function filterRecordsBySelection(records: MarketRecord[]): MarketRecord[] {
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const selectedRecords = records.filter((record) =>
|
||||
selectedAuthorIds.has(record.authorId)
|
||||
);
|
||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||
}
|
||||
|
||||
async function prepareCurrentPageForExport(): Promise<void> {
|
||||
await runSyncCycle();
|
||||
await harvestCurrentPageForExport();
|
||||
@ -514,13 +422,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
async function harvestCurrentPageForExport(): Promise<void> {
|
||||
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
||||
if (
|
||||
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||
hydrationSnapshot.blankExportFieldCount === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
|
||||
const table = syncMarketTable(options.document);
|
||||
const scrollContainer = findCurrentPageScrollContainer(table);
|
||||
@ -544,13 +446,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
|
||||
) {
|
||||
setScrollTop(scrollContainer, nextScrollTop);
|
||||
hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
||||
if (
|
||||
hydrationSnapshot.missingDefaultFieldCount === 0 &&
|
||||
hydrationSnapshot.blankExportFieldCount === 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
|
||||
if (nextScrollTop === maxScrollTop) {
|
||||
break;
|
||||
@ -559,6 +455,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
if (scrollContainer.scrollTop !== originalScrollTop) {
|
||||
setScrollTop(scrollContainer, originalScrollTop);
|
||||
await collectCurrentPageSnapshotsUntilSettled();
|
||||
}
|
||||
}
|
||||
|
||||
@ -674,19 +571,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function collectCurrentPageSnapshotsUntilSettled(): Promise<{
|
||||
blankExportFieldCount: number;
|
||||
fingerprint: string;
|
||||
missingDefaultFieldCount: number;
|
||||
}> {
|
||||
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
|
||||
let previousFingerprint = "";
|
||||
let stablePassCount = 0;
|
||||
let fingerprintStableSince = 0;
|
||||
let lastSnapshot = {
|
||||
blankExportFieldCount: 0,
|
||||
fingerprint: "",
|
||||
missingDefaultFieldCount: 0
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await waitForDomSettled();
|
||||
@ -702,7 +590,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
collectCurrentPageSnapshots();
|
||||
const hydrationSnapshot = readVisibleRowHydrationSnapshot();
|
||||
lastSnapshot = hydrationSnapshot;
|
||||
if (!hydrationSnapshot.fingerprint) {
|
||||
stablePassCount = 0;
|
||||
previousFingerprint = "";
|
||||
@ -723,7 +610,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
hydrationSnapshot.blankExportFieldCount === 0 &&
|
||||
stablePassCount >= 2
|
||||
) {
|
||||
return hydrationSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -732,11 +619,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
stablePassCount >= 2 &&
|
||||
stableForMs >= 500
|
||||
) {
|
||||
return hydrationSnapshot;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return lastSnapshot;
|
||||
}
|
||||
|
||||
function readVisibleRowHydrationSnapshot(): {
|
||||
|
||||
@ -290,140 +290,6 @@ describe("market-content-entry", () => {
|
||||
expect((toolbar as HTMLElement | null)?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
test("selection keeps a clicked creator checked after the table re-renders", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const mutationObserver = createMutationObserverFactory();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
mutationObserverFactory: mutationObserver.factory,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("a");
|
||||
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
|
||||
|
||||
const table = document.querySelector("[data-market-table]");
|
||||
if (!(table instanceof HTMLElement)) {
|
||||
throw new Error("Missing market table");
|
||||
}
|
||||
|
||||
table.outerHTML = buildMarketTableOnlyFixture();
|
||||
mutationObserver.trigger();
|
||||
await flushWithTimers();
|
||||
|
||||
expect(readSelectionCheckboxForAuthor("a").checked).toBe(true);
|
||||
});
|
||||
|
||||
test("selection survives a page change and re-render", async () => {
|
||||
const pages = [
|
||||
[
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
],
|
||||
[
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
|
||||
]
|
||||
];
|
||||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||||
installAsyncPaginationHarness(pages);
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
click('[data-testid="next-page"]');
|
||||
await flushWithTimers();
|
||||
|
||||
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
|
||||
expect(readSelectionCheckboxForAuthor("333").checked).toBe(false);
|
||||
});
|
||||
|
||||
test("selection header selects all visible creators on the current page", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
]);
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickHeaderSelectionCheckbox();
|
||||
|
||||
expect(readSelectionCheckboxForAuthor("111").checked).toBe(true);
|
||||
expect(readSelectionCheckboxForAuthor("222").checked).toBe(true);
|
||||
});
|
||||
|
||||
test("selection header clears all visible creators on the current page", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
]);
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickHeaderSelectionCheckbox();
|
||||
clickHeaderSelectionCheckbox();
|
||||
|
||||
expect(readSelectionCheckboxForAuthor("111").checked).toBe(false);
|
||||
expect(readSelectionCheckboxForAuthor("222").checked).toBe(false);
|
||||
});
|
||||
|
||||
test("selection header becomes indeterminate when only part of the current page is selected", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
]);
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
|
||||
expect(readHeaderSelectionCheckbox().checked).toBe(false);
|
||||
expect(readHeaderSelectionCheckbox().indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
test("hydrates current page rows on start", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
@ -1242,84 +1108,6 @@ describe("market-content-entry", () => {
|
||||
).toContain("有效页数");
|
||||
});
|
||||
|
||||
test("selected export uses only creators selected in the current range", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" },
|
||||
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
clickSelectionCheckboxForAuthor("333");
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 40, 50);
|
||||
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"111",
|
||||
"333"
|
||||
]);
|
||||
});
|
||||
|
||||
test("selected export falls back to all creators in the current range when no selection matches", async () => {
|
||||
const pages = [
|
||||
[
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
],
|
||||
[
|
||||
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" },
|
||||
{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }
|
||||
]
|
||||
];
|
||||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||||
installAsyncPaginationHarness(pages);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
click('[data-testid="next-page"]');
|
||||
await flushWithTimers();
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 40, 50);
|
||||
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"333",
|
||||
"444"
|
||||
]);
|
||||
});
|
||||
|
||||
test("prompts for a batch name before submitting the current range", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const promptBatchName = vi.fn(() => "618达人筛选第一批");
|
||||
@ -1359,100 +1147,6 @@ describe("market-content-entry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("selected batch submit uses only creators selected in the current range", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" },
|
||||
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }
|
||||
]);
|
||||
const promptBatchName = vi.fn(() => "自动选择批次");
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
promptBatchName,
|
||||
submitBatch,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("222");
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 40, 50);
|
||||
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authors: [{ authorId: "222", authorName: "达人 B" }]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("selected batch submit falls back to all creators in the current range when no selection matches", async () => {
|
||||
const pages = [
|
||||
[
|
||||
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
|
||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||
],
|
||||
[
|
||||
{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" },
|
||||
{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }
|
||||
]
|
||||
];
|
||||
document.body.innerHTML = buildRealMarketFixture(pages[0]);
|
||||
installAsyncPaginationHarness(pages);
|
||||
const promptBatchName = vi.fn(() => "自动选择批次");
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
promptBatchName,
|
||||
submitBatch,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
clickSelectionCheckboxForAuthor("111");
|
||||
click('[data-testid="next-page"]');
|
||||
await flushWithTimers();
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 40, 50);
|
||||
|
||||
expect(submitBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authors: [
|
||||
{ authorId: "333", authorName: "达人 C" },
|
||||
{ authorId: "444", authorName: "达人 D" }
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("shows an error when the batch name is blank", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const promptBatchName = vi.fn(() => " ");
|
||||
@ -3444,50 +3138,6 @@ function click(selector: string) {
|
||||
element.click();
|
||||
}
|
||||
|
||||
function clickSelectionCheckboxForAuthor(authorId: string) {
|
||||
readSelectionCheckboxForAuthor(authorId).click();
|
||||
}
|
||||
|
||||
function clickHeaderSelectionCheckbox() {
|
||||
readHeaderSelectionCheckbox().click();
|
||||
}
|
||||
|
||||
function readSelectionCheckboxForAuthor(authorId: string) {
|
||||
const bySelectionAuthorId = document.querySelector(
|
||||
`[data-market-selection-author-id="${authorId}"]`
|
||||
) as HTMLInputElement | null;
|
||||
if (bySelectionAuthorId) {
|
||||
return bySelectionAuthorId;
|
||||
}
|
||||
|
||||
const bySyntheticRow = document.querySelector(
|
||||
`[data-market-row][data-author-id="${authorId}"] [data-market-selection-checkbox="row"]`
|
||||
) as HTMLInputElement | null;
|
||||
if (bySyntheticRow) {
|
||||
return bySyntheticRow;
|
||||
}
|
||||
|
||||
const byAuthorCell = document.querySelector(
|
||||
`[data-testid="author-cell-${authorId}"] [data-market-selection-checkbox="row"]`
|
||||
) as HTMLInputElement | null;
|
||||
if (byAuthorCell) {
|
||||
return byAuthorCell;
|
||||
}
|
||||
|
||||
throw new Error(`Missing selection checkbox for author: ${authorId}`);
|
||||
}
|
||||
|
||||
function readHeaderSelectionCheckbox() {
|
||||
const checkbox = document.querySelector(
|
||||
'[data-market-selection-checkbox="header"]'
|
||||
) as HTMLInputElement | null;
|
||||
if (!checkbox) {
|
||||
throw new Error("Missing header selection checkbox");
|
||||
}
|
||||
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
function setInputValue(selector: string, value: string) {
|
||||
const element = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!element) {
|
||||
|
||||
@ -39,9 +39,6 @@ describe("market-dom-sync", () => {
|
||||
const table = syncMarketTable(document);
|
||||
|
||||
expect(table).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-market-header-cell="selection"]')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
|
||||
).not.toBeNull();
|
||||
@ -66,9 +63,7 @@ describe("market-dom-sync", () => {
|
||||
expect(
|
||||
document.querySelector('[data-market-header-cell="cpSearch"]')
|
||||
).not.toBeNull();
|
||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18);
|
||||
expect(table?.headerSelectionCheckbox).not.toBeNull();
|
||||
expect(table?.rows[0]?.selectionCheckbox).not.toBeNull();
|
||||
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16);
|
||||
});
|
||||
|
||||
test("renders loading, success, missing, and failed states", () => {
|
||||
@ -175,11 +170,7 @@ describe("market-dom-sync", () => {
|
||||
throw new Error("Expected market table");
|
||||
}
|
||||
|
||||
expect(table.headerSelectionCheckbox).not.toBeNull();
|
||||
expect(table.rows[0]?.selectionCheckbox).not.toBeNull();
|
||||
expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
|
||||
expect(readSelectionHeaderText()).toBe("全选");
|
||||
expect(readSelectionRowCheckboxCount()).toBe(2);
|
||||
expect(readPluginHeaderTexts()).toEqual([
|
||||
"单视频看后搜率",
|
||||
"个人视频看后搜率",
|
||||
@ -293,20 +284,6 @@ describe("market-dom-sync", () => {
|
||||
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
|
||||
});
|
||||
|
||||
test("keeps reading native author rows after the selection column is injected", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixture();
|
||||
|
||||
expect(syncMarketTable(document)?.rows.map((row) => row.authorId)).toEqual([
|
||||
"111",
|
||||
"222"
|
||||
]);
|
||||
|
||||
const table = syncMarketTable(document);
|
||||
|
||||
expect(table?.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
||||
expect(readAuthorNames()).toEqual(["达人 A", "达人 B"]);
|
||||
});
|
||||
|
||||
test("uses native-like alignment styles for plugin cells", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
|
||||
|
||||
@ -949,18 +926,6 @@ function readScrollHintText() {
|
||||
);
|
||||
}
|
||||
|
||||
function readSelectionHeaderText() {
|
||||
return (
|
||||
document
|
||||
.querySelector('[data-market-header-cell="selection"]')
|
||||
?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function readSelectionRowCheckboxCount() {
|
||||
return document.querySelectorAll('[data-market-selection-checkbox="row"]').length;
|
||||
}
|
||||
|
||||
function readAuthorNames() {
|
||||
const authorColumn = document.querySelector(
|
||||
'[data-testid="author-section"] .content-column'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user