feat: honor all-range export and batch scope

This commit is contained in:
wxs
2026-07-27 16:00:57 +08:00
parent e15bc0657a
commit e03dedd91e
5 changed files with 293 additions and 47 deletions
+23 -24
View File
@@ -9,7 +9,7 @@
项目输入包括: 项目输入包括:
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果; - 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
- 使用者在页面上勾选的达人; - 使用者在页面上勾选的达人(可选)
- 使用者粘贴的达人星图 ID - 使用者粘贴的达人星图 ID
- 使用者填写的批次名称; - 使用者填写的批次名称;
- 使用者选择的导出字段和传播指标筛选规则; - 使用者选择的导出字段和传播指标筛选规则;
@@ -105,12 +105,12 @@
### 2.5 导出选中达人数据 ### 2.5 导出选中达人数据
- 触发者:使用者点击 `导出选中达人数据` - 触发者:使用者点击 `导出选中达人数据`
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 - 输入:可选的当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留范围内已勾选的达人,然后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。 - 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。
- 输出:CSV 文件下载到浏览器默认下载目录。 - 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 内容。 - 下一步:使用者检查 CSV 内容。
- 人工操作:需要使用者勾选达人并点击按钮 - 人工操作:选择 `全部` 时可直接点击按钮;选择其他范围时需要先勾选达人
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态``失败原因` 会标明部分成功及失败来源。 - 条件分支:存在勾选但全部范围内没有勾选达人时,不下载 CSV 并提示;单项补充失败不阻止下载,CSV 的 `导出状态``失败原因` 会标明部分成功及失败来源。
### 2.6 按星图 ID 导出 ### 2.6 按星图 ID 导出
@@ -126,7 +126,7 @@
- 触发者:使用者点击 `提交批次` - 触发者:使用者点击 `提交批次`
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。 - 输入:当前范围或已勾选达人、批次名称、登录用户信息。
- 处理:插件先要求输入批次名称,再收集达人数据应用传播指标规则和选中规则,检查登录状态,组装批次 payload,提交到后端。 - 处理:插件先要求输入批次名称,再收集达人数据应用传播指标规则。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。 - 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
- 下一步:在后端系统中继续处理批次。 - 下一步:在后端系统中继续处理批次。
- 人工操作:需要使用者输入批次名称。 - 人工操作:需要使用者输入批次名称。
@@ -211,11 +211,11 @@
### 3.6 导出选中达人数据 ### 3.6 导出选中达人数据
- 步骤目的:把使用者选定的达人数据导出为 CSV。 - 步骤目的:把使用者选定的达人数据导出为 CSV。
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 - 输入内容:可选的已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。
- 处理规则: - 处理规则:
- 必须先勾选达人 - 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页
- 先按导出范围收集达人; - 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人;
- 再严格保留当前导出范围内已勾选的达人 - 选择当前页、前 5 页、前 10 页或自定义页数且没有勾选时,提示并停止
- 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口; - 只补充已选字段对应的画像、效果预估、传播指标、看后搜率和秒思指标,未选数据类别不调用对应接口;
- 基础字段、导出状态和失败原因等固定保留; - 基础字段、导出状态和失败原因等固定保留;
- 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入; - 画像请求全部失败时仍下载 CSV,已成功的效果预估等字段正常写入;
@@ -223,8 +223,8 @@
- 输出结果:CSV 文件。 - 输出结果:CSV 文件。
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。 - 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
- 失败后如何处理: - 失败后如何处理:
- 没有勾选达人:提示并停止; - `全部` 范围没有勾选达人:提示并停止;
- 当前范围内无选中达人:提示并停止; - 存在勾选但全部范围内无选中达人:提示并停止;
- 单个达人部分失败:记录为部分成功或失败; - 单个达人部分失败:记录为部分成功或失败;
- 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。 - 画像失败:对应画像单元格留空,失败来源写入 `失败原因`,其他成功数据仍保留并下载。
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。 - 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
@@ -277,9 +277,9 @@
- 点击后先输入批次名称; - 点击后先输入批次名称;
- 取消输入则停止; - 取消输入则停止;
- 批次名为空则提示并停止; - 批次名为空则提示并停止;
- 如果存在已勾选达人,则优先提交当前范围内已勾选达人 - 选择 `全部` 且没有勾选达人时,从第 1 页提交当前筛选结果的全部分页
- 如果没有勾选达人,则提交当前范围内所有达人; - 选择 `全部` 且存在已勾选达人时,严格提交全部范围内已勾选达人;
- 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人; - 选择 `全部` 时,如果已勾选达人不在结果内,则提示并停止,不会回退为提交全部达人;
- 前端不生成批次 ID,批次 ID 由后端生成; - 前端不生成批次 ID,批次 ID 由后端生成;
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表; - payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。 - 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
@@ -356,10 +356,9 @@
### 5.3 过滤规则 ### 5.3 过滤规则
- 星图页面中缺少达人 ID 或达人名称的行会跳过; - 星图页面中缺少达人 ID 或达人名称的行会跳过;
- 导出选中达人数据必须有已勾选达人; - 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人;
- 画像导出只保留当前导出范围内已勾选达人; - 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人;
- 提交批次如果存在已选达人,会优先保留当前范围内的已选达人; - 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人;
- 如果当前范围内没有任何已选达人,提交批次会回退为当前范围全部达人;
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤; - 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。 - 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
@@ -485,7 +484,7 @@
1. 在星图市场中完成筛选; 1. 在星图市场中完成筛选;
2. 等待页面列表加载完成; 2. 等待页面列表加载完成;
3. 勾选需要导出的达人; 3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人;
4. 可选:点击 `选择字段` 调整 CSV 字段; 4. 可选:点击 `选择字段` 调整 CSV 字段;
5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径; 5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径;
6. 点击 `导出选中达人数据` 6. 点击 `导出选中达人数据`
@@ -589,7 +588,7 @@
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 | | 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 |
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 | | 后端指标服务地址 | 查询秒思 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 | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 | | COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 | | 导出范围 | 决定收集和最终处理哪些页面达人 | 当前工具栏默认值为前 5 页;选择 `全部` 且未勾选时处理当前筛选结果全量,存在勾选时勾选优先 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
| 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 | | 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 |
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 | | 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
@@ -630,8 +629,8 @@
### 9.5 失败表现 ### 9.5 失败表现
- 未登录:星图页面显示登录提示,不出现业务工具栏; - 未登录:星图页面显示登录提示,不出现业务工具栏;
- 没勾选就导出选中达人数据:提示请先勾选; - `全部` 范围没勾选就导出选中达人数据:提示请先勾选;
- 当前范围无选中达人:提示当前导出范围内没有选中的达人; - 选择 `全部` 且勾选达人不在当前筛选结果内:提示全部范围内没有选中的达人;
- 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因; - 全部已选补充数据都失败:CSV 仍会下载,该行标记为部分成功或失败并列明失败原因;
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID; - 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
- 批次提交失败:状态区显示接口错误或通用失败提示; - 批次提交失败:状态区显示接口错误或通用失败提示;
@@ -699,7 +698,7 @@
- Drone 发布是否为唯一正式发布方式:未确认。 - Drone 发布是否为唯一正式发布方式:未确认。
- 插件是否有统一日志、错误上报或审计记录:未确认。 - 插件是否有统一日志、错误上报或审计记录:未确认。
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。 - 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。 - 导出全部页面时最多导出多少页:后台静默导出从第 1 页开始,当前最多尝试 200 页;真实星图侧上限未确认。
- 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。 - 传播指标请求是否应该限制并发:当前筛选已按同一达人相同口径去重,但真实业务级并发上限仍未确认。
- 后续业务系统如何消费批次:未确认。 - 后续业务系统如何消费批次:未确认。
+56 -20
View File
@@ -339,16 +339,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const toolbarHandlers = { const toolbarHandlers = {
onExportAudienceProfile: async () => { onExportAudienceProfile: async () => {
syncSelectionStateFromDom(); syncSelectionStateFromDom();
if (selectedAuthorIds.size === 0) {
setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人");
return;
}
const exportTarget = readToolbarExportTarget(toolbar); const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) { if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return; return;
} }
if (selectedAuthorIds.size === 0 && exportTarget.target.mode !== "all") {
setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人");
return;
}
setToolbarBusyState(toolbar, true); setToolbarBusyState(toolbar, true);
try { try {
@@ -356,16 +355,27 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const exportSelection = resolveAudienceProfileExportSelection( const exportSelection = resolveAudienceProfileExportSelection(
selectedHeaders selectedHeaders
); );
const selectedRecords = filterRecordsBySelectionStrict( const resolvedRecords = await exportRecords(
await exportRecords(exportTarget.target, "画像导出中", { exportTarget.target,
"画像导出中",
{
includeBackendMetrics: exportSelection.includeBackendMetrics, includeBackendMetrics: exportSelection.includeBackendMetrics,
includeRates: exportSelection.includeRates, includeRates: exportSelection.includeRates,
showDetailedProgress: false, showDetailedProgress: false,
spreadInfoConfigs: exportSelection.spreadInfoConfigs spreadInfoConfigs: exportSelection.spreadInfoConfigs
}) }
);
const selectedRecords = filterRecordsForAudienceProfileExport(
resolvedRecords,
exportTarget.target
); );
if (selectedRecords.length === 0) { if (selectedRecords.length === 0) {
setToolbarExportStatus(toolbar, "当前导出范围内没有选中的达人"); setToolbarExportStatus(
toolbar,
exportTarget.target.mode === "all" && selectedAuthorIds.size > 0
? "全部范围内没有选中的达人"
: "当前导出范围内没有选中的达人"
);
return; return;
} }
@@ -510,18 +520,29 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, true); setToolbarBusyState(toolbar, true);
try { try {
const hasSelectedAuthors = selectedAuthorIds.size > 0; const hasSelectedAuthors = selectedAuthorIds.size > 0;
const records = filterRecordsBySelection( const resolvedRecords = await applySpreadThresholdFilter(
await applySpreadThresholdFilter( await exportRecords(
await exportRecords( exportTarget.target,
exportTarget.target, hasSelectedAuthors ? "提交已选达人中" : "提交中",
hasSelectedAuthors ? "提交已选达人中" : "提交中", {
{ showDetailedProgress: !hasSelectedAuthors
showDetailedProgress: !hasSelectedAuthors }
} ),
), spreadFilter.filter
spreadFilter.filter
)
); );
const records =
exportTarget.target.mode === "all"
? filterRecordsForAllRange(resolvedRecords)
: filterRecordsBySelection(resolvedRecords);
if (records.length === 0) {
setToolbarExportStatus(
toolbar,
exportTarget.target.mode === "all" && hasSelectedAuthors
? "全部范围内没有选中的达人"
: "当前范围内没有可提交的达人"
);
return;
}
const authState = await getAuthState(); const authState = await getAuthState();
if (!authState.isAuthenticated) { if (!authState.isAuthenticated) {
throw new Error("请先登录插件"); throw new Error("请先登录插件");
@@ -1168,6 +1189,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return records.filter((record) => selectedAuthorIds.has(record.authorId)); return records.filter((record) => selectedAuthorIds.has(record.authorId));
} }
function filterRecordsForAllRange(records: MarketRecord[]): MarketRecord[] {
return selectedAuthorIds.size === 0
? records
: filterRecordsBySelectionStrict(records);
}
function filterRecordsForAudienceProfileExport(
records: MarketRecord[],
target: MarketExportTarget
): MarketRecord[] {
return target.mode === "all"
? filterRecordsForAllRange(records)
: filterRecordsBySelectionStrict(records);
}
async function loadAudienceProfileSet( async function loadAudienceProfileSet(
record: MarketRecord, record: MarketRecord,
exportSelection: AudienceProfileExportSelection exportSelection: AudienceProfileExportSelection
@@ -51,10 +51,11 @@ export function createSilentExportController(
const mergedRecords = new Map<string, MarketRecord>(); const mergedRecords = new Map<string, MarketRecord>();
const maxPageCount = target.mode === "count" ? target.pageCount : 200; const maxPageCount = target.mode === "count" ? target.pageCount : 200;
const initialPage = target.mode === "all" ? 1 : baseRequest.initialPage;
let totalPagesHint: number | undefined; let totalPagesHint: number | undefined;
for (let offset = 0; offset < maxPageCount; offset += 1) { for (let offset = 0; offset < maxPageCount; offset += 1) {
const pageNumber = baseRequest.initialPage + offset; const pageNumber = initialPage + offset;
options.onProgress?.({ options.onProgress?.({
currentPage: offset + 1, currentPage: offset + 1,
totalPages: target.mode === "count" ? target.pageCount : totalPagesHint totalPages: target.mode === "count" ? target.pageCount : totalPagesHint
+162 -2
View File
@@ -1791,7 +1791,7 @@ describe("market-content-entry", () => {
}); });
}); });
test("audience profile export requires selected creators", async () => { test("audience profile export requires selected creators outside of the all range", async () => {
document.body.innerHTML = buildRealMarketFixture([ document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]); ]);
@@ -1825,6 +1825,109 @@ describe("market-content-entry", () => {
).toContain("请先勾选需要导出数据的达人"); ).toContain("请先勾选需要导出数据的达人");
}); });
test("audience profile export includes all filtered creators when all is selected without checks", async () => {
const records = [
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
];
document.body.innerHTML = buildRealMarketFixture(records);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(records),
totalPages: 1
}
}),
ok: true
}));
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile: async () => ({ status: "success" as const }),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(
buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId)
).toEqual(["111", "222"]);
});
test("audience profile export gives checked creators priority in the all range", async () => {
const records = [
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
];
document.body.innerHTML = buildRealMarketFixture(records);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows(records),
totalPages: 1
}
}),
ok: true
}));
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile: async () => ({ status: "success" as const }),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(
buildAudienceProfileCsv.mock.calls[0]?.[0].map(({ record }) => record.authorId)
).toEqual(["222"]);
});
test("audience profile export loads profiles only for selected creators", async () => { test("audience profile export loads profiles only for selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([ document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
@@ -2967,8 +3070,62 @@ describe("market-content-entry", () => {
); );
}); });
test("all-range batch submit does not fall back when checked creators are outside the results", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({ page: 1 }),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
(
globalThis as typeof globalThis & { fetch?: typeof fetch }
).fetch = vi.fn(async () => ({
json: async () => ({
data: {
marketList: buildMarketListResponseRows([
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]),
totalPages: 1
}
}),
ok: true
}));
const submitBatch = vi.fn(async () => ({ ok: true }));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
promptBatchName: vi.fn(() => "自动选择批次"),
submitBatch,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]');
await waitForCondition(() =>
document.querySelector('[data-plugin-export-status="text"]')?.textContent ===
"全部范围内没有选中的达人"
);
expect(submitBatch).not.toHaveBeenCalled();
});
test( test(
"default paged batch submit keeps detailed progress when no creators are selected", "all-range batch submit keeps detailed progress when no creators are selected",
async () => { async () => {
const pages = [ const pages = [
[ [
@@ -3042,6 +3199,9 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter(); removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
for (let attempt = 0; attempt < 40; attempt += 1) { for (let attempt = 0; attempt < 40; attempt += 1) {
+50
View File
@@ -155,6 +155,56 @@ describe("silent-export-controller", () => {
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]); expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
}); });
test("starts all-result exports from page 1 even after browsing to a later page", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 2
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number };
};
const pageNo = body.page_param?.page ?? 0;
requestedPages.push(pageNo);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`
},
star_id: String(pageNo)
}
],
pagination: {
page: pageNo,
totalPages: 3
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({ mode: "all" });
expect(requestedPages).toEqual([1, 2, 3]);
expect(records?.map((record) => record.authorId)).toEqual(["1", "2", "3"]);
});
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => { test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
document.documentElement.setAttribute( document.documentElement.setAttribute(
"data-sces-market-request-snapshot", "data-sces-market-request-snapshot",