Compare commits
5
Commits
370cc0170b
...
5c21f631d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c21f631d7 | ||
|
|
9326d676b7 | ||
|
|
b48e244a99 | ||
|
|
8eebb44551 | ||
|
|
720f5983dc |
@@ -0,0 +1,665 @@
|
||||
# Independent Spread Metric Filter 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:** Replace the single shared spread-video configuration with independently configurable completion-rate and interaction-rate rules that are combined with AND semantics.
|
||||
|
||||
**Architecture:** Represent each enabled metric as a `SpreadMetricFilterRule` containing its metric, threshold, and normalized `SpreadInfoConfig`. The toolbar owns metric selection and per-rule controls; the market controller groups rules by normalized config, loads one Xingtu snapshot per unique config and author, and evaluates every rule before export or batch submission.
|
||||
|
||||
**Tech Stack:** TypeScript 6, Chrome MV3 content scripts, DOM APIs, Vitest 4 with JSDOM, tsup build scripts.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify `src/content/market/types.ts`: own shared spread config and filter rule types.
|
||||
- Modify `src/content/market/spread-info.ts`: normalize configs, build stable config keys, and compare one metric rule against a mapped snapshot.
|
||||
- Modify `src/content/market/plugin-toolbar.ts`: render metric checkboxes and per-metric dynamic rule controls, enforce personal-video constraints, and read validated rule arrays.
|
||||
- Modify `src/content/market/index.ts`: group rules by config, reuse snapshots, and apply AND filtering for export and batch submission.
|
||||
- Modify `tests/spread-info.test.ts`: cover config normalization, grouping keys, and single-rule comparison.
|
||||
- Modify `tests/market-content-entry.test.ts`: cover toolbar interaction, validation, request grouping, AND behavior, export, and batch submission.
|
||||
- Modify `docs/项目流程说明文档.md`: replace the obsolete seven-threshold/global-config description with the implemented two-metric independent-rule flow.
|
||||
|
||||
### Task 1: Shared Rule Model and Pure Spread Filter Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/types.ts:15-35`
|
||||
- Modify: `src/content/market/spread-info.ts:1-25, 210-223`
|
||||
- Test: `tests/spread-info.test.ts:1-214`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for normalized configs, stable keys, and metric-specific matching**
|
||||
|
||||
Replace the old `matchesSpreadThresholds` test with focused tests and imports:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||
expect(normalizeSpreadInfoConfig({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).toEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("uses all normalized video dimensions in the config key", () => {
|
||||
expect(buildSpreadInfoConfigKey({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})).toBe(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
}));
|
||||
expect(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 2
|
||||
})).not.toBe(buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
}));
|
||||
});
|
||||
|
||||
test("matches only the metric named by one filter rule", () => {
|
||||
expect(matchesSpreadMetricRule(
|
||||
{ finishRate: "28.24%", interactionRate: "4.02%" },
|
||||
{
|
||||
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
|
||||
metric: "finishRate",
|
||||
threshold: 28
|
||||
}
|
||||
)).toBe(true);
|
||||
expect(matchesSpreadMetricRule(
|
||||
{ finishRate: "28.24%" },
|
||||
{
|
||||
config: { flowType: 0, onlyAssign: false, range: 2, type: 1 },
|
||||
metric: "interactionRate",
|
||||
threshold: 1
|
||||
}
|
||||
)).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the new API is missing**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: FAIL because `normalizeSpreadInfoConfig`, `buildSpreadInfoConfigKey`, and `matchesSpreadMetricRule` are not exported.
|
||||
|
||||
- [ ] **Step 3: Replace the global threshold type with shared rule types**
|
||||
|
||||
In `types.ts`, remove `SpreadMetricThresholds` and define:
|
||||
|
||||
```ts
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
export type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
export interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
```
|
||||
|
||||
Move `SpreadInfoConfig` ownership out of `spread-info.ts` and import it there from `types.ts`.
|
||||
|
||||
- [ ] **Step 4: Implement pure normalization, keying, and comparison helpers**
|
||||
|
||||
In `spread-info.ts`, replace `matchesSpreadThresholds` with:
|
||||
|
||||
```ts
|
||||
export function normalizeSpreadInfoConfig(
|
||||
config: SpreadInfoConfig
|
||||
): SpreadInfoConfig {
|
||||
return config.type === 1
|
||||
? { ...config, flowType: 0, onlyAssign: false }
|
||||
: { ...config };
|
||||
}
|
||||
|
||||
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
|
||||
const normalized = normalizeSpreadInfoConfig(config);
|
||||
return [
|
||||
normalized.type,
|
||||
normalized.onlyAssign ? 1 : 0,
|
||||
normalized.flowType,
|
||||
normalized.range
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function matchesSpreadMetricRule(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
rule: SpreadMetricFilterRule
|
||||
): boolean {
|
||||
const numericValue = readDisplayNumber(metrics[rule.metric]);
|
||||
return numericValue !== null && numericValue >= rule.threshold;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the focused tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit the shared rule model**
|
||||
|
||||
```bash
|
||||
git add src/content/market/types.ts src/content/market/spread-info.ts tests/spread-info.test.ts
|
||||
git commit -m "refactor: model independent spread metric rules"
|
||||
```
|
||||
|
||||
### Task 2: Dynamic Per-Metric Toolbar Rules
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/plugin-toolbar.ts:1-590, 810-1035`
|
||||
- Test: `tests/market-content-entry.test.ts:390-480, 1285-1340`
|
||||
|
||||
- [ ] **Step 1: Write failing DOM tests for the default and enabled states**
|
||||
|
||||
Update the toolbar assertions to use metric checkboxes and scoped rule rows:
|
||||
|
||||
```ts
|
||||
const finishToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
const interactionToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="interactionRate"]'
|
||||
) as HTMLInputElement;
|
||||
const finishRule = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"]'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(finishToggle.checked).toBe(false);
|
||||
expect(interactionToggle.checked).toBe(false);
|
||||
expect(finishRule.hidden).toBe(true);
|
||||
|
||||
finishToggle.checked = true;
|
||||
finishToggle.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
expect(finishRule.hidden).toBe(false);
|
||||
expect(finishRule.querySelector('[data-plugin-spread-filter="type"]')).not.toBeNull();
|
||||
expect(finishRule.querySelector('[data-plugin-spread-threshold="finishRate"]')).not.toBeNull();
|
||||
```
|
||||
|
||||
Add reusable test helpers:
|
||||
|
||||
```ts
|
||||
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
|
||||
const selector = `[data-plugin-spread-metric="${metric}"]`;
|
||||
const input = document.querySelector(selector) as HTMLInputElement;
|
||||
input.checked = true;
|
||||
dispatchChange(selector);
|
||||
}
|
||||
|
||||
function setSpreadRuleSelect(
|
||||
metric: "finishRate" | "interactionRate",
|
||||
field: "type" | "onlyAssign" | "flowType" | "range",
|
||||
value: string
|
||||
) {
|
||||
const selector =
|
||||
`[data-plugin-spread-rule="${metric}"] [data-plugin-spread-filter="${field}"]`;
|
||||
setSelectValue(selector, value);
|
||||
dispatchChange(selector);
|
||||
}
|
||||
```
|
||||
|
||||
Then verify per-rule isolation:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("finishRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "1");
|
||||
|
||||
const finishOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
const interactionOnlyAssign = document.querySelector(
|
||||
'[data-plugin-spread-rule="interactionRate"] [data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement;
|
||||
|
||||
expect(finishOnlyAssign.value).toBe("true");
|
||||
expect(finishOnlyAssign.disabled).toBe(false);
|
||||
expect(interactionOnlyAssign.value).toBe("false");
|
||||
expect(interactionOnlyAssign.disabled).toBe(true);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing validation tests for rule-array parsing**
|
||||
|
||||
Exercise `readToolbarSpreadFilter` through export button integration. The validation path is synchronous before any export work starts:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
click('[data-plugin-export="button"]');
|
||||
expect(
|
||||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||
).toContain("完播率");
|
||||
expect(buildCsv).not.toHaveBeenCalled();
|
||||
|
||||
setInputValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-threshold="finishRate"]',
|
||||
"30"
|
||||
);
|
||||
```
|
||||
|
||||
Import `ensurePluginToolbar` and `readToolbarSpreadFilter` in a focused test, then assert the empty and reset states directly:
|
||||
|
||||
```ts
|
||||
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [] } });
|
||||
|
||||
enableSpreadMetric("finishRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
const toggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement;
|
||||
toggle.checked = false;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
toggle.checked = true;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
|
||||
expect(
|
||||
(document.querySelector('[data-plugin-spread-threshold="finishRate"]') as HTMLInputElement).value
|
||||
).toBe("");
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
|
||||
"1"
|
||||
);
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
|
||||
"2"
|
||||
);
|
||||
```
|
||||
|
||||
Define the no-op handlers next to the existing DOM test helpers:
|
||||
|
||||
```ts
|
||||
function createNoopToolbarHandlers() {
|
||||
return {
|
||||
onConfigureAudienceProfileFields: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExportAudienceProfile: vi.fn(),
|
||||
onExportAudienceProfileByIds: vi.fn(),
|
||||
onSubmitBatch: vi.fn()
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the toolbar-focused test file and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread"`
|
||||
|
||||
Expected: FAIL because metric checkboxes and per-rule controls do not exist.
|
||||
|
||||
- [ ] **Step 4: Replace the global toolbar DOM contract with per-rule DOM objects**
|
||||
|
||||
Define this internal shape in `plugin-toolbar.ts`:
|
||||
|
||||
```ts
|
||||
interface SpreadMetricRuleDom {
|
||||
enabledInput: HTMLInputElement;
|
||||
flowTypeSelect: HTMLSelectElement;
|
||||
onlyAssignSelect: HTMLSelectElement;
|
||||
rangeSelect: HTMLSelectElement;
|
||||
root: HTMLElement;
|
||||
thresholdInput: HTMLInputElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
|
||||
```
|
||||
|
||||
Replace the four global config selects and two loose threshold inputs in `PluginToolbarDom` with `spreadMetricRules: SpreadMetricRuleDomMap`.
|
||||
|
||||
- [ ] **Step 5: Build metric selectors and hidden rule rows**
|
||||
|
||||
Create one checkbox and one rule row for each fixed definition:
|
||||
|
||||
```ts
|
||||
const SPREAD_FILTER_DEFINITIONS = [
|
||||
{ label: "完播率", metric: "finishRate" },
|
||||
{ label: "互动率", metric: "interactionRate" }
|
||||
] as const;
|
||||
```
|
||||
|
||||
Use these stable selectors:
|
||||
|
||||
```text
|
||||
data-plugin-spread-metric="finishRate|interactionRate"
|
||||
data-plugin-spread-rule="finishRate|interactionRate"
|
||||
data-plugin-spread-filter="type|onlyAssign|flowType|range"
|
||||
data-plugin-spread-threshold="finishRate|interactionRate"
|
||||
```
|
||||
|
||||
Keep both rule rows in the DOM but set `hidden=true` until their checkboxes are enabled. Layout the selection controls before the rule container and remove the old global “视频口径” group.
|
||||
|
||||
- [ ] **Step 6: Implement independent state synchronization and reset behavior**
|
||||
|
||||
On each metric checkbox change:
|
||||
|
||||
```ts
|
||||
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
|
||||
rule.root.hidden = !rule.enabledInput.checked;
|
||||
if (!rule.enabledInput.checked) {
|
||||
rule.thresholdInput.value = "";
|
||||
rule.typeSelect.value = "1";
|
||||
rule.onlyAssignSelect.value = "false";
|
||||
rule.flowTypeSelect.value = "0";
|
||||
rule.rangeSelect.value = "2";
|
||||
clearSpreadMetricRuleValidation(rule);
|
||||
}
|
||||
syncSpreadMetricVideoConstraints(rule);
|
||||
}
|
||||
```
|
||||
|
||||
`syncSpreadMetricVideoConstraints` must only touch the changed rule. For personal video it fixes and disables `onlyAssign` and `flowType`; for Xingtu video it enables them.
|
||||
|
||||
- [ ] **Step 7: Parse selected controls into validated rule arrays**
|
||||
|
||||
Rework `readToolbarSpreadFilter` to iterate in finish-rate then interaction-rate order:
|
||||
|
||||
```ts
|
||||
const rules: SpreadMetricFilterRule[] = [];
|
||||
for (const metric of ["finishRate", "interactionRate"] as const) {
|
||||
const ruleDom = toolbar.spreadMetricRules[metric];
|
||||
clearSpreadMetricRuleValidation(ruleDom);
|
||||
if (!ruleDom.enabledInput.checked) continue;
|
||||
|
||||
const threshold = Number(ruleDom.thresholdInput.value.trim());
|
||||
if (!ruleDom.thresholdInput.value.trim() || !Number.isFinite(threshold) || threshold < 0) {
|
||||
markSpreadMetricRuleInvalid(ruleDom);
|
||||
return { error: `请输入有效的${metric === "finishRate" ? "完播率" : "互动率"}筛选阈值` };
|
||||
}
|
||||
|
||||
rules.push({ metric, threshold, config: readSpreadMetricConfig(ruleDom) });
|
||||
}
|
||||
return { filter: { rules } };
|
||||
```
|
||||
|
||||
Update busy-state handling and native-control styling to walk every checkbox, threshold input, and select in `spreadMetricRules`.
|
||||
|
||||
- [ ] **Step 8: Run toolbar-focused tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "toolbar|spread"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit the dynamic toolbar**
|
||||
|
||||
```bash
|
||||
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: add independent spread metric controls"
|
||||
```
|
||||
|
||||
### Task 3: Config-Deduplicated AND Filtering for Export and Batch Submission
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/content/market/index.ts:30-95, 801-828`
|
||||
- Test: `tests/market-content-entry.test.ts:1440-1496, 2436-2492`
|
||||
|
||||
- [ ] **Step 1: Write a failing export test for different configs and AND semantics**
|
||||
|
||||
Configure completion rate as Xingtu/assigned/30-day and interaction rate as personal/90-day. Mock snapshots by config:
|
||||
|
||||
```ts
|
||||
const loadSpreadFilterMetrics = vi.fn(async (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => {
|
||||
if (config.type === 2) {
|
||||
return { finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" };
|
||||
}
|
||||
return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" };
|
||||
});
|
||||
```
|
||||
|
||||
Configure the controls and assert the exact result and calls:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(["a"]);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
});
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write a failing export test for same-config request reuse**
|
||||
|
||||
Configure both rules as personal/30-day, return both metrics in one snapshot, and assert:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update the batch-submit integration test to enable and apply a rule**
|
||||
|
||||
Enable both rules before submitting and preserve the exact payload assertion:
|
||||
|
||||
```ts
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({ authorId: "a" })
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the new integration tests and verify failure**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: FAIL because the controller still reads one global config and threshold object.
|
||||
|
||||
- [ ] **Step 5: Update the controller dependency type**
|
||||
|
||||
Change `loadSpreadFilterMetrics` to accept `SpreadInfoConfig` and return `MappedSpreadInfoResponse`:
|
||||
|
||||
```ts
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Group normalized rules and reuse each snapshot**
|
||||
|
||||
Replace the one-request filter body with config groups:
|
||||
|
||||
```ts
|
||||
const normalizedRules = filter.rules.map((rule) => ({
|
||||
...rule,
|
||||
config: normalizeSpreadInfoConfig(rule.config)
|
||||
}));
|
||||
const groups = new Map<string, {
|
||||
config: SpreadInfoConfig;
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}>();
|
||||
|
||||
for (const rule of normalizedRules) {
|
||||
const key = buildSpreadInfoConfigKey(rule.config);
|
||||
const group = groups.get(key) ?? { config: rule.config, rules: [] };
|
||||
group.rules.push(rule);
|
||||
groups.set(key, group);
|
||||
}
|
||||
```
|
||||
|
||||
For each record, load all unique groups, catch a group failure as an empty snapshot, and preserve the existing per-author isolation:
|
||||
|
||||
```ts
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => {
|
||||
try {
|
||||
snapshots.set(key, await loadSpreadFilterMetrics(spreadAuthorId, group.config));
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
}));
|
||||
|
||||
const matchesAll = normalizedRules.every((rule) =>
|
||||
matchesSpreadMetricRule(
|
||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
||||
rule
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Return records unchanged when `filter` is missing or `filter.rules.length === 0`.
|
||||
|
||||
- [ ] **Step 7: Run focused integration tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Run all market content-entry tests**
|
||||
|
||||
Run: `npx vitest run tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit config-deduplicated filtering**
|
||||
|
||||
```bash
|
||||
git add src/content/market/index.ts tests/market-content-entry.test.ts
|
||||
git commit -m "feat: filter spread metrics by independent configs"
|
||||
```
|
||||
|
||||
### Task 4: Process Documentation and Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/项目流程说明文档.md:251-269, 323, 363, 426, 490, 509, 593`
|
||||
|
||||
- [ ] **Step 1: Update the maintained process description**
|
||||
|
||||
Rewrite section `3.8 传播指标阈值筛选` to state:
|
||||
|
||||
```markdown
|
||||
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
|
||||
- 处理规则:
|
||||
- 默认不选择任何指标,不启用二次筛选;
|
||||
- 每个已选指标必须填写阈值,并使用自己的视频口径请求传播指标;
|
||||
- 同时选择完播率和互动率时,两条规则必须全部满足;
|
||||
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
|
||||
- 个人视频固定为不限指派、不排除营销流量;
|
||||
- 请求失败、缺少 ID 或缺少已选指标的达人视为不满足筛选。
|
||||
```
|
||||
|
||||
Update the interface table's concurrency note to mention unique-config reuse, and change the configuration table row from one generic “传播指标阈值” to “传播指标规则”,defaulting to no selected metric.
|
||||
|
||||
- [ ] **Step 2: Run documentation consistency checks**
|
||||
|
||||
Run: `rg -n "七个指标阈值|没有填写任何阈值|平均时长按秒|播放量、评论、点赞、转发" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: no output.
|
||||
|
||||
Run: `rg -n "每个已选指标|口径相同|传播指标规则" docs/项目流程说明文档.md`
|
||||
|
||||
Expected: matches in section 3.8, the `get_author_spread_info` interface row, and the configuration table.
|
||||
|
||||
- [ ] **Step 3: Run focused feature tests**
|
||||
|
||||
Run: `npx vitest run tests/spread-info.test.ts tests/market-content-entry.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `npm test`
|
||||
|
||||
Expected: PASS with no failed test files.
|
||||
|
||||
- [ ] **Step 5: Build the extension**
|
||||
|
||||
Run: `npm run build`
|
||||
|
||||
Expected: exit code `0` and refreshed development bundle in `dist/`.
|
||||
|
||||
- [ ] **Step 6: Review the final diff and workspace scope**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: no whitespace errors.
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: only the planned source, test, and process-document files are modified; `.superpowers/` remains untracked and must not be staged.
|
||||
|
||||
- [ ] **Step 7: Commit documentation and verification-complete state**
|
||||
|
||||
```bash
|
||||
git add docs/项目流程说明文档.md
|
||||
git commit -m "docs: update independent spread filter flow"
|
||||
```
|
||||
@@ -0,0 +1,172 @@
|
||||
# 传播指标独立视频口径筛选设计
|
||||
|
||||
## 背景
|
||||
|
||||
当前工具栏把视频类型、是否指派、是否排除营销流量和时间范围作为一套全局视频口径,再把完播率和互动率阈值同时应用到这套口径上。
|
||||
|
||||
正确的业务关系应为:用户先选择筛选指标,每个已选指标再分别配置阈值和一套独立视频口径。例如,完播率可以使用“星图视频、只看指派、近30天”,互动率同时使用“个人视频、近90天”。
|
||||
|
||||
## 目标
|
||||
|
||||
- 当前只提供完播率和互动率两个可选筛选指标。
|
||||
- 默认不选择任何指标,不改变现有导出和提交结果。
|
||||
- 支持同时选择两个指标。
|
||||
- 每个已选指标必须填写阈值并配置一套独立视频口径。
|
||||
- 同时启用多个指标时,达人必须满足全部指标规则才会被保留。
|
||||
- CSV 导出和提交批次复用相同的筛选结果。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增完播率和互动率之外的筛选指标。
|
||||
- 不修改传播数据 CSV 的可选字段和字段命名。
|
||||
- 不修改批次提交接口或批次 payload。
|
||||
- 不修改星图 `get_author_spread_info` 的接口参数和响应字段映射。
|
||||
- 不调整导出范围、选择达人、字段选择等其他工具栏能力。
|
||||
|
||||
## 前端交互
|
||||
|
||||
### 初始状态
|
||||
|
||||
“传播指标筛选”区域显示完播率、互动率两个复选框,默认均未选中。
|
||||
|
||||
未选中任何指标时:
|
||||
|
||||
- 不显示指标规则行。
|
||||
- 不调用传播指标接口。
|
||||
- 导出 CSV 和提交批次保持原行为。
|
||||
|
||||
### 动态规则行
|
||||
|
||||
勾选一个指标后,显示该指标的规则行。规则行从左到右包含:
|
||||
|
||||
1. 指标名称。
|
||||
2. `>=` 阈值输入框和 `%` 单位。
|
||||
3. 视频类型:个人视频或星图视频。
|
||||
4. 是否只看指派:不限指派或只看指派。
|
||||
5. 是否排除营销流量:不排除营销或排除营销。
|
||||
6. 时间范围:近30天或近90天。
|
||||
|
||||
新规则行沿用现有默认口径:个人视频、不限指派、不排除营销、近30天。阈值默认为空,必须由用户填写。
|
||||
|
||||
取消勾选指标后,移除并丢弃对应规则,该指标不参与筛选。重新勾选时按上述默认值创建新规则,不恢复之前输入的阈值和口径。
|
||||
|
||||
同时勾选完播率和互动率时,显示两条互相独立的规则行。界面明确提示“全部规则都达标才保留达人”。
|
||||
|
||||
### 个人视频约束
|
||||
|
||||
每条规则独立应用现有个人视频约束。选择个人视频后:
|
||||
|
||||
- `onlyAssign` 自动设为 `false`,显示“不限指派”。
|
||||
- `flowType` 自动设为 `0`,显示“不排除营销”。
|
||||
- 是否指派和营销流量两个下拉框禁用。
|
||||
- 时间范围仍可选择近30天或近90天。
|
||||
|
||||
切换回星图视频后,重新启用是否指派和营销流量下拉框。
|
||||
|
||||
### 输入校验
|
||||
|
||||
每个已选指标都必须填写一个大于或等于 `0` 的有限数值阈值。阈值为空、非数字或小于 `0` 时:
|
||||
|
||||
- 阻止导出 CSV 或提交批次。
|
||||
- 在对应指标规则行展示校验状态。
|
||||
- 工具栏状态区域给出可理解的错误提示。
|
||||
|
||||
未选中的指标不参与校验。
|
||||
|
||||
## 数据模型
|
||||
|
||||
筛选模型从“一套全局口径 + 多个可选阈值”改为规则数组:
|
||||
|
||||
```ts
|
||||
type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
interface SpreadThresholdFilter {
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
```
|
||||
|
||||
每条规则完整表达“用哪套视频口径读取哪个指标,并与什么阈值比较”。同一指标最多出现一条规则。
|
||||
|
||||
`SpreadInfoConfig` 从 `spread-info.ts` 移到 `types.ts`,由工具栏、筛选模型和传播接口客户端共同引用,避免公共筛选类型反向依赖接口实现模块。
|
||||
|
||||
传播数据导出仍保留完整的 `MappedSpreadInfoResponse` 和七项传播指标映射。筛选规则类型只收窄筛选入口,不影响已有 CSV 导出能力。
|
||||
|
||||
## 筛选执行
|
||||
|
||||
### 处理顺序
|
||||
|
||||
1. 从工具栏读取已选指标,生成 `SpreadMetricFilterRule[]`。
|
||||
2. 没有规则时直接返回原达人集合。
|
||||
3. 按现有导出范围或已选达人规则收集候选达人。
|
||||
4. 对每个达人按规则中的 `config` 分组。
|
||||
5. 每个唯一 `config` 调用一次 `get_author_spread_info`。
|
||||
6. 从返回快照中读取每条规则对应的指标。
|
||||
7. 使用显示百分数值与阈值做 `>=` 比较。
|
||||
8. 对同一达人的所有规则执行 AND 汇总。
|
||||
9. 只有全部规则通过的达人进入 CSV 或批次 payload。
|
||||
|
||||
### 请求复用
|
||||
|
||||
如果完播率和互动率使用完全相同的视频口径,同一达人只请求一次接口,并从同一响应中分别读取 `play_over_rate.value` 和 `interact_rate.value`。
|
||||
|
||||
如果两条规则的视频口径不同,同一达人分别请求两次。
|
||||
|
||||
视频口径是否相同由 `type`、`onlyAssign`、`flowType` 和 `range` 四个规范化后的参数共同决定。个人视频规则必须在分组前规范化为 `onlyAssign=false`、`flowType=0`。
|
||||
|
||||
### 指标映射
|
||||
|
||||
- 完播率规则读取映射后的 `finishRate`,来源为 `data.play_over_rate.value`。
|
||||
- 互动率规则读取映射后的 `interactionRate`,来源为 `data.interact_rate.value`。
|
||||
- 两个接口值继续沿用现有基点百分比格式化逻辑,再与用户输入的显示百分数比较。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 达人缺少传播指标请求所需 ID:该达人不满足筛选。
|
||||
- 单次请求失败、超时或返回非成功状态:依赖该口径的规则不通过。
|
||||
- 响应缺少已选指标:对应规则不通过。
|
||||
- 一条规则不通过:该达人因 AND 关系被排除。
|
||||
- 单个达人失败不终止整批导出或提交,其他达人继续处理。
|
||||
- 没有达人满足规则时,保持现有空结果处理方式,不扩大本次改动范围。
|
||||
|
||||
## 组件与代码边界
|
||||
|
||||
- `plugin-toolbar.ts`:指标复选框、动态规则行、个人视频联动、逐行校验和规则读取。
|
||||
- `types.ts`:共享的 `SpreadInfoConfig`、筛选指标联合类型、单条规则类型和规则数组模型。
|
||||
- `spread-info.ts`:复用现有请求构造与响应映射;提供单指标规则比较或等价的可测试纯函数。
|
||||
- `index.ts`:按唯一口径加载快照、对每个达人执行规则 AND 判断,并让导出和提交共享该流程。
|
||||
- 现有 CSV 字段定义、批次 payload 构造和外部接口契约不变。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
### 工具栏测试
|
||||
|
||||
- 默认两个指标均未选中,不显示规则行。
|
||||
- 勾选或取消完播率、互动率时,正确新增或移除对应规则行。
|
||||
- 两个规则行可以保存不同阈值和不同视频口径。
|
||||
- 个人视频自动规范化并禁用受限选项;星图视频重新启用选项。
|
||||
- 已选指标缺少合法阈值时返回逐行校验错误。
|
||||
- 未选择任何指标时读取到空规则集。
|
||||
|
||||
### 纯逻辑测试
|
||||
|
||||
- 单规则按对应指标和显示百分数正确比较。
|
||||
- 两条规则均通过时返回通过。
|
||||
- 任意一条规则不通过或缺少指标值时返回不通过。
|
||||
- 相同视频口径生成相同分组键,不同口径生成不同分组键。
|
||||
- 个人视频在分组前强制规范化固定参数。
|
||||
|
||||
### 集成测试
|
||||
|
||||
- 未选择指标时,导出和提交均不调用传播指标筛选请求。
|
||||
- 两个指标使用不同口径时,每个达人发起两个请求并按 AND 过滤。
|
||||
- 两个指标使用相同口径时,每个达人只发起一个请求并复用响应。
|
||||
- 请求失败、缺少传播 ID 或缺少指标值的达人被排除,不中断整批处理。
|
||||
- CSV 导出和批次提交得到相同的保留达人集合。
|
||||
- 现有传播数据导出、工具栏挂载和批次 payload 测试继续通过。
|
||||
- 项目类型检查、测试和构建通过。
|
||||
+29
-28
@@ -12,14 +12,14 @@
|
||||
- 使用者在页面上勾选的达人;
|
||||
- 使用者粘贴的达人星图 ID;
|
||||
- 使用者填写的批次名称;
|
||||
- 使用者选择的导出字段和传播指标筛选阈值;
|
||||
- 使用者选择的导出字段和传播指标筛选规则;
|
||||
- 当前插件登录用户的 Logto 身份和访问令牌。
|
||||
|
||||
项目处理过程包括:
|
||||
|
||||
- 在星图达人市场页面挂载插件工具栏;
|
||||
- 读取当前页面或星图列表接口返回的达人数据;
|
||||
- 根据勾选范围、分页范围、阈值筛选规则确定最终达人集合;
|
||||
- 根据勾选范围、分页范围、传播指标规则确定最终达人集合;
|
||||
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
|
||||
- 调用公司后端接口补充秒思 api 指标;
|
||||
- 生成 CSV 文件,或组装批次 payload 提交到后端。
|
||||
@@ -55,7 +55,7 @@
|
||||
3. 打开巨量星图达人市场页面;
|
||||
4. 插件读取登录状态并挂载工具栏;
|
||||
5. 插件读取当前星图达人列表,并补充页面展示指标;
|
||||
6. 使用者选择达人、字段、传播指标筛选条件或输入星图 ID;
|
||||
6. 使用者选择达人、字段、传播指标筛选规则或输入星图 ID;
|
||||
7. 使用者触发导出或提交批次;
|
||||
8. 插件收集达人数据,按规则过滤、去重、补充字段;
|
||||
9. 插件调用星图接口和公司后端接口补充数据;
|
||||
@@ -105,7 +105,7 @@
|
||||
### 2.5 导出选中达人数据
|
||||
|
||||
- 触发者:使用者点击 `导出选中达人数据`。
|
||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
|
||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
|
||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
||||
- 下一步:使用者检查 CSV 内容。
|
||||
@@ -126,7 +126,7 @@
|
||||
|
||||
- 触发者:使用者点击 `提交批次`。
|
||||
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
|
||||
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标阈值筛选和选中规则,检查登录状态,组装批次 payload,提交到后端。
|
||||
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标规则和选中规则,检查登录状态,组装批次 payload,提交到后端。
|
||||
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
|
||||
- 下一步:在后端系统中继续处理批次。
|
||||
- 人工操作:需要使用者输入批次名称。
|
||||
@@ -211,7 +211,7 @@
|
||||
### 3.6 导出选中达人数据
|
||||
|
||||
- 步骤目的:把使用者选定的达人数据导出为 CSV。
|
||||
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
||||
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
|
||||
- 处理规则:
|
||||
- 必须先勾选达人;
|
||||
- 先按导出范围收集达人;
|
||||
@@ -248,23 +248,23 @@
|
||||
- 整体流程异常:提示按 ID 导出失败。
|
||||
- 是否影响后续步骤:不写入外部系统,不影响批次。
|
||||
|
||||
### 3.8 传播指标阈值筛选
|
||||
### 3.8 传播指标规则筛选
|
||||
|
||||
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
|
||||
- 输入内容:视频类别、是否只看指派、是否排除营销流量、时间范围、七个指标阈值。
|
||||
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
|
||||
- 处理规则:
|
||||
- 没有填写任何阈值时,不启用该筛选;
|
||||
- 填写多个阈值时必须全部满足;
|
||||
- 个人视频固定为不限指派、不排除营销流量;
|
||||
- 星图视频可选择只看指派、不限指派、排除营销流量或不排除营销流量;
|
||||
- 默认不选择任何指标,不启用二次筛选;
|
||||
- 选择指标后必须填写该指标阈值,并为该指标选择一套独立视频口径;
|
||||
- 同时选择完播率和互动率时,两条规则必须全部满足;
|
||||
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
|
||||
- 每条个人视频规则固定为不限指派、不排除营销流量;
|
||||
- 每条星图视频规则可独立选择只看指派、不限指派、排除营销流量或不排除营销流量;
|
||||
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`;
|
||||
- 平均时长按秒比较;
|
||||
- 播放量、评论、点赞、转发按普通数字比较;
|
||||
- 请求失败或缺少被启用指标的达人视为不满足筛选。
|
||||
- 请求失败、缺少传播指标请求 ID 或缺少已选指标的达人视为不满足筛选。
|
||||
- 输出结果:过滤后的达人集合。
|
||||
- 外部依赖:星图传播指标接口。
|
||||
- 失败后如何处理:
|
||||
- 阈值非法:阻止导出或提交并提示;
|
||||
- 已选指标阈值为空或非法:阻止导出或提交,并提示具体指标;
|
||||
- 单个达人筛选请求失败:跳过该达人;
|
||||
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
|
||||
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
|
||||
@@ -320,7 +320,7 @@
|
||||
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
|
||||
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
|
||||
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 与画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
|
||||
| `get_author_spread_info` | 获取内容传播指标,并用于阈值筛选 | 内容数据导出、阈值筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败时该达人不满足筛选 |
|
||||
| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 |
|
||||
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id`、`values`、`page=1`、`size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
|
||||
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
|
||||
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
|
||||
@@ -344,7 +344,7 @@
|
||||
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
|
||||
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
|
||||
- 公司 talent-search 后端:秒思 api 指标;
|
||||
- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和阈值筛选条件。
|
||||
- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和传播指标筛选规则。
|
||||
|
||||
### 5.2 保留规则
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
- 画像导出只保留当前导出范围内的已勾选达人;
|
||||
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
|
||||
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
|
||||
- 传播指标阈值筛选启用后,不满足全部阈值的达人会被过滤;
|
||||
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
|
||||
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
||||
|
||||
### 5.4 去重规则
|
||||
@@ -423,7 +423,7 @@
|
||||
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
|
||||
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
|
||||
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
|
||||
- 阈值筛选没有持久状态,重新执行时按当前页面输入框值重新判断。
|
||||
- 传播指标规则没有持久状态,重新执行时按当前页面选择和输入值重新判断。
|
||||
|
||||
重复执行相对安全的操作:
|
||||
|
||||
@@ -438,7 +438,7 @@
|
||||
- 重复点击提交批次;
|
||||
- 修改后端地址后提交批次;
|
||||
- 使用不同星图筛选条件或不同字段选择重复导出后,拿多个 CSV 混用;
|
||||
- 阈值输入为空或变化后重复提交,可能导致提交达人集合变化。
|
||||
- 指标选择、视频口径或阈值变化后重复提交,可能导致提交达人集合变化。
|
||||
|
||||
未确认项:
|
||||
|
||||
@@ -487,7 +487,7 @@
|
||||
2. 等待页面列表加载完成;
|
||||
3. 勾选需要导出的达人;
|
||||
4. 可选:点击 `选择字段` 调整 CSV 字段;
|
||||
5. 可选:填写传播指标阈值;
|
||||
5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径;
|
||||
6. 点击 `导出选中达人数据`;
|
||||
7. 等待状态提示从导出中消失或浏览器下载完成;
|
||||
8. 在下载目录或 Chrome 下载列表中查看 CSV;
|
||||
@@ -506,7 +506,7 @@
|
||||
|
||||
1. 在星图市场中完成筛选;
|
||||
2. 可选:勾选需要提交的达人;
|
||||
3. 可选:填写传播指标阈值;
|
||||
3. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径;
|
||||
4. 点击 `提交批次`;
|
||||
5. 输入批次名称;
|
||||
6. 等待页面提示 `批次提交成功`;
|
||||
@@ -541,7 +541,7 @@
|
||||
- 删除正在被 Chrome 加载的 `dist` 文件夹;
|
||||
- 随意修改 Logto 配置、后端地址、scope 或 manifest key;
|
||||
- 在未确认星图筛选条件的情况下提交全部范围达人;
|
||||
- 阈值筛选填错导致提交集合被大幅改变。
|
||||
- 传播指标、视频口径或阈值选择错误,导致提交集合被大幅改变。
|
||||
|
||||
### 7.10 不能随便改的参数
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 |
|
||||
| COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
|
||||
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
|
||||
| 传播指标阈值 | 导出或提交前二次过滤达人 | 空 | 非负数字 | 影响最终保留达人集合 | 不需要重启 | 填错会过滤掉目标达人 |
|
||||
| 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 |
|
||||
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
|
||||
|
||||
## 9. 任务执行和结果确认
|
||||
@@ -635,6 +635,7 @@
|
||||
- 全部画像失败:提示画像导出失败,不下载 CSV;
|
||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
||||
- 已选传播指标未填写合法阈值:状态区提示具体指标,导出或提交不会开始;
|
||||
- 更新清单失败:弹窗显示暂时无法检查更新或错误信息。
|
||||
|
||||
### 9.6 最终结果查看位置
|
||||
@@ -666,7 +667,7 @@
|
||||
- 批次提交接口幂等规则:未确认。
|
||||
- 项目没有持久任务状态记录,不支持真正断点续跑。
|
||||
- 导出范围过大时,会产生大量星图接口请求,运行时间会变长。
|
||||
- 当前传播指标补充和筛选存在并发请求;是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
|
||||
- 当前传播指标补充和筛选存在并发请求;筛选会复用同一达人相同视频口径的响应,但是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
|
||||
- 星图页面结构变化可能导致工具栏挂载、列表读取或翻页失效。
|
||||
- 星图网页登录态过期会导致接口失败。
|
||||
- Logto token 不可用会导致后端指标和批次提交失败。
|
||||
@@ -678,7 +679,7 @@
|
||||
- 扩展 ID、Logto 回调和 manifest key 强相关,改错会导致登录失败。
|
||||
- `http://localhost:8083` 作为批次提交默认地址时,只适合本机后端可用的场景;生产或同事环境是否适用未确认。
|
||||
- 下载 CSV 不会自动校验业务完整性,需要使用者或管理者检查导出状态和关键字段。
|
||||
- 传播指标阈值填错会改变导出或提交达人集合。
|
||||
- 传播指标、视频口径或阈值选错会改变导出或提交达人集合。
|
||||
|
||||
## 11. 未确认项清单
|
||||
|
||||
@@ -699,7 +700,7 @@
|
||||
- 插件是否有统一日志、错误上报或审计记录:未确认。
|
||||
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
|
||||
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。
|
||||
- 传播指标请求是否应该限制并发:需求文档曾提出需要限制,但当前真实业务级并发控制未确认。
|
||||
- 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。
|
||||
- 后续业务系统如何消费批次:未确认。
|
||||
|
||||
## 12. 文档维护规则
|
||||
|
||||
+50
-13
@@ -31,7 +31,13 @@ import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import { createSilentExportController } from "./silent-export-controller";
|
||||
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
createSpreadInfoClient,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
type MappedSpreadInfoResponse
|
||||
} from "./spread-info";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
readToolbarSpreadFilter,
|
||||
@@ -57,6 +63,8 @@ import type {
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState,
|
||||
SpreadInfoConfig,
|
||||
SpreadMetricFilterRule,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
@@ -84,8 +92,8 @@ export interface CreateMarketControllerOptions {
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadThresholdFilter["config"]
|
||||
) => Promise<Record<string, string | undefined>>;
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
@@ -802,10 +810,19 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
records: MarketRecord[],
|
||||
filter: SpreadThresholdFilter | undefined
|
||||
): Promise<MarketRecord[]> {
|
||||
if (!filter) {
|
||||
if (!filter || filter.rules.length === 0) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const normalizedRules = filter.rules.map((rule) => ({
|
||||
...rule,
|
||||
config: normalizeSpreadInfoConfig(rule.config)
|
||||
}));
|
||||
const configsByKey = new Map<string, SpreadInfoConfig>();
|
||||
normalizedRules.forEach((rule) => {
|
||||
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config);
|
||||
});
|
||||
|
||||
const matchedAuthorIds = new Set<string>();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
@@ -814,21 +831,41 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await loadSpreadFilterMetrics(
|
||||
spreadAuthorId,
|
||||
filter.config
|
||||
);
|
||||
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
} catch {}
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(
|
||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
||||
try {
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||
}
|
||||
|
||||
function matchesAllSpreadMetricRules(
|
||||
rules: SpreadMetricFilterRule[],
|
||||
snapshots: Map<string, MappedSpreadInfoResponse>
|
||||
): boolean {
|
||||
return rules.every((rule) =>
|
||||
matchesSpreadMetricRule(
|
||||
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
|
||||
rule
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget,
|
||||
SpreadFilterMetric,
|
||||
SpreadInfoConfig,
|
||||
SpreadMetricFilterRule,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
@@ -12,12 +15,25 @@ export interface PluginToolbarHandlers {
|
||||
onSubmitBatch(): Promise<void> | void;
|
||||
}
|
||||
|
||||
type VisibleSpreadThresholdKey = Extract<
|
||||
keyof SpreadThresholdFilter["thresholds"],
|
||||
"finishRate" | "interactionRate"
|
||||
>;
|
||||
interface SpreadMetricRuleDom {
|
||||
enabledInput: HTMLInputElement;
|
||||
flowTypeSelect: HTMLSelectElement;
|
||||
onlyAssignSelect: HTMLSelectElement;
|
||||
rangeSelect: HTMLSelectElement;
|
||||
root: HTMLElement;
|
||||
thresholdInput: HTMLInputElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
type SpreadThresholdInputMap = Record<VisibleSpreadThresholdKey, HTMLInputElement>;
|
||||
type SpreadMetricRuleDomMap = Record<SpreadFilterMetric, SpreadMetricRuleDom>;
|
||||
|
||||
const SPREAD_FILTER_DEFINITIONS: ReadonlyArray<{
|
||||
label: string;
|
||||
metric: SpreadFilterMetric;
|
||||
}> = [
|
||||
{ label: "完播率", metric: "finishRate" },
|
||||
{ label: "互动率", metric: "interactionRate" }
|
||||
];
|
||||
|
||||
export interface PluginToolbarDom {
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
@@ -28,11 +44,7 @@ export interface PluginToolbarDom {
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
spreadThresholdInputs: SpreadThresholdInputMap;
|
||||
spreadMetricRules: SpreadMetricRuleDomMap;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
@@ -59,7 +71,8 @@ export function ensurePluginToolbar(
|
||||
if (
|
||||
existingRoot.querySelector(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
)
|
||||
) &&
|
||||
existingRoot.querySelector('[data-plugin-spread-metric="finishRate"]')
|
||||
) {
|
||||
ensureToolbarMounted(existingRoot, document);
|
||||
return readToolbarDom(existingRoot);
|
||||
@@ -126,31 +139,7 @@ export function ensurePluginToolbar(
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
const spreadFilterTypeSelect = document.createElement("select");
|
||||
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
|
||||
appendOption(spreadFilterTypeSelect, "1", "个人视频");
|
||||
appendOption(spreadFilterTypeSelect, "2", "星图视频");
|
||||
spreadFilterTypeSelect.value = "1";
|
||||
|
||||
const spreadFilterOnlyAssignSelect = document.createElement("select");
|
||||
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
||||
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
|
||||
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
|
||||
spreadFilterOnlyAssignSelect.value = "false";
|
||||
|
||||
const spreadFilterFlowTypeSelect = document.createElement("select");
|
||||
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
||||
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
|
||||
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
|
||||
spreadFilterFlowTypeSelect.value = "0";
|
||||
|
||||
const spreadFilterRangeSelect = document.createElement("select");
|
||||
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
|
||||
appendOption(spreadFilterRangeSelect, "2", "近30天");
|
||||
appendOption(spreadFilterRangeSelect, "3", "近90天");
|
||||
spreadFilterRangeSelect.value = "2";
|
||||
|
||||
const spreadThresholdInputs = createSpreadThresholdInputs(document);
|
||||
const spreadMetricRules = createSpreadMetricRuleDoms(document);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.pluginToolbarPanel = "root";
|
||||
@@ -177,27 +166,20 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton
|
||||
);
|
||||
|
||||
const videoGroup = document.createElement("div");
|
||||
videoGroup.dataset.pluginToolbarGroup = "video";
|
||||
applyToolbarGroupStyles(videoGroup);
|
||||
videoGroup.append(
|
||||
createToolbarGroupTitle(document, "视频口径"),
|
||||
spreadFilterTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterRangeSelect
|
||||
);
|
||||
|
||||
const thresholdGroup = document.createElement("div");
|
||||
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
|
||||
applyThresholdGroupStyles(thresholdGroup);
|
||||
thresholdGroup.append(
|
||||
...createSpreadThresholdControls(document, spreadThresholdInputs)
|
||||
);
|
||||
|
||||
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
|
||||
firstRow.append(dataGroup, videoGroup, exportStatusText);
|
||||
secondRow.append(thresholdTitle, thresholdGroup);
|
||||
const metricSelector = createSpreadMetricSelector(document, spreadMetricRules);
|
||||
const rulesGroup = document.createElement("div");
|
||||
rulesGroup.dataset.pluginSpreadRules = "root";
|
||||
applySpreadRulesGroupStyles(rulesGroup);
|
||||
rulesGroup.append(
|
||||
...SPREAD_FILTER_DEFINITIONS.map(
|
||||
({ metric }) => spreadMetricRules[metric].root
|
||||
)
|
||||
);
|
||||
const filterNote = createSpreadFilterNote(document);
|
||||
|
||||
firstRow.append(dataGroup, exportStatusText);
|
||||
secondRow.append(thresholdTitle, metricSelector, rulesGroup, filterNote);
|
||||
panel.append(firstRow, secondRow);
|
||||
|
||||
root.append(panel);
|
||||
@@ -211,11 +193,7 @@ export function ensurePluginToolbar(
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
...spreadThresholdInputs
|
||||
spreadMetricRules
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
@@ -234,42 +212,6 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton.addEventListener("click", () => {
|
||||
void handlers.onSubmitBatch();
|
||||
});
|
||||
exportRangeSelect.addEventListener("change", () => {
|
||||
syncCustomPagesInputVisibility({
|
||||
batchSubmitButton,
|
||||
audienceProfileFieldButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
spreadFilterTypeSelect.addEventListener("change", () => {
|
||||
syncSpreadFilterControlState({
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileExportButton,
|
||||
@@ -280,15 +222,27 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs,
|
||||
spreadMetricRules,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
|
||||
exportRangeSelect.addEventListener("change", () => {
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
});
|
||||
|
||||
SPREAD_FILTER_DEFINITIONS.forEach(({ metric }) => {
|
||||
const rule = spreadMetricRules[metric];
|
||||
rule.enabledInput.addEventListener("change", () => {
|
||||
syncSpreadMetricRuleState(rule);
|
||||
syncSpreadFilterNote(toolbarDom);
|
||||
});
|
||||
rule.typeSelect.addEventListener("change", () => {
|
||||
syncSpreadMetricVideoConstraints(rule);
|
||||
});
|
||||
});
|
||||
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
syncAllSpreadMetricRules(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
@@ -304,94 +258,161 @@ function appendOption(
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function createSpreadThresholdInputs(
|
||||
function createSpreadMetricRuleDoms(
|
||||
document: Document
|
||||
): SpreadThresholdInputMap {
|
||||
): SpreadMetricRuleDomMap {
|
||||
return Object.fromEntries(
|
||||
SPREAD_FILTER_DEFINITIONS.map(({ label, metric }) => [
|
||||
metric,
|
||||
createSpreadMetricRuleDom(document, metric, label)
|
||||
])
|
||||
) as unknown as SpreadMetricRuleDomMap;
|
||||
}
|
||||
|
||||
function createSpreadMetricRuleDom(
|
||||
document: Document,
|
||||
metric: SpreadFilterMetric,
|
||||
label: string
|
||||
): SpreadMetricRuleDom {
|
||||
const enabledInput = document.createElement("input");
|
||||
enabledInput.type = "checkbox";
|
||||
enabledInput.dataset.pluginSpreadMetric = metric;
|
||||
enabledInput.setAttribute("aria-label", `启用${label}筛选`);
|
||||
|
||||
const thresholdInput = document.createElement("input");
|
||||
thresholdInput.type = "number";
|
||||
thresholdInput.min = "0";
|
||||
thresholdInput.step = "0.1";
|
||||
thresholdInput.dataset.pluginSpreadThreshold = metric;
|
||||
thresholdInput.setAttribute("aria-label", `${label}筛选阈值`);
|
||||
|
||||
const typeSelect = document.createElement("select");
|
||||
typeSelect.dataset.pluginSpreadFilter = "type";
|
||||
appendOption(typeSelect, "1", "个人视频");
|
||||
appendOption(typeSelect, "2", "星图视频");
|
||||
typeSelect.value = "1";
|
||||
|
||||
const onlyAssignSelect = document.createElement("select");
|
||||
onlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
||||
appendOption(onlyAssignSelect, "false", "不限指派");
|
||||
appendOption(onlyAssignSelect, "true", "只看指派");
|
||||
onlyAssignSelect.value = "false";
|
||||
|
||||
const flowTypeSelect = document.createElement("select");
|
||||
flowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
||||
appendOption(flowTypeSelect, "0", "不排除营销");
|
||||
appendOption(flowTypeSelect, "1", "排除营销");
|
||||
flowTypeSelect.value = "0";
|
||||
|
||||
const rangeSelect = document.createElement("select");
|
||||
rangeSelect.dataset.pluginSpreadFilter = "range";
|
||||
appendOption(rangeSelect, "2", "近30天");
|
||||
appendOption(rangeSelect, "3", "近90天");
|
||||
rangeSelect.value = "2";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.dataset.pluginSpreadRule = metric;
|
||||
root.hidden = true;
|
||||
applySpreadMetricRuleStyles(root);
|
||||
|
||||
const metricLabel = document.createElement("strong");
|
||||
metricLabel.textContent = label;
|
||||
applySpreadMetricRuleLabelStyles(metricLabel);
|
||||
|
||||
const thresholdControl = document.createElement("label");
|
||||
thresholdControl.dataset.pluginSpreadThresholdControl = metric;
|
||||
applySpreadThresholdControlStyles(thresholdControl);
|
||||
|
||||
const operator = document.createElement("b");
|
||||
operator.dataset.pluginSpreadThresholdOperator = "gte";
|
||||
operator.textContent = "≥";
|
||||
|
||||
const unitText = document.createElement("span");
|
||||
unitText.dataset.pluginSpreadThresholdUnit = metric;
|
||||
unitText.textContent = "%";
|
||||
thresholdControl.append(operator, thresholdInput, unitText);
|
||||
|
||||
root.append(
|
||||
metricLabel,
|
||||
thresholdControl,
|
||||
typeSelect,
|
||||
onlyAssignSelect,
|
||||
flowTypeSelect,
|
||||
rangeSelect
|
||||
);
|
||||
|
||||
return {
|
||||
finishRate: createSpreadThresholdInput(document, "finishRate"),
|
||||
interactionRate: createSpreadThresholdInput(document, "interactionRate")
|
||||
enabledInput,
|
||||
flowTypeSelect,
|
||||
onlyAssignSelect,
|
||||
rangeSelect,
|
||||
root,
|
||||
thresholdInput,
|
||||
typeSelect
|
||||
};
|
||||
}
|
||||
|
||||
function createSpreadThresholdInput(
|
||||
function createSpreadMetricSelector(
|
||||
document: Document,
|
||||
key: VisibleSpreadThresholdKey
|
||||
): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.step = getSpreadThresholdStep(key);
|
||||
input.dataset.pluginSpreadThreshold = key;
|
||||
return input;
|
||||
}
|
||||
|
||||
function getSpreadThresholdStep(
|
||||
key: VisibleSpreadThresholdKey
|
||||
): string {
|
||||
return key === "finishRate" || key === "interactionRate" ? "0.1" : "1";
|
||||
}
|
||||
|
||||
function createSpreadThresholdControls(
|
||||
document: Document,
|
||||
inputs: SpreadThresholdInputMap
|
||||
): HTMLElement[] {
|
||||
const controls: HTMLElement[] = [];
|
||||
const entries: Array<[string, string, HTMLInputElement]> = [
|
||||
["完播率", "%", inputs.finishRate],
|
||||
["互动率", "%", inputs.interactionRate]
|
||||
];
|
||||
|
||||
entries.forEach(([label, unit, input], index) => {
|
||||
if (index > 0) {
|
||||
controls.push(createSpreadThresholdConjunction(document));
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.dataset.pluginSpreadThresholdControl = input.dataset.pluginSpreadThreshold;
|
||||
applySpreadThresholdControlStyles(wrapper);
|
||||
rules: SpreadMetricRuleDomMap
|
||||
): HTMLElement {
|
||||
const selector = document.createElement("div");
|
||||
selector.dataset.pluginSpreadMetrics = "root";
|
||||
applySpreadMetricSelectorStyles(selector);
|
||||
|
||||
SPREAD_FILTER_DEFINITIONS.forEach(({ label, metric }) => {
|
||||
const option = document.createElement("label");
|
||||
applySpreadMetricOptionStyles(option);
|
||||
const labelText = document.createElement("span");
|
||||
labelText.textContent = label;
|
||||
|
||||
const operator = document.createElement("b");
|
||||
operator.dataset.pluginSpreadThresholdOperator = "gte";
|
||||
operator.textContent = "≥";
|
||||
|
||||
const unitText = document.createElement("span");
|
||||
unitText.dataset.pluginSpreadThresholdUnit = input.dataset.pluginSpreadThreshold;
|
||||
unitText.textContent = unit;
|
||||
|
||||
wrapper.append(labelText, operator, input, unitText);
|
||||
controls.push(wrapper);
|
||||
option.append(rules[metric].enabledInput, labelText);
|
||||
selector.appendChild(option);
|
||||
});
|
||||
|
||||
return controls;
|
||||
return selector;
|
||||
}
|
||||
|
||||
function createSpreadThresholdConjunction(document: Document): HTMLElement {
|
||||
const conjunction = document.createElement("span");
|
||||
conjunction.dataset.pluginSpreadThresholdConjunction = "and";
|
||||
conjunction.textContent = "且";
|
||||
applySpreadThresholdConjunctionStyles(conjunction);
|
||||
return conjunction;
|
||||
function createSpreadFilterNote(document: Document): HTMLElement {
|
||||
const note = document.createElement("span");
|
||||
note.dataset.pluginSpreadFilterNote = "and";
|
||||
note.textContent = "全部规则都达标才保留达人";
|
||||
note.hidden = true;
|
||||
applySpreadFilterNoteStyles(note);
|
||||
return note;
|
||||
}
|
||||
|
||||
function readSpreadThresholdInputs(
|
||||
root: HTMLElement
|
||||
): SpreadThresholdInputMap {
|
||||
return {
|
||||
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
||||
interactionRate: readSpreadThresholdInput(root, "interactionRate")
|
||||
};
|
||||
}
|
||||
|
||||
function readSpreadThresholdInput(
|
||||
root: HTMLElement,
|
||||
key: VisibleSpreadThresholdKey
|
||||
): HTMLInputElement {
|
||||
return root.querySelector(
|
||||
`[data-plugin-spread-threshold="${key}"]`
|
||||
) as HTMLInputElement;
|
||||
function readSpreadMetricRuleDoms(root: HTMLElement): SpreadMetricRuleDomMap {
|
||||
return Object.fromEntries(
|
||||
SPREAD_FILTER_DEFINITIONS.map(({ metric }) => {
|
||||
const ruleRoot = root.querySelector(
|
||||
`[data-plugin-spread-rule="${metric}"]`
|
||||
) as HTMLElement;
|
||||
return [
|
||||
metric,
|
||||
{
|
||||
enabledInput: root.querySelector(
|
||||
`[data-plugin-spread-metric="${metric}"]`
|
||||
) as HTMLInputElement,
|
||||
flowTypeSelect: ruleRoot.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement,
|
||||
onlyAssignSelect: ruleRoot.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement,
|
||||
rangeSelect: ruleRoot.querySelector(
|
||||
'[data-plugin-spread-filter="range"]'
|
||||
) as HTMLSelectElement,
|
||||
root: ruleRoot,
|
||||
thresholdInput: ruleRoot.querySelector(
|
||||
`[data-plugin-spread-threshold="${metric}"]`
|
||||
) as HTMLInputElement,
|
||||
typeSelect: ruleRoot.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement
|
||||
} satisfies SpreadMetricRuleDom
|
||||
];
|
||||
})
|
||||
) as unknown as SpreadMetricRuleDomMap;
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
@@ -420,23 +441,11 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
spreadFilterFlowTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterOnlyAssignSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterRangeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="range"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadThresholdInputs: readSpreadThresholdInputs(root),
|
||||
spreadMetricRules: readSpreadMetricRuleDoms(root),
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
syncAllSpreadMetricRules(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
@@ -498,44 +507,34 @@ export function readToolbarExportTarget(
|
||||
export function readToolbarSpreadFilter(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; filter?: SpreadThresholdFilter } {
|
||||
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
||||
const rules: SpreadMetricFilterRule[] = [];
|
||||
|
||||
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
||||
const trimmedValue = input.value.trim();
|
||||
if (!trimmedValue) {
|
||||
for (const { label, metric } of SPREAD_FILTER_DEFINITIONS) {
|
||||
const ruleDom = toolbar.spreadMetricRules[metric];
|
||||
clearSpreadMetricRuleValidation(ruleDom);
|
||||
if (!ruleDom.enabledInput.checked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
const trimmedValue = ruleDom.thresholdInput.value.trim();
|
||||
const threshold = Number(trimmedValue);
|
||||
if (!trimmedValue || !Number.isFinite(threshold) || threshold < 0) {
|
||||
markSpreadMetricRuleInvalid(ruleDom);
|
||||
return {
|
||||
error: "请输入有效筛选阈值"
|
||||
error: `请输入有效的${label}筛选阈值`
|
||||
};
|
||||
}
|
||||
|
||||
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
||||
rules.push({
|
||||
config: readSpreadMetricConfig(ruleDom),
|
||||
metric,
|
||||
threshold
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(thresholds).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
||||
return {
|
||||
filter: {
|
||||
config: {
|
||||
flowType:
|
||||
type === 1
|
||||
? 0
|
||||
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
|
||||
? 1
|
||||
: 0,
|
||||
onlyAssign:
|
||||
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
|
||||
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
|
||||
type
|
||||
},
|
||||
thresholds
|
||||
rules
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -552,16 +551,19 @@ export function setToolbarBusyState(
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.spreadFilterTypeSelect,
|
||||
toolbar.spreadFilterOnlyAssignSelect,
|
||||
toolbar.spreadFilterFlowTypeSelect,
|
||||
toolbar.spreadFilterRangeSelect,
|
||||
...Object.values(toolbar.spreadThresholdInputs)
|
||||
...Object.values(toolbar.spreadMetricRules).flatMap((rule) => [
|
||||
rule.enabledInput,
|
||||
rule.thresholdInput,
|
||||
rule.typeSelect,
|
||||
rule.onlyAssignSelect,
|
||||
rule.flowTypeSelect,
|
||||
rule.rangeSelect
|
||||
])
|
||||
].forEach((element) => {
|
||||
element.disabled = isBusy;
|
||||
});
|
||||
if (!isBusy) {
|
||||
syncSpreadFilterControlState(toolbar);
|
||||
syncAllSpreadMetricRules(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,14 +579,68 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
|
||||
}
|
||||
|
||||
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
||||
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
||||
if (isPersonalVideo) {
|
||||
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
||||
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
||||
function syncAllSpreadMetricRules(toolbar: PluginToolbarDom): void {
|
||||
Object.values(toolbar.spreadMetricRules).forEach(syncSpreadMetricRuleState);
|
||||
syncSpreadFilterNote(toolbar);
|
||||
}
|
||||
|
||||
function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void {
|
||||
rule.root.hidden = !rule.enabledInput.checked;
|
||||
if (!rule.enabledInput.checked) {
|
||||
rule.thresholdInput.value = "";
|
||||
rule.typeSelect.value = "1";
|
||||
rule.onlyAssignSelect.value = "false";
|
||||
rule.flowTypeSelect.value = "0";
|
||||
rule.rangeSelect.value = "2";
|
||||
clearSpreadMetricRuleValidation(rule);
|
||||
}
|
||||
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
||||
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
||||
syncSpreadMetricVideoConstraints(rule);
|
||||
}
|
||||
|
||||
function syncSpreadMetricVideoConstraints(rule: SpreadMetricRuleDom): void {
|
||||
const isPersonalVideo = rule.typeSelect.value !== "2";
|
||||
if (isPersonalVideo) {
|
||||
rule.onlyAssignSelect.value = "false";
|
||||
rule.flowTypeSelect.value = "0";
|
||||
}
|
||||
rule.onlyAssignSelect.disabled = isPersonalVideo;
|
||||
rule.flowTypeSelect.disabled = isPersonalVideo;
|
||||
}
|
||||
|
||||
function syncSpreadFilterNote(toolbar: PluginToolbarDom): void {
|
||||
const note = toolbar.root.querySelector(
|
||||
'[data-plugin-spread-filter-note="and"]'
|
||||
) as HTMLElement | null;
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledCount = Object.values(toolbar.spreadMetricRules).filter(
|
||||
(rule) => rule.enabledInput.checked
|
||||
).length;
|
||||
note.hidden = enabledCount < 2;
|
||||
}
|
||||
|
||||
function readSpreadMetricConfig(rule: SpreadMetricRuleDom): SpreadInfoConfig {
|
||||
const type = rule.typeSelect.value === "2" ? 2 : 1;
|
||||
return {
|
||||
flowType:
|
||||
type === 1 ? 0 : rule.flowTypeSelect.value === "1" ? 1 : 0,
|
||||
onlyAssign:
|
||||
type === 1 ? false : rule.onlyAssignSelect.value === "true",
|
||||
range: rule.rangeSelect.value === "3" ? 3 : 2,
|
||||
type
|
||||
};
|
||||
}
|
||||
|
||||
function clearSpreadMetricRuleValidation(rule: SpreadMetricRuleDom): void {
|
||||
delete rule.root.dataset.pluginSpreadRuleInvalid;
|
||||
rule.thresholdInput.removeAttribute("aria-invalid");
|
||||
}
|
||||
|
||||
function markSpreadMetricRuleInvalid(rule: SpreadMetricRuleDom): void {
|
||||
rule.root.dataset.pluginSpreadRuleInvalid = "true";
|
||||
rule.thresholdInput.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
@@ -794,17 +850,61 @@ function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||
group.style.flexWrap = "nowrap";
|
||||
}
|
||||
|
||||
function applyThresholdGroupStyles(group: HTMLElement): void {
|
||||
function applySpreadMetricSelectorStyles(selector: HTMLElement): void {
|
||||
selector.style.display = "flex";
|
||||
selector.style.alignItems = "center";
|
||||
selector.style.gap = "12px";
|
||||
selector.style.flex = "0 0 auto";
|
||||
selector.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function applySpreadMetricOptionStyles(option: HTMLElement): void {
|
||||
option.style.display = "inline-flex";
|
||||
option.style.alignItems = "center";
|
||||
option.style.gap = "5px";
|
||||
option.style.height = "32px";
|
||||
option.style.color = "#344054";
|
||||
option.style.fontSize = "12px";
|
||||
option.style.fontWeight = "700";
|
||||
option.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function applySpreadRulesGroupStyles(group: HTMLElement): void {
|
||||
group.style.display = "flex";
|
||||
group.style.alignItems = "center";
|
||||
group.style.gap = "7px";
|
||||
group.style.flexDirection = "column";
|
||||
group.style.alignItems = "stretch";
|
||||
group.style.gap = "6px";
|
||||
group.style.minWidth = "0";
|
||||
group.style.flex = "1 1 auto";
|
||||
group.style.flexWrap = "nowrap";
|
||||
group.style.overflowX = "auto";
|
||||
group.style.overflowY = "hidden";
|
||||
}
|
||||
|
||||
function applySpreadMetricRuleStyles(rule: HTMLElement): void {
|
||||
rule.style.display = "flex";
|
||||
rule.style.alignItems = "center";
|
||||
rule.style.gap = "7px";
|
||||
rule.style.minWidth = "max-content";
|
||||
rule.style.minHeight = "32px";
|
||||
rule.style.whiteSpace = "nowrap";
|
||||
}
|
||||
|
||||
function applySpreadMetricRuleLabelStyles(label: HTMLElement): void {
|
||||
label.style.width = "48px";
|
||||
label.style.color = "#344054";
|
||||
label.style.fontSize = "12px";
|
||||
label.style.fontWeight = "800";
|
||||
label.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applySpreadFilterNoteStyles(note: HTMLElement): void {
|
||||
note.style.color = "#0f8a5f";
|
||||
note.style.fontSize = "12px";
|
||||
note.style.fontWeight = "800";
|
||||
note.style.whiteSpace = "nowrap";
|
||||
note.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
||||
const title = document.createElement("span");
|
||||
title.dataset.pluginToolbarTitle = label;
|
||||
@@ -834,14 +934,8 @@ function applyNativeControlStyles(
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
} & Record<
|
||||
VisibleSpreadThresholdKey,
|
||||
HTMLInputElement
|
||||
>
|
||||
spreadMetricRules: SpreadMetricRuleDomMap;
|
||||
}
|
||||
): void {
|
||||
const primaryButton =
|
||||
findButtonContainingText(document, "发布任务") ??
|
||||
@@ -870,13 +964,31 @@ function applyNativeControlStyles(
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
const nativeControls = Array.from(Object.values(controls)).filter(
|
||||
(element): element is HTMLInputElement | HTMLSelectElement =>
|
||||
element instanceof document.defaultView!.HTMLInputElement ||
|
||||
element instanceof document.defaultView!.HTMLSelectElement
|
||||
const ruleControls = Object.values(controls.spreadMetricRules).flatMap(
|
||||
(rule) => [
|
||||
rule.enabledInput,
|
||||
rule.thresholdInput,
|
||||
rule.typeSelect,
|
||||
rule.onlyAssignSelect,
|
||||
rule.flowTypeSelect,
|
||||
rule.rangeSelect
|
||||
]
|
||||
);
|
||||
const nativeControls = [
|
||||
controls.exportCustomPagesInput,
|
||||
controls.exportRangeSelect,
|
||||
...ruleControls
|
||||
];
|
||||
|
||||
nativeControls.forEach((element) => {
|
||||
if (element instanceof document.defaultView!.HTMLInputElement && element.type === "checkbox") {
|
||||
element.style.width = "16px";
|
||||
element.style.height = "16px";
|
||||
element.style.padding = "0";
|
||||
element.style.accentColor = "#7f1d2d";
|
||||
element.style.flex = "0 0 auto";
|
||||
return;
|
||||
}
|
||||
element.style.height = "32px";
|
||||
element.style.border = "1px solid #d0d7de";
|
||||
element.style.borderRadius = "6px";
|
||||
@@ -890,22 +1002,15 @@ function applyNativeControlStyles(
|
||||
controls.exportRangeSelect.style.minWidth = "104px";
|
||||
controls.exportCustomPagesInput.style.width = "72px";
|
||||
|
||||
[
|
||||
controls.spreadFilterTypeSelect,
|
||||
controls.spreadFilterOnlyAssignSelect,
|
||||
controls.spreadFilterFlowTypeSelect,
|
||||
controls.spreadFilterRangeSelect
|
||||
].forEach((select) => {
|
||||
select.style.minWidth = "84px";
|
||||
});
|
||||
|
||||
Object.values(controls).forEach((element) => {
|
||||
ruleControls.forEach((element) => {
|
||||
if (element instanceof document.defaultView!.HTMLSelectElement) {
|
||||
element.style.minWidth = "84px";
|
||||
}
|
||||
if (
|
||||
element instanceof document.defaultView!.HTMLInputElement &&
|
||||
element.dataset.pluginSpreadThreshold
|
||||
) {
|
||||
element.style.width =
|
||||
element.dataset.pluginSpreadThreshold === "playMedian" ? "82px" : "58px";
|
||||
element.style.width = "58px";
|
||||
element.style.minWidth = "0";
|
||||
element.style.height = "26px";
|
||||
element.style.border = "0";
|
||||
@@ -945,14 +1050,6 @@ function applySpreadThresholdControlStyles(control: HTMLElement): void {
|
||||
control.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applySpreadThresholdConjunctionStyles(conjunction: HTMLElement): void {
|
||||
conjunction.style.color = "#0f8a5f";
|
||||
conjunction.style.fontSize = "12px";
|
||||
conjunction.style.fontWeight = "900";
|
||||
conjunction.style.whiteSpace = "nowrap";
|
||||
conjunction.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applyStatusStyles(statusText: HTMLElement): void {
|
||||
statusText.style.color = "#64748b";
|
||||
statusText.style.fontSize = "12px";
|
||||
@@ -1026,6 +1123,11 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
color: #0f8a5f !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
|
||||
[data-plugin-spread-rule-invalid="true"] [data-plugin-spread-threshold-control] {
|
||||
border-color: #dc2626 !important;
|
||||
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||
import type {
|
||||
SpreadInfoConfig,
|
||||
SpreadInfoMetrics,
|
||||
SpreadMetricFilterRule
|
||||
} from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
@@ -10,13 +14,6 @@ type FetchLike = (
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
interface SpreadInfoClientOptions {
|
||||
baseUrl?: string;
|
||||
configs?: SpreadInfoConfig[];
|
||||
@@ -207,19 +204,30 @@ export function mapSpreadInfoResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSpreadThresholds(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
thresholds: SpreadMetricThresholds
|
||||
): boolean {
|
||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return true;
|
||||
}
|
||||
export function normalizeSpreadInfoConfig(
|
||||
config: SpreadInfoConfig
|
||||
): SpreadInfoConfig {
|
||||
return config.type === 1
|
||||
? { ...config, flowType: 0, onlyAssign: false }
|
||||
: { ...config };
|
||||
}
|
||||
|
||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
||||
const numericValue = readDisplayNumber(metricValue);
|
||||
return numericValue !== null && numericValue >= threshold;
|
||||
});
|
||||
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
|
||||
const normalized = normalizeSpreadInfoConfig(config);
|
||||
return [
|
||||
normalized.type,
|
||||
normalized.onlyAssign ? 1 : 0,
|
||||
normalized.flowType,
|
||||
normalized.range
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function matchesSpreadMetricRule(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
rule: SpreadMetricFilterRule
|
||||
): boolean {
|
||||
const numericValue = readDisplayNumber(metrics[rule.metric]);
|
||||
return numericValue !== null && numericValue >= rule.threshold;
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
|
||||
+14
-15
@@ -14,24 +14,23 @@ export interface BackendMetrics {
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadMetricThresholds {
|
||||
averageCommentCount?: number;
|
||||
averageDuration?: number;
|
||||
averageLikeCount?: number;
|
||||
averageShareCount?: number;
|
||||
finishRate?: number;
|
||||
interactionRate?: number;
|
||||
playMedian?: number;
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
export type SpreadFilterMetric = "finishRate" | "interactionRate";
|
||||
|
||||
export interface SpreadMetricFilterRule {
|
||||
config: SpreadInfoConfig;
|
||||
metric: SpreadFilterMetric;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
config: {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
};
|
||||
thresholds: SpreadMetricThresholds;
|
||||
rules: SpreadMetricFilterRule[];
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createMarketResultStore } from "../src/content/market/result-store";
|
||||
import type { SpreadInfoConfig } from "../src/content/market/types";
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
|
||||
@@ -415,11 +416,11 @@ describe("market-content-entry", () => {
|
||||
const dataGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="data"]'
|
||||
) as HTMLElement | null;
|
||||
const videoGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="video"]'
|
||||
const metricSelector = document.querySelector(
|
||||
'[data-plugin-spread-metrics="root"]'
|
||||
) as HTMLElement | null;
|
||||
const thresholdGroup = document.querySelector(
|
||||
'[data-plugin-toolbar-group="thresholds"]'
|
||||
const rulesGroup = document.querySelector(
|
||||
'[data-plugin-spread-rules="root"]'
|
||||
) as HTMLElement | null;
|
||||
const statusText = document.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
@@ -433,12 +434,12 @@ describe("market-content-entry", () => {
|
||||
const operators = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
|
||||
).map((element) => element.textContent);
|
||||
const conjunctions = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold-conjunction]")
|
||||
).map((element) => element.textContent);
|
||||
const thresholdControls = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold-control]")
|
||||
);
|
||||
const metricInputs = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-metric]")
|
||||
) as HTMLInputElement[];
|
||||
const ruleRows = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-rule]")
|
||||
) as HTMLElement[];
|
||||
const thresholdInputs = Array.from(
|
||||
document.querySelectorAll("[data-plugin-spread-threshold]")
|
||||
) as HTMLInputElement[];
|
||||
@@ -450,22 +451,19 @@ describe("market-content-entry", () => {
|
||||
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
|
||||
expect(thresholdRow?.style.alignItems).toBe("center");
|
||||
expect(dataGroup?.parentElement).toBe(primaryRow);
|
||||
expect(videoGroup?.parentElement).toBe(primaryRow);
|
||||
expect(statusText?.parentElement).toBe(primaryRow);
|
||||
expect(thresholdGroup?.parentElement).toBe(thresholdRow);
|
||||
expect(thresholdGroup?.style.flexWrap).toBe("nowrap");
|
||||
expect(thresholdGroup?.style.overflowX).toBe("auto");
|
||||
expect(metricSelector?.parentElement).toBe(thresholdRow);
|
||||
expect(rulesGroup?.parentElement).toBe(thresholdRow);
|
||||
expect(rulesGroup?.style.flexDirection).toBe("column");
|
||||
expect(primaryRow?.style.justifyContent).toBe("flex-start");
|
||||
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
|
||||
expect(titles.map((element) => element.textContent)).toEqual([
|
||||
"视频口径",
|
||||
"传播指标筛选"
|
||||
]);
|
||||
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
|
||||
expect(titles[1]?.style.background).toBe("rgb(238, 245, 255)");
|
||||
expect(document.querySelector("[data-plugin-spread-threshold-rule]")).toBeNull();
|
||||
expect(operators).toEqual(["≥", "≥"]);
|
||||
expect(conjunctions).toEqual(["且"]);
|
||||
expect(metricInputs.map((input) => input.checked)).toEqual([false, false]);
|
||||
expect(ruleRows.map((row) => row.hidden)).toEqual([true, true]);
|
||||
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
|
||||
"",
|
||||
""
|
||||
@@ -474,10 +472,6 @@ describe("market-content-entry", () => {
|
||||
"0.1",
|
||||
"0.1"
|
||||
]);
|
||||
expect(thresholdControls.map((control) => control.textContent)).toEqual([
|
||||
"完播率≥%",
|
||||
"互动率≥%"
|
||||
]);
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
"导出CSV",
|
||||
"导出选中达人数据",
|
||||
@@ -1282,7 +1276,7 @@ describe("market-content-entry", () => {
|
||||
expect(customPagesInput?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => {
|
||||
test("toolbar exposes independent metric rules and applies personal-video constraints per rule", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
@@ -1297,31 +1291,148 @@ describe("market-content-entry", () => {
|
||||
|
||||
await controller.ready;
|
||||
|
||||
const videoTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const assignSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const flowTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const finishToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="finishRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
const interactionToggle = document.querySelector(
|
||||
'[data-plugin-spread-metric="interactionRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
const finishRule = document.querySelector(
|
||||
'[data-plugin-spread-rule="finishRate"]'
|
||||
) as HTMLElement | null;
|
||||
const interactionRule = document.querySelector(
|
||||
'[data-plugin-spread-rule="interactionRate"]'
|
||||
) as HTMLElement | null;
|
||||
const finishRateInput = document.querySelector(
|
||||
'[data-plugin-spread-threshold="finishRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(videoTypeSelect?.value).toBe("1");
|
||||
expect(assignSelect?.value).toBe("false");
|
||||
expect(assignSelect?.disabled).toBe(true);
|
||||
expect(flowTypeSelect?.value).toBe("0");
|
||||
expect(flowTypeSelect?.disabled).toBe(true);
|
||||
expect(finishToggle?.checked).toBe(false);
|
||||
expect(interactionToggle?.checked).toBe(false);
|
||||
expect(finishRule?.hidden).toBe(true);
|
||||
expect(interactionRule?.hidden).toBe(true);
|
||||
expect(finishRateInput?.placeholder).toBe("");
|
||||
|
||||
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
||||
dispatchChange('[data-plugin-spread-filter="type"]');
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("finishRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "type", "1");
|
||||
|
||||
expect(assignSelect?.disabled).toBe(false);
|
||||
expect(flowTypeSelect?.disabled).toBe(false);
|
||||
const finishAssignSelect = readSpreadRuleSelect(
|
||||
"finishRate",
|
||||
"onlyAssign"
|
||||
);
|
||||
const finishFlowTypeSelect = readSpreadRuleSelect(
|
||||
"finishRate",
|
||||
"flowType"
|
||||
);
|
||||
const interactionAssignSelect = readSpreadRuleSelect(
|
||||
"interactionRate",
|
||||
"onlyAssign"
|
||||
);
|
||||
const interactionFlowTypeSelect = readSpreadRuleSelect(
|
||||
"interactionRate",
|
||||
"flowType"
|
||||
);
|
||||
|
||||
expect(finishRule?.hidden).toBe(false);
|
||||
expect(interactionRule?.hidden).toBe(false);
|
||||
expect(finishAssignSelect.value).toBe("true");
|
||||
expect(finishAssignSelect.disabled).toBe(false);
|
||||
expect(finishFlowTypeSelect.value).toBe("1");
|
||||
expect(finishFlowTypeSelect.disabled).toBe(false);
|
||||
expect(interactionAssignSelect.value).toBe("false");
|
||||
expect(interactionAssignSelect.disabled).toBe(true);
|
||||
expect(interactionFlowTypeSelect.value).toBe("0");
|
||||
expect(interactionFlowTypeSelect.disabled).toBe(true);
|
||||
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
if (!finishToggle) {
|
||||
throw new Error("Missing finish-rate metric toggle");
|
||||
}
|
||||
finishToggle.checked = false;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
finishToggle.checked = true;
|
||||
dispatchChange('[data-plugin-spread-metric="finishRate"]');
|
||||
|
||||
expect(finishRateInput?.value).toBe("");
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]',
|
||||
"1"
|
||||
);
|
||||
expectSelectValue(
|
||||
'[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]',
|
||||
"2"
|
||||
);
|
||||
});
|
||||
|
||||
test("reads selected spread metrics as independent validated rules", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
await controller.ready;
|
||||
|
||||
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
|
||||
"../src/content/market/plugin-toolbar"
|
||||
);
|
||||
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
|
||||
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||
filter: { rules: [] }
|
||||
});
|
||||
|
||||
enableSpreadMetric("finishRate");
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||
error: "请输入有效的完播率筛选阈值"
|
||||
});
|
||||
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("finishRate", "flowType", "1");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
|
||||
expect(readToolbarSpreadFilter(toolbar)).toEqual({
|
||||
filter: {
|
||||
rules: [
|
||||
{
|
||||
config: {
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
},
|
||||
metric: "finishRate",
|
||||
threshold: 30
|
||||
},
|
||||
{
|
||||
config: {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
},
|
||||
metric: "interactionRate",
|
||||
threshold: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||
@@ -1437,7 +1548,7 @@ describe("market-content-entry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("export keeps only records that match spread threshold filters", async () => {
|
||||
test("export requires independent spread metric configs to all match", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
@@ -1459,9 +1570,79 @@ describe("market-content-entry", () => {
|
||||
}
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
||||
interactionRate: "5%"
|
||||
const loadSpreadFilterMetrics = vi.fn(async (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => {
|
||||
if (config.type === 2) {
|
||||
return { finishRate: "35%" };
|
||||
}
|
||||
return {
|
||||
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
|
||||
};
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
loadSpreadMetrics: async () => ({}),
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("finishRate", "onlyAssign", "true");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: true,
|
||||
range: 2,
|
||||
type: 2
|
||||
});
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"a"
|
||||
]);
|
||||
});
|
||||
|
||||
test("export reuses one spread snapshot when metric configs match", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-a",
|
||||
nickname: "Alpha"
|
||||
},
|
||||
star_id: "a"
|
||||
}
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const loadSpreadFilterMetrics = vi.fn(async () => ({
|
||||
finishRate: "35%",
|
||||
interactionRate: "6%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
@@ -1473,6 +1654,7 @@ describe("market-content-entry", () => {
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
loadSpreadMetrics: async () => ({}),
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
@@ -1480,10 +1662,14 @@ describe("market-content-entry", () => {
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
@@ -1495,6 +1681,58 @@ describe("market-content-entry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("export excludes missing-id and failed spread metric records without aborting", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
]);
|
||||
attachMarketListState([
|
||||
{ star_id: "a" },
|
||||
{
|
||||
attribute_datas: {
|
||||
id: "spread-b",
|
||||
nickname: "Beta"
|
||||
},
|
||||
star_id: "b"
|
||||
}
|
||||
]);
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const loadSpreadFilterMetrics = vi.fn(async () => {
|
||||
throw new Error("request failed");
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
loadSpreadMetrics: async () => ({}),
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
enableSpreadMetric("finishRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1);
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-b", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
expect(buildCsv.mock.calls[0][0]).toEqual([]);
|
||||
});
|
||||
|
||||
test(
|
||||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||||
async () => {
|
||||
@@ -2433,7 +2671,7 @@ describe("market-content-entry", () => {
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
});
|
||||
|
||||
test("batch submit keeps only records that match spread threshold filters", async () => {
|
||||
test("batch submit applies all independent spread metric rules", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixture([
|
||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
||||
@@ -2455,9 +2693,19 @@ describe("market-content-entry", () => {
|
||||
}
|
||||
]);
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
}));
|
||||
const loadSpreadFilterMetrics = vi.fn(async (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => {
|
||||
if (config.type === 2) {
|
||||
return {
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
};
|
||||
}
|
||||
return {
|
||||
interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%"
|
||||
};
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
@@ -2480,7 +2728,12 @@ describe("market-content-entry", () => {
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
enableSpreadMetric("finishRate");
|
||||
enableSpreadMetric("interactionRate");
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5");
|
||||
setSpreadRuleSelect("finishRate", "type", "2");
|
||||
setSpreadRuleSelect("interactionRate", "range", "3");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
@@ -5089,6 +5342,52 @@ function click(selector: string) {
|
||||
element.click();
|
||||
}
|
||||
|
||||
function enableSpreadMetric(metric: "finishRate" | "interactionRate") {
|
||||
const selector = `[data-plugin-spread-metric="${metric}"]`;
|
||||
const input = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!input) {
|
||||
throw new Error(`Missing spread metric toggle: ${metric}`);
|
||||
}
|
||||
|
||||
input.checked = true;
|
||||
dispatchChange(selector);
|
||||
}
|
||||
|
||||
function readSpreadRuleSelect(
|
||||
metric: "finishRate" | "interactionRate",
|
||||
field: "type" | "onlyAssign" | "flowType" | "range"
|
||||
): HTMLSelectElement {
|
||||
const selector =
|
||||
`[data-plugin-spread-rule="${metric}"] ` +
|
||||
`[data-plugin-spread-filter="${field}"]`;
|
||||
const select = document.querySelector(selector) as HTMLSelectElement | null;
|
||||
if (!select) {
|
||||
throw new Error(`Missing spread rule select: ${metric}.${field}`);
|
||||
}
|
||||
|
||||
return select;
|
||||
}
|
||||
|
||||
function setSpreadRuleSelect(
|
||||
metric: "finishRate" | "interactionRate",
|
||||
field: "type" | "onlyAssign" | "flowType" | "range",
|
||||
value: string
|
||||
) {
|
||||
const select = readSpreadRuleSelect(metric, field);
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
function createNoopToolbarHandlers() {
|
||||
return {
|
||||
onConfigureAudienceProfileFields: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExportAudienceProfile: vi.fn(),
|
||||
onExportAudienceProfileByIds: vi.fn(),
|
||||
onSubmitBatch: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function clickSelectionCheckboxForAuthor(authorId: string) {
|
||||
readSelectionCheckboxForAuthor(authorId).click();
|
||||
}
|
||||
|
||||
+73
-27
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
buildSpreadInfoColumns,
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
matchesSpreadThresholds,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
mapSpreadInfoResponse
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
@@ -167,46 +169,90 @@ describe("spread-info", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("matches thresholds using display values and requires every filled threshold", () => {
|
||||
test("normalizes fixed personal-video parameters before grouping", () => {
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
normalizeSpreadInfoConfig({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
).toEqual({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
});
|
||||
});
|
||||
|
||||
test("uses all normalized video dimensions in the config key", () => {
|
||||
expect(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 1,
|
||||
onlyAssign: true,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
).toBe(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 2
|
||||
})
|
||||
).not.toBe(
|
||||
buildSpreadInfoConfigKey({
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 2
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("matches only the metric named by one filter rule", () => {
|
||||
expect(
|
||||
matchesSpreadMetricRule(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
interactionRate: "4.02%"
|
||||
},
|
||||
{
|
||||
averageDuration: 50,
|
||||
finishRate: 28,
|
||||
interactionRate: 4,
|
||||
playMedian: 10000000
|
||||
config: {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
metric: "finishRate",
|
||||
threshold: 28
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 57
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
matchesSpreadMetricRule(
|
||||
{
|
||||
finishRate: "28.24%"
|
||||
},
|
||||
{
|
||||
finishRate: 20,
|
||||
interactionRate: 1
|
||||
config: {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
metric: "interactionRate",
|
||||
threshold: 1
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user