feat: streamline export field selection

This commit is contained in:
wxs
2026-07-29 10:51:24 +08:00
parent 53db9b0777
commit d121849d3e
12 changed files with 1157 additions and 1700 deletions
+24 -49
View File
@@ -12,14 +12,14 @@
- 使用者在页面上勾选的达人(可选); - 使用者在页面上勾选的达人(可选);
- 使用者粘贴的达人星图 ID - 使用者粘贴的达人星图 ID
- 使用者填写的批次名称; - 使用者填写的批次名称;
- 使用者选择的导出字段和传播指标筛选规则 - 使用者选择的导出字段;
- 当前插件登录用户的 Logto 身份和访问令牌。 - 当前插件登录用户的 Logto 身份和访问令牌。
项目处理过程包括: 项目处理过程包括:
- 在星图达人市场页面挂载插件工具栏; - 在星图达人市场页面挂载插件工具栏;
- 读取当前页面或星图列表接口返回的达人数据; - 读取当前页面或星图列表接口返回的达人数据;
- 根据勾选范围分页范围、传播指标规则确定最终达人集合; - 根据勾选范围分页范围确定最终达人集合;
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息; - 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
- 调用公司后端接口补充秒思 api 指标; - 调用公司后端接口补充秒思 api 指标;
- 生成 CSV 文件,或组装批次 payload 提交到后端。 - 生成 CSV 文件,或组装批次 payload 提交到后端。
@@ -55,9 +55,9 @@
3. 打开巨量星图达人市场页面; 3. 打开巨量星图达人市场页面;
4. 插件读取登录状态并挂载工具栏; 4. 插件读取登录状态并挂载工具栏;
5. 插件读取当前星图达人列表,并补充页面展示指标; 5. 插件读取当前星图达人列表,并补充页面展示指标;
6. 使用者选择达人、字段、传播指标筛选规则或输入星图 ID 6. 使用者选择达人、检索并勾选导出字段,或输入星图 ID
7. 使用者触发导出或提交批次; 7. 使用者触发导出或提交批次;
8. 插件收集达人数据,按规则过滤、去重补充字段; 8. 插件收集达人数据,去重补充已选字段;
9. 插件调用星图接口和公司后端接口补充数据; 9. 插件调用星图接口和公司后端接口补充数据;
10. 插件下载 CSV,或把批次提交给后端; 10. 插件下载 CSV,或把批次提交给后端;
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。 11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
@@ -105,7 +105,7 @@
### 2.5 导出选中达人数据 ### 2.5 导出选中达人数据
- 触发者:使用者点击 `导出选中达人数据` - 触发者:使用者点击 `导出选中达人数据`
- 输入:可选的当前勾选达人、当前导出范围字段选择配置、传播指标筛选规则 - 输入:可选的当前勾选达人、当前导出范围字段选择配置。
- 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。 - 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。
- 输出:CSV 文件下载到浏览器默认下载目录。 - 输出:CSV 文件下载到浏览器默认下载目录。
- 下一步:使用者检查 CSV 内容。 - 下一步:使用者检查 CSV 内容。
@@ -126,7 +126,7 @@
- 触发者:使用者点击 `提交批次` - 触发者:使用者点击 `提交批次`
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。 - 输入:当前范围或已勾选达人、批次名称、登录用户信息。
- 处理:插件先要求输入批次名称,再收集达人数据并应用传播指标规则。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。 - 处理:插件先要求输入批次名称,再按当前范围收集达人数据。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。 - 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
- 下一步:在后端系统中继续处理批次。 - 下一步:在后端系统中继续处理批次。
- 人工操作:需要使用者输入批次名称。 - 人工操作:需要使用者输入批次名称。
@@ -211,7 +211,7 @@
### 3.6 导出选中达人数据 ### 3.6 导出选中达人数据
- 步骤目的:把使用者选定的达人数据导出为 CSV。 - 步骤目的:把使用者选定的达人数据导出为 CSV。
- 输入内容:可选的已勾选达人、当前导出范围字段选择配置、传播指标筛选规则 - 输入内容:可选的已勾选达人、当前导出范围字段选择配置。
- 处理规则: - 处理规则:
- 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页; - 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页;
- 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人; - 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人;
@@ -248,31 +248,10 @@
- 整体流程异常:提示按 ID 导出失败。 - 整体流程异常:提示按 ID 导出失败。
- 是否影响后续步骤:不写入外部系统,不影响批次。 - 是否影响后续步骤:不写入外部系统,不影响批次。
### 3.8 传播指标规则筛选 ### 3.8 批次提交
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。
- 处理规则:
- 默认不选择任何指标,不启用二次筛选;
- 选择指标后必须填写该指标阈值,并为该指标选择一套独立视频口径;
- 同时选择完播率和互动率时,两条规则必须全部满足;
- 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求;
- 每条个人视频规则固定为不限指派、不排除营销流量;
- 每条星图视频规则可独立选择只看指派、不限指派、排除营销流量或不排除营销流量;
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`
- 请求失败、缺少传播指标请求 ID 或缺少已选指标的达人视为不满足筛选。
- 输出结果:过滤后的达人集合。
- 外部依赖:星图传播指标接口。
- 失败后如何处理:
- 已选指标阈值为空或非法:阻止导出或提交,并提示具体指标;
- 单个达人筛选请求失败:跳过该达人;
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
### 3.9 批次提交
- 步骤目的:把达人集合提交给后续业务系统。 - 步骤目的:把达人集合提交给后续业务系统。
- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称登录用户信息。 - 输入内容:导出范围内达人、已选达人、批次名称登录用户信息。
- 处理规则: - 处理规则:
- 点击后先输入批次名称; - 点击后先输入批次名称;
- 取消输入则停止; - 取消输入则停止;
@@ -293,7 +272,7 @@
- 网络失败:提交失败。 - 网络失败:提交失败。
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。 - 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
### 3.10 CSV 下载 ### 3.9 CSV 下载
- 步骤目的:把导出结果交付给使用者。 - 步骤目的:把导出结果交付给使用者。
- 输入内容:生成好的 CSV 字符串和文件名。 - 输入内容:生成好的 CSV 字符串和文件名。
@@ -320,7 +299,7 @@
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id``platform_source=1``author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 粉丝画像和铁粉画像分别按是否选择对应字段决定是否调用;同一达人已选画像请求串行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,未选择的画像不请求也不记失败 | | `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_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_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 | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 ID 或缺少已选指标时该达人不满足筛选 | | `get_author_spread_info` | 获取内容传播指标 | 内容数据导出 | `o_author_id``platform_source=1``platform_channel=1``type``flow_type``only_assign``range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 只请求已勾选内容字段所属的参数组合;同一参数组合下勾选多个字段仍只请求一次;单个达人的多组已选参数串行,达人之间并发 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时对应字段留空并记录失败来源 |
| 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 | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 | | 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 或非成功响应会终止本次提交 | | 批次提交后端 `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 不可用时不进入业务流程,或后端调用失败 | | Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
@@ -344,14 +323,15 @@
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段; - 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
- 星图详情类接口:看后搜率、画像、商业能力、传播指标; - 星图详情类接口:看后搜率、画像、商业能力、传播指标;
- 公司 talent-search 后端:秒思 api 指标; - 公司 talent-search 后端:秒思 api 指标;
- 使用者输入:勾选状态、星图 ID、批次名称字段选择和传播指标筛选规则 - 使用者输入:勾选状态、星图 ID、批次名称字段选择。
### 5.2 保留规则 ### 5.2 保留规则
- 有有效达人 ID 和达人名称的记录会进入页面记录集合; - 有有效达人 ID 和达人名称的记录会进入页面记录集合;
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行; - 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
- CSV 基础字段、导出状态、失败原因等固定字段会保留; - CSV 基础字段、导出状态、失败原因等固定字段会保留;
- 字段选择只影响可选数据字段,不删除固定标识字段 - 字段选择只影响可选数据字段,不删除固定标识字段
- 字段弹窗左侧持续展示已选字段,右侧支持按字段名或分类检索,并实时同步勾选状态。
### 5.3 过滤规则 ### 5.3 过滤规则
@@ -359,7 +339,6 @@
- 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人; - 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人;
- 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人; - 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人;
- 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人; - 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人;
- 传播指标规则启用后,不满足全部已选规则的达人会被过滤;
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。 - 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
### 5.4 去重规则 ### 5.4 去重规则
@@ -422,7 +401,6 @@
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。 - 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。 - 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。 - 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
- 传播指标规则没有持久状态,重新执行时按当前页面选择和输入值重新判断。
重复执行相对安全的操作: 重复执行相对安全的操作:
@@ -485,12 +463,11 @@
1. 在星图市场中完成筛选; 1. 在星图市场中完成筛选;
2. 等待页面列表加载完成; 2. 等待页面列表加载完成;
3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人; 3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人;
4. 可选:点击 `选择字段` 调整 CSV 字段; 4. 可选:点击 `选择字段`,通过关键词检索字段,并在已选区域确认最终字段;
5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径 5. 点击 `导出选中达人数据`
6. 点击 `导出选中达人数据` 6. 等待状态提示从导出中消失或浏览器下载完成
7. 等待状态提示从导出中消失或浏览器下载完成 7. 在下载目录或 Chrome 下载列表中查看 CSV
8. 在下载目录或 Chrome 下载列表中查看 CSV; 8. 检查 `导出状态``失败原因` 字段。
9. 检查 `导出状态``失败原因` 字段。
### 7.4 按 ID 执行导出任务 ### 7.4 按 ID 执行导出任务
@@ -505,11 +482,10 @@
1. 在星图市场中完成筛选; 1. 在星图市场中完成筛选;
2. 可选:勾选需要提交的达人; 2. 可选:勾选需要提交的达人;
3. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径 3. 点击 `提交批次`
4. 点击 `提交批次` 4. 输入批次名称
5. 输入批次名称 5. 等待页面提示 `批次提交成功`
6. 等待页面提示 `批次提交成功` 6. 到后端系统确认批次是否生成。
7. 到后端系统确认批次是否生成。
### 7.6 只执行某个子流程 ### 7.6 只执行某个子流程
@@ -589,8 +565,7 @@
| 后端指标服务地址 | 查询秒思 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 列 | 不需要重启,会本地保存 | 漏导业务字段 |
## 9. 任务执行和结果确认 ## 9. 任务执行和结果确认
@@ -127,20 +127,6 @@ export function listAudienceProfileCsvHeaders(
]; ];
} }
export function buildAudienceProfileFieldValues(
row: AudienceProfileExportRow
): Record<string, string> {
const columns = deduplicateCsvColumns([
...buildMarketCsvColumns([row.record]).map(toMarketColumn),
...buildBusinessEstimateColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
]);
return Object.fromEntries(
columns.map((column) => [column.header, column.readValue(row)])
);
}
export function listAudienceProfileSelectableFieldGroups( export function listAudienceProfileSelectableFieldGroups(
marketListHeaders: string[] = [] marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] { ): AudienceProfileCsvFieldGroup[] {
@@ -1,294 +1,729 @@
import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv"; import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv";
interface ActiveFieldDialog {
focus(): void;
promise: Promise<string[] | null>;
}
interface SelectableField {
group: string;
header: string;
}
interface GroupControl {
headers: string[];
input: HTMLInputElement;
root: HTMLElement;
}
const FIELD_DIALOG_STYLE_ID = "sces-audience-profile-field-dialog-style";
const activeDialogs = new WeakMap<Document, ActiveFieldDialog>();
export function promptForAudienceProfileFields( export function promptForAudienceProfileFields(
document: Document, document: Document,
groups: AudienceProfileCsvFieldGroup[], groups: AudienceProfileCsvFieldGroup[],
selectedHeaders: string[] | undefined selectedHeaders: string[] | undefined
): Promise<string[] | null> { ): Promise<string[] | null> {
return new Promise((resolve) => { const activeDialog = activeDialogs.get(document);
const selectableHeaders = groups.flatMap((group) => group.headers); if (activeDialog) {
const selectedHeaderSet = new Set( activeDialog.focus();
selectedHeaders === undefined return activeDialog.promise;
? selectableHeaders }
: selectedHeaders.filter((header) => selectableHeaders.includes(header))
);
const overlay = document.createElement("div"); ensureResponsiveStyles(document);
overlay.dataset.audienceProfileFieldDialog = "overlay"; const fields = listSelectableFields(groups);
applyOverlayStyles(overlay); const selectableHeaderSet = new Set(fields.map((field) => field.header));
const selectedHeaderSet = new Set(
selectedHeaders === undefined
? fields.map((field) => field.header)
: selectedHeaders.filter((header) => selectableHeaderSet.has(header))
);
const dialog = document.createElement("section"); let resolveDialog: (value: string[] | null) => void = () => undefined;
applyDialogStyles(dialog); const promise = new Promise<string[] | null>((resolve) => {
resolveDialog = resolve;
const title = document.createElement("h2");
applyTitleStyles(title);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
applyHintStyles(hint);
const toolbar = document.createElement("div");
applyToolbarStyles(toolbar);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applySecondaryButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applySecondaryButtonStyles(resetButton);
toolbar.append(selectAllButton, resetButton);
const groupContainer = document.createElement("div");
applyGroupContainerStyles(groupContainer);
const fieldInputs: HTMLInputElement[] = [];
groups.forEach((group) => {
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
const groupFieldInputs = group.headers.map((header) => {
const fieldLabel = document.createElement("label");
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
input.checked = selectedHeaderSet.has(header);
const text = document.createElement("span");
text.textContent = header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
fieldInputs.push(input);
return input;
});
const syncGroupInput = () => {
const checkedCount = groupFieldInputs.filter((input) => input.checked).length;
groupInput.checked = checkedCount === groupFieldInputs.length;
groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length;
};
groupInput.addEventListener("change", () => {
groupFieldInputs.forEach((input) => {
input.checked = groupInput.checked;
});
syncTitle();
});
groupFieldInputs.forEach((input) => {
input.addEventListener("change", () => {
syncGroupInput();
syncTitle();
});
});
syncGroupInput();
groupSection.append(groupHeader, fieldList);
groupContainer.append(groupSection);
});
const actions = document.createElement("div");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(title, hint, toolbar, groupContainer, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function syncTitle() {
const checkedCount = fieldInputs.filter((input) => input.checked).length;
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
}
function close(value: string[] | null) {
overlay.remove();
resolve(value);
}
selectAllButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
resetButton.addEventListener("click", () => {
fieldInputs.forEach((input) => {
input.checked = true;
});
syncTitle();
syncAllGroupInputs(dialog);
});
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
const nextHeaders = fieldInputs
.filter((input) => input.checked)
.map((input) => input.value);
close(nextHeaders);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
syncTitle();
}); });
const overlay = document.createElement("div");
overlay.dataset.audienceProfileFieldDialog = "overlay";
applyOverlayStyles(overlay);
const dialog = document.createElement("section");
dialog.dataset.audienceProfileFieldDialog = "dialog";
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("role", "dialog");
applyDialogStyles(dialog);
const header = document.createElement("header");
applyDialogHeaderStyles(header);
const headingGroup = document.createElement("div");
const title = document.createElement("h2");
title.id = "sces-audience-profile-field-dialog-title";
title.textContent = "选择导出字段";
applyTitleStyles(title);
dialog.setAttribute("aria-labelledby", title.id);
const hint = document.createElement("p");
hint.textContent = "基础字段会固定保留。字段设置会用于本次及后续 CSV 导出。";
applyHintStyles(hint);
headingGroup.append(title, hint);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.dataset.audienceProfileFieldDialogClose = "button";
closeButton.textContent = "×";
closeButton.title = "关闭";
closeButton.setAttribute("aria-label", "关闭字段选择");
applyCloseButtonStyles(closeButton);
header.append(headingGroup, closeButton);
const content = document.createElement("div");
content.dataset.audienceProfileFieldDialogContent = "root";
applyContentStyles(content);
const selectedPane = document.createElement("section");
selectedPane.dataset.audienceProfileFieldDialogPane = "selected";
applyPaneStyles(selectedPane);
const selectedHeader = document.createElement("div");
applyPaneHeaderStyles(selectedHeader);
const selectedTitle = document.createElement("h3");
selectedTitle.textContent = "已选字段";
applyPaneTitleStyles(selectedTitle);
const selectedCount = document.createElement("span");
selectedCount.dataset.audienceProfileFieldDialogSelectedCount = "text";
selectedCount.setAttribute("aria-live", "polite");
applyCountStyles(selectedCount);
selectedHeader.append(selectedTitle, selectedCount);
const selectionActions = document.createElement("div");
applySelectionActionsStyles(selectionActions);
const selectAllButton = document.createElement("button");
selectAllButton.type = "button";
selectAllButton.textContent = "全选";
applyTextButtonStyles(selectAllButton);
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "恢复默认";
applyTextButtonStyles(resetButton);
selectionActions.append(selectAllButton, resetButton);
const selectedList = document.createElement("div");
selectedList.dataset.audienceProfileFieldDialogSelectedList = "root";
applySelectedListStyles(selectedList);
const selectedEmptyState = document.createElement("p");
selectedEmptyState.dataset.audienceProfileFieldDialogSelectedEmpty = "text";
selectedEmptyState.textContent = "暂未选择字段";
applyEmptyStateStyles(selectedEmptyState);
selectedPane.append(
selectedHeader,
selectionActions,
selectedList,
selectedEmptyState
);
const resultPane = document.createElement("section");
resultPane.dataset.audienceProfileFieldDialogPane = "results";
applyPaneStyles(resultPane);
const resultHeader = document.createElement("div");
applyPaneHeaderStyles(resultHeader);
const resultTitle = document.createElement("h3");
resultTitle.textContent = "字段列表";
applyPaneTitleStyles(resultTitle);
const resultCount = document.createElement("span");
resultCount.dataset.audienceProfileFieldDialogResultCount = "text";
resultCount.setAttribute("aria-live", "polite");
applyCountStyles(resultCount);
resultHeader.append(resultTitle, resultCount);
const searchInput = document.createElement("input");
searchInput.type = "search";
searchInput.placeholder = "搜索字段名称或分类";
searchInput.dataset.audienceProfileFieldDialogSearch = "input";
searchInput.setAttribute("aria-label", "搜索导出字段");
searchInput.autocomplete = "off";
applySearchInputStyles(searchInput);
const resultList = document.createElement("div");
resultList.dataset.audienceProfileFieldDialogResults = "root";
applyResultListStyles(resultList);
const resultEmptyState = document.createElement("p");
resultEmptyState.dataset.audienceProfileFieldDialogResultEmpty = "text";
resultEmptyState.textContent = "没有匹配的字段";
resultEmptyState.hidden = true;
applyEmptyStateStyles(resultEmptyState);
const resultInputs = new Map<string, HTMLInputElement>();
const resultItems = new Map<string, HTMLElement>();
const groupControls: GroupControl[] = [];
groups.forEach((group) => {
const groupFields = fields.filter((field) => field.group === group.label);
if (groupFields.length === 0) return;
const groupSection = document.createElement("section");
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
groupSection.dataset.audienceProfileFieldDialogGroupLabel = group.label;
applyGroupSectionStyles(groupSection);
const groupHeader = document.createElement("label");
applyGroupHeaderStyles(groupHeader);
const groupInput = document.createElement("input");
groupInput.type = "checkbox";
groupInput.dataset.audienceProfileFieldDialogGroupToggle = group.label;
applyCheckboxStyles(groupInput);
const groupTitle = document.createElement("span");
groupTitle.textContent = group.label;
groupHeader.append(groupInput, groupTitle);
const fieldList = document.createElement("div");
applyFieldListStyles(fieldList);
groupFields.forEach((field) => {
const fieldLabel = document.createElement("label");
fieldLabel.dataset.audienceProfileFieldDialogResult = field.header;
fieldLabel.dataset.audienceProfileFieldDialogResultGroup = field.group;
applyFieldLabelStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.value = field.header;
input.dataset.audienceProfileFieldDialogField = "checkbox";
applyCheckboxStyles(input);
const text = document.createElement("span");
text.textContent = field.header;
fieldLabel.append(input, text);
fieldList.append(fieldLabel);
resultInputs.set(field.header, input);
resultItems.set(field.header, fieldLabel);
input.addEventListener("change", () => {
setFieldSelected(field.header, input.checked);
});
});
const headers = groupFields.map((field) => field.header);
groupInput.addEventListener("change", () => {
headers.forEach((fieldHeader) => {
if (groupInput.checked) selectedHeaderSet.add(fieldHeader);
else selectedHeaderSet.delete(fieldHeader);
});
syncSelectionView();
});
groupControls.push({ headers, input: groupInput, root: groupSection });
groupSection.append(groupHeader, fieldList);
resultList.append(groupSection);
});
resultPane.append(
resultHeader,
searchInput,
resultList,
resultEmptyState
);
content.append(selectedPane, resultPane);
const actions = document.createElement("footer");
applyActionsStyles(actions);
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.textContent = "取消";
applySecondaryButtonStyles(cancelButton);
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
confirmButton.textContent = "保存";
applyPrimaryButtonStyles(confirmButton);
actions.append(cancelButton, confirmButton);
dialog.append(header, content, actions);
overlay.append(dialog);
document.body.appendChild(overlay);
function setFieldSelected(fieldHeader: string, isSelected: boolean): void {
if (isSelected) selectedHeaderSet.add(fieldHeader);
else selectedHeaderSet.delete(fieldHeader);
syncSelectionView();
}
function syncSelectionView(): void {
resultInputs.forEach((input, fieldHeader) => {
input.checked = selectedHeaderSet.has(fieldHeader);
});
groupControls.forEach(({ headers, input }) => {
const checkedCount = headers.filter((fieldHeader) =>
selectedHeaderSet.has(fieldHeader)
).length;
input.checked = checkedCount === headers.length;
input.indeterminate = checkedCount > 0 && checkedCount < headers.length;
});
selectedList.replaceChildren();
fields.forEach((field) => {
if (!selectedHeaderSet.has(field.header)) return;
const fieldLabel = document.createElement("label");
fieldLabel.dataset.audienceProfileFieldDialogSelectedField = field.header;
applySelectedFieldStyles(fieldLabel);
const input = document.createElement("input");
input.type = "checkbox";
input.checked = true;
input.value = field.header;
input.dataset.audienceProfileFieldDialogSelectedCheckbox = "checkbox";
applyCheckboxStyles(input);
const textGroup = document.createElement("span");
applySelectedFieldTextStyles(textGroup);
const fieldName = document.createElement("span");
fieldName.textContent = field.header;
const groupName = document.createElement("small");
groupName.textContent = field.group;
applySelectedFieldGroupStyles(groupName);
textGroup.append(fieldName, groupName);
fieldLabel.append(input, textGroup);
input.addEventListener("change", () => {
setFieldSelected(field.header, input.checked);
});
selectedList.append(fieldLabel);
});
selectedCount.textContent = `${selectedHeaderSet.size}`;
setElementVisible(
selectedEmptyState,
selectedHeaderSet.size === 0,
"block"
);
}
function syncSearchResults(): void {
const query = searchInput.value.trim().toLocaleLowerCase();
let visibleCount = 0;
resultItems.forEach((item, fieldHeader) => {
const searchableText = `${item.dataset.audienceProfileFieldDialogResultGroup ?? ""} ${fieldHeader}`.toLocaleLowerCase();
const isVisible = !query || searchableText.includes(query);
setElementVisible(item, isVisible, "flex");
if (isVisible) visibleCount += 1;
});
groupControls.forEach(({ root }) => {
const hasVisibleField = Array.from(
root.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-result]"
)
).some((item) => !item.hidden);
setElementVisible(root, hasVisibleField, "block");
});
resultCount.textContent = query
? `找到 ${visibleCount}`
: `${fields.length} 个可选`;
setElementVisible(resultEmptyState, visibleCount === 0, "block");
}
function close(value: string[] | null): void {
document.removeEventListener("keydown", onKeyDown);
activeDialogs.delete(document);
overlay.remove();
resolveDialog(value);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") close(null);
}
selectAllButton.addEventListener("click", () => {
fields.forEach((field) => selectedHeaderSet.add(field.header));
syncSelectionView();
});
resetButton.addEventListener("click", () => {
selectedHeaderSet.clear();
fields.forEach((field) => selectedHeaderSet.add(field.header));
syncSelectionView();
});
searchInput.addEventListener("input", syncSearchResults);
closeButton.addEventListener("click", () => close(null));
cancelButton.addEventListener("click", () => close(null));
confirmButton.addEventListener("click", () => {
close(
fields
.filter((field) => selectedHeaderSet.has(field.header))
.map((field) => field.header)
);
});
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close(null);
});
document.addEventListener("keydown", onKeyDown);
syncSelectionView();
syncSearchResults();
searchInput.focus();
activeDialogs.set(document, {
focus: () => searchInput.focus(),
promise
});
return promise;
} }
function syncAllGroupInputs(dialog: HTMLElement): void { function listSelectableFields(
dialog groups: AudienceProfileCsvFieldGroup[]
.querySelectorAll('[data-audience-profile-field-dialog-group="section"]') ): SelectableField[] {
.forEach((section) => { const seenHeaders = new Set<string>();
const groupInput = section.querySelector(":scope > label > input"); return groups.flatMap((group) =>
const fieldInputs = Array.from( group.headers.flatMap((header) => {
section.querySelectorAll(":scope > div input") if (seenHeaders.has(header)) return [];
) as HTMLInputElement[]; seenHeaders.add(header);
if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) { return [{ group: group.label, header }];
return; })
} );
}
const checkedCount = fieldInputs.filter((input) => input.checked).length; function setElementVisible(
groupInput.checked = checkedCount === fieldInputs.length; element: HTMLElement,
groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length; isVisible: boolean,
}); visibleDisplay: "block" | "flex"
): void {
element.hidden = !isVisible;
element.style.display = isVisible ? visibleDisplay : "none";
} }
function applyOverlayStyles(overlay: HTMLElement): void { function applyOverlayStyles(overlay: HTMLElement): void {
overlay.style.position = "fixed"; Object.assign(overlay.style, {
overlay.style.inset = "0"; alignItems: "center",
overlay.style.zIndex = "2147483647"; background: "rgba(15, 23, 42, 0.42)",
overlay.style.display = "flex"; display: "flex",
overlay.style.alignItems = "center"; inset: "0",
overlay.style.justifyContent = "center"; justifyContent: "center",
overlay.style.background = "rgba(15, 23, 42, 0.38)"; padding: "24px",
position: "fixed",
zIndex: "2147483647"
});
} }
function applyDialogStyles(dialog: HTMLElement): void { function applyDialogStyles(dialog: HTMLElement): void {
dialog.style.width = "680px"; Object.assign(dialog.style, {
dialog.style.maxWidth = "calc(100vw - 32px)"; background: "#ffffff",
dialog.style.maxHeight = "calc(100vh - 48px)"; borderRadius: "8px",
dialog.style.display = "flex"; boxShadow: "0 20px 52px rgba(15, 23, 42, 0.24)",
dialog.style.flexDirection = "column"; boxSizing: "border-box",
dialog.style.background = "#ffffff"; display: "flex",
dialog.style.borderRadius = "8px"; flexDirection: "column",
dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; height: "min(680px, calc(100vh - 48px))",
dialog.style.padding = "20px"; maxWidth: "calc(100vw - 48px)",
dialog.style.boxSizing = "border-box"; minHeight: "420px",
overflow: "hidden",
width: "960px"
});
}
function applyDialogHeaderStyles(header: HTMLElement): void {
Object.assign(header.style, {
alignItems: "flex-start",
borderBottom: "1px solid #e5e7eb",
display: "flex",
flex: "0 0 auto",
justifyContent: "space-between",
padding: "20px 22px 16px"
});
} }
function applyTitleStyles(title: HTMLElement): void { function applyTitleStyles(title: HTMLElement): void {
title.style.margin = "0 0 8px"; Object.assign(title.style, {
title.style.fontSize = "18px"; color: "#1f2329",
title.style.fontWeight = "700"; fontSize: "18px",
title.style.color = "#1f2329"; fontWeight: "700",
letterSpacing: "0",
lineHeight: "26px",
margin: "0"
});
} }
function applyHintStyles(hint: HTMLElement): void { function applyHintStyles(hint: HTMLElement): void {
hint.style.margin = "0 0 12px"; Object.assign(hint.style, {
hint.style.fontSize = "13px"; color: "#64748b",
hint.style.lineHeight = "20px"; fontSize: "13px",
hint.style.color = "#64748b"; lineHeight: "20px",
margin: "4px 0 0"
});
} }
function applyToolbarStyles(toolbar: HTMLElement): void { function applyCloseButtonStyles(button: HTMLButtonElement): void {
toolbar.style.display = "flex"; Object.assign(button.style, {
toolbar.style.gap = "8px"; alignItems: "center",
toolbar.style.marginBottom = "12px"; background: "transparent",
border: "0",
borderRadius: "4px",
color: "#64748b",
cursor: "pointer",
display: "flex",
flex: "0 0 auto",
fontSize: "24px",
height: "32px",
justifyContent: "center",
padding: "0",
width: "32px"
});
} }
function applyGroupContainerStyles(container: HTMLElement): void { function applyContentStyles(content: HTMLElement): void {
container.style.display = "flex"; Object.assign(content.style, {
container.style.flexDirection = "column"; display: "grid",
container.style.gap = "10px"; flex: "1 1 auto",
container.style.overflow = "auto"; gap: "16px",
container.style.paddingRight = "4px"; gridTemplateColumns: "minmax(260px, 0.8fr) minmax(380px, 1.4fr)",
minHeight: "0",
padding: "16px 22px"
});
}
function applyPaneStyles(pane: HTMLElement): void {
Object.assign(pane.style, {
border: "1px solid #e2e8f0",
borderRadius: "6px",
display: "flex",
flexDirection: "column",
minHeight: "0",
overflow: "hidden"
});
}
function applyPaneHeaderStyles(header: HTMLElement): void {
Object.assign(header.style, {
alignItems: "center",
background: "#f8fafc",
borderBottom: "1px solid #e2e8f0",
display: "flex",
flex: "0 0 auto",
justifyContent: "space-between",
minHeight: "44px",
padding: "0 14px"
});
}
function applyPaneTitleStyles(title: HTMLElement): void {
Object.assign(title.style, {
color: "#1f2329",
fontSize: "14px",
fontWeight: "700",
letterSpacing: "0",
margin: "0"
});
}
function applyCountStyles(count: HTMLElement): void {
Object.assign(count.style, {
color: "#64748b",
fontSize: "12px",
whiteSpace: "nowrap"
});
}
function applySelectionActionsStyles(actions: HTMLElement): void {
Object.assign(actions.style, {
alignItems: "center",
borderBottom: "1px solid #eef2f6",
display: "flex",
flex: "0 0 auto",
gap: "14px",
minHeight: "40px",
padding: "0 14px"
});
}
function applyTextButtonStyles(button: HTMLButtonElement): void {
Object.assign(button.style, {
background: "transparent",
border: "0",
color: "#2563eb",
cursor: "pointer",
fontSize: "12px",
fontWeight: "600",
padding: "4px 0"
});
}
function applySelectedListStyles(list: HTMLElement): void {
Object.assign(list.style, {
display: "flex",
flex: "1 1 auto",
flexDirection: "column",
minHeight: "0",
overflow: "auto",
padding: "6px 0"
});
}
function applySelectedFieldStyles(label: HTMLElement): void {
Object.assign(label.style, {
alignItems: "flex-start",
borderBottom: "1px solid #f1f5f9",
color: "#334155",
cursor: "pointer",
display: "flex",
fontSize: "13px",
gap: "8px",
lineHeight: "18px",
padding: "9px 14px"
});
}
function applySelectedFieldTextStyles(text: HTMLElement): void {
Object.assign(text.style, {
display: "flex",
flexDirection: "column",
minWidth: "0",
overflowWrap: "anywhere"
});
}
function applySelectedFieldGroupStyles(group: HTMLElement): void {
Object.assign(group.style, {
color: "#94a3b8",
fontSize: "11px",
lineHeight: "16px"
});
}
function applySearchInputStyles(input: HTMLInputElement): void {
Object.assign(input.style, {
background: "#ffffff",
border: "1px solid #cbd5e1",
borderRadius: "6px",
boxSizing: "border-box",
color: "#1f2329",
flex: "0 0 auto",
fontSize: "13px",
height: "38px",
margin: "12px 14px 8px",
outline: "none",
padding: "0 11px",
width: "calc(100% - 28px)"
});
}
function applyResultListStyles(list: HTMLElement): void {
Object.assign(list.style, {
flex: "1 1 auto",
minHeight: "0",
overflow: "auto",
padding: "0 14px 10px"
});
} }
function applyGroupSectionStyles(section: HTMLElement): void { function applyGroupSectionStyles(section: HTMLElement): void {
section.style.border = "1px solid #e5e7eb"; Object.assign(section.style, {
section.style.borderRadius = "8px"; borderBottom: "1px solid #e5e7eb",
section.style.padding = "10px"; padding: "10px 0"
});
} }
function applyGroupHeaderStyles(label: HTMLElement): void { function applyGroupHeaderStyles(label: HTMLElement): void {
label.style.display = "flex"; Object.assign(label.style, {
label.style.alignItems = "center"; alignItems: "center",
label.style.gap = "8px"; color: "#1f2329",
label.style.fontWeight = "700"; cursor: "pointer",
label.style.color = "#1f2329"; display: "flex",
label.style.marginBottom = "8px"; fontSize: "13px",
fontWeight: "700",
gap: "8px",
marginBottom: "7px"
});
} }
function applyFieldListStyles(list: HTMLElement): void { function applyFieldListStyles(list: HTMLElement): void {
list.style.display = "grid"; Object.assign(list.style, {
list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))"; display: "grid",
list.style.gap = "8px"; gap: "3px",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))"
});
} }
function applyFieldLabelStyles(label: HTMLElement): void { function applyFieldLabelStyles(label: HTMLElement): void {
label.style.display = "flex"; Object.assign(label.style, {
label.style.alignItems = "center"; alignItems: "flex-start",
label.style.gap = "6px"; borderRadius: "4px",
label.style.fontSize = "13px"; color: "#475569",
label.style.lineHeight = "18px"; cursor: "pointer",
label.style.color = "#374151"; display: "flex",
fontSize: "12px",
gap: "7px",
lineHeight: "18px",
minWidth: "0",
overflowWrap: "anywhere",
padding: "6px"
});
}
function applyCheckboxStyles(input: HTMLInputElement): void {
Object.assign(input.style, {
accentColor: "#2563eb",
flex: "0 0 auto",
height: "14px",
margin: "2px 0 0",
width: "14px"
});
}
function applyEmptyStateStyles(state: HTMLElement): void {
Object.assign(state.style, {
color: "#94a3b8",
fontSize: "13px",
margin: "auto",
padding: "24px",
textAlign: "center"
});
} }
function applyActionsStyles(actions: HTMLElement): void { function applyActionsStyles(actions: HTMLElement): void {
actions.style.display = "flex"; Object.assign(actions.style, {
actions.style.justifyContent = "flex-end"; borderTop: "1px solid #e5e7eb",
actions.style.columnGap = "8px"; columnGap: "8px",
actions.style.marginTop = "14px"; display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
padding: "14px 22px"
});
} }
function applyPrimaryButtonStyles(button: HTMLButtonElement): void { function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px"; Object.assign(button.style, {
button.style.padding = "0 15px"; background: "#7f1d2d",
button.style.border = "1px solid #7f1d2d"; border: "1px solid #7f1d2d",
button.style.borderRadius = "8px"; borderRadius: "8px",
button.style.background = "#7f1d2d"; color: "#ffffff",
button.style.color = "#ffffff"; fontWeight: "600",
button.style.fontWeight = "600"; height: "34px",
padding: "0 18px"
});
} }
function applySecondaryButtonStyles(button: HTMLButtonElement): void { function applySecondaryButtonStyles(button: HTMLButtonElement): void {
button.style.height = "32px"; Object.assign(button.style, {
button.style.padding = "0 15px"; background: "#ffffff",
button.style.border = "1px solid #d0d7de"; border: "1px solid #d0d7de",
button.style.borderRadius = "8px"; borderRadius: "8px",
button.style.background = "#ffffff"; color: "#1f2329",
button.style.color = "#1f2329"; fontWeight: "600",
button.style.fontWeight = "600"; height: "34px",
padding: "0 18px"
});
}
function ensureResponsiveStyles(document: Document): void {
if (document.getElementById(FIELD_DIALOG_STYLE_ID)) return;
const style = document.createElement("style");
style.id = FIELD_DIALOG_STYLE_ID;
style.textContent = `
[data-audience-profile-field-dialog-field='checkbox']:focus-visible,
[data-audience-profile-field-dialog-selected-checkbox='checkbox']:focus-visible,
[data-audience-profile-field-dialog-group-toggle]:focus-visible,
[data-audience-profile-field-dialog-search='input']:focus-visible,
[data-audience-profile-field-dialog='dialog'] button:focus-visible {
outline: 2px solid #2563eb !important;
outline-offset: 2px !important;
}
[data-audience-profile-field-dialog-result]:hover,
[data-audience-profile-field-dialog-selected-field]:hover {
background: #f8fafc !important;
}
@media (max-width: 720px) {
[data-audience-profile-field-dialog='overlay'] { align-items: stretch !important; padding: 12px !important; }
[data-audience-profile-field-dialog='dialog'] { height: calc(100vh - 24px) !important; max-width: none !important; min-height: 0 !important; width: 100% !important; }
[data-audience-profile-field-dialog-content='root'] { display: flex !important; flex-direction: column !important; overflow: auto !important; padding: 12px 14px !important; }
[data-audience-profile-field-dialog-pane='selected'] { flex: 0 0 210px !important; }
[data-audience-profile-field-dialog-pane='results'] { flex: 1 0 340px !important; }
[data-audience-profile-field-dialog-results='root'] > section > div { grid-template-columns: 1fr !important; }
}
`;
document.head.appendChild(style);
} }
+10 -90
View File
@@ -4,7 +4,6 @@ import {
listRateCsvHeaders listRateCsvHeaders
} from "./csv-exporter"; } from "./csv-exporter";
import { import {
buildAudienceProfileFieldValues,
buildAudienceProfileCsv, buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups, listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions type AudienceProfileCsvOptions
@@ -53,10 +52,6 @@ import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client"; import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller"; import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import {
listNumericMetricFilterDefinitions,
matchesMetricFilterRule
} from "./metric-filter";
import { createSilentExportController } from "./silent-export-controller"; import { createSilentExportController } from "./silent-export-controller";
import { import {
createSpreadInfoClient, createSpreadInfoClient,
@@ -66,7 +61,6 @@ import {
} from "./spread-info"; } from "./spread-info";
import { import {
readToolbarExportTarget, readToolbarExportTarget,
readToolbarSpreadFilter,
setToolbarBusyState, setToolbarBusyState,
setToolbarExportStatus setToolbarExportStatus
} from "./plugin-toolbar"; } from "./plugin-toolbar";
@@ -89,8 +83,7 @@ import type {
MarketRecord, MarketRecord,
MarketRowSnapshot, MarketRowSnapshot,
MarketSortState, MarketSortState,
MetricThresholdFilter, SpreadInfoConfig
SpreadInfoConfig,
} from "./types"; } from "./types";
interface MutationObserverLike { interface MutationObserverLike {
@@ -494,12 +487,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return; return;
} }
const spreadFilter = readToolbarSpreadFilter(toolbar);
if (spreadFilter.error) {
setToolbarExportStatus(toolbar, spreadFilter.error);
return;
}
const batchName = await promptBatchName(); const batchName = await promptBatchName();
if (batchName === null) { if (batchName === null) {
return; return;
@@ -513,15 +500,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, true); setToolbarBusyState(toolbar, true);
try { try {
const hasSelectedAuthors = selectedAuthorIds.size > 0; const hasSelectedAuthors = selectedAuthorIds.size > 0;
const resolvedRecords = await applyMetricThresholdFilter( const resolvedRecords = await exportRecords(
await exportRecords( exportTarget.target,
exportTarget.target, hasSelectedAuthors ? "提交已选达人中" : "提交中",
hasSelectedAuthors ? "提交已选达人中" : "提交中", {
{ showDetailedProgress: !hasSelectedAuthors
showDetailedProgress: !hasSelectedAuthors }
}
),
spreadFilter.filter
); );
const records = const records =
exportTarget.target.mode === "all" exportTarget.target.mode === "all"
@@ -559,11 +543,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
} }
}; };
toolbar = ensurePluginToolbar( toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const ready = (async () => { const ready = (async () => {
await runSyncCycle(); await runSyncCycle();
@@ -736,11 +716,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function applyCurrentView(): void { function applyCurrentView(): void {
runWithoutMutationSync(() => { runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar( toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const table = syncMarketTable(options.document); const table = syncMarketTable(options.document);
if (!table) { if (!table) {
return; return;
@@ -1126,51 +1102,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return selectedRecords.length > 0 ? selectedRecords : records; return selectedRecords.length > 0 ? selectedRecords : records;
} }
async function applyMetricThresholdFilter(
records: MarketRecord[],
filter: MetricThresholdFilter | undefined
): Promise<MarketRecord[]> {
if (!filter || filter.rules.length === 0) {
return records;
}
const filterSelection = resolveAudienceProfileExportSelection(
filter.rules.map((rule) => rule.field)
);
const hydratedRecords = await hydrateExportRecords(records, {
includeBackendMetrics: filterSelection.includeBackendMetrics,
includeRates: filterSelection.includeRates,
spreadInfoConfigs: filterSelection.spreadInfoConfigs
});
const matchedRecords: MarketRecord[] = [];
for (let index = 0; index < hydratedRecords.length; index += 1) {
const record = hydratedRecords[index];
setToolbarExportStatus(
toolbar,
`指标筛选 ${index + 1}/${hydratedRecords.length}...`
);
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(record, filterSelection),
filterSelection.businessAbility
? loadBusinessAbilitySafe(record)
: Promise.resolve(undefined)
]);
const values = buildAudienceProfileFieldValues(
finalizeAudienceProfileExportRow({
businessAbility,
profiles,
record
})
);
if (filter.rules.every((rule) => matchesMetricFilterRule(values, rule))) {
matchedRecords.push(record);
}
}
return matchedRecords;
}
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] { function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
if (selectedAuthorIds.size === 0) { if (selectedAuthorIds.size === 0) {
return []; return [];
@@ -1451,13 +1382,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
); );
} }
function readMetricFilterDefinitions() {
const marketListHeaders = buildBaseColumns(
readCurrentPageRecords(syncMarketTable(options.document))
).map((column) => column.header);
return listNumericMetricFilterDefinitions(marketListHeaders);
}
function readAudienceProfileSelectedHeaders(): string[] | undefined { function readAudienceProfileSelectedHeaders(): string[] | undefined {
try { try {
const rawValue = options.window.localStorage?.getItem( const rawValue = options.window.localStorage?.getItem(
@@ -2046,11 +1970,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
async function runSingleSyncCycle(): Promise<void> { async function runSingleSyncCycle(): Promise<void> {
toolbar = ensurePluginToolbar( toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
if (isPluginToolbarMounted(toolbar.root, options.document)) { if (isPluginToolbarMounted(toolbar.root, options.document)) {
toolbarRemountScheduled = false; toolbarRemountScheduled = false;
} }
-80
View File
@@ -1,80 +0,0 @@
import {
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvFieldGroup
} from "./audience-profile-csv";
import type { MetricFilterRule } from "./types";
export interface MetricFilterDefinition {
field: string;
group: string;
}
const NUMERIC_MARKET_LIST_FIELDS = new Set([
"连接用户数",
"粉丝数",
"预期CPM",
"预期播放量",
"互动率",
"完播率",
"爆文率",
"21-60s报价"
]);
export function listNumericMetricFilterGroups(
marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] {
return listAudienceProfileSelectableFieldGroups(marketListHeaders)
.map((group) => ({
...group,
headers: group.headers.filter((header) =>
group.label === "列表字段"
? NUMERIC_MARKET_LIST_FIELDS.has(header)
: true
)
}))
.filter((group) => group.headers.length > 0);
}
export function listNumericMetricFilterDefinitions(
marketListHeaders: string[] = []
): MetricFilterDefinition[] {
return listNumericMetricFilterGroups(marketListHeaders).flatMap((group) =>
group.headers.map((field) => ({ field, group: group.label }))
);
}
export function matchesMetricFilterRule(
values: Record<string, string>,
rule: MetricFilterRule
): boolean {
const value = parseDisplayNumber(values[rule.field]);
if (value === null) {
return false;
}
return rule.operator === "gte"
? value >= rule.threshold
: value <= rule.threshold;
}
export function parseDisplayNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const normalized = value.trim().replace(/[\s,¥¥]/g, "");
if (!normalized || normalized === "缺失") {
return null;
}
const suffix = normalized.endsWith("w") || normalized.endsWith("万")
? 10_000
: normalized.endsWith("亿")
? 100_000_000
: 1;
const numericText = normalized
.replace(/[%w万亿]/g, "")
.replace(/^\+/, "");
const numericValue = Number(numericText);
return Number.isFinite(numericValue) ? numericValue * suffix : null;
}
File diff suppressed because it is too large Load Diff
+1 -43
View File
@@ -1,7 +1,6 @@
import type { import type {
SpreadInfoConfig, SpreadInfoConfig,
SpreadInfoMetrics, SpreadInfoMetrics
SpreadMetricFilterRule
} from "./types"; } from "./types";
interface FetchResponseLike { interface FetchResponseLike {
@@ -110,12 +109,6 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
} }
return metrics; return metrics;
},
async loadAuthorSpreadMetricSnapshot(
authorId: string,
config: SpreadInfoConfig
): Promise<MappedSpreadInfoResponse> {
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
} }
}; };
@@ -243,32 +236,6 @@ export function mapSpreadInfoResponse(
}; };
} }
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;
}
function buildSpreadInfoColumnHeader( function buildSpreadInfoColumnHeader(
config: SpreadInfoConfig, config: SpreadInfoConfig,
metric: SpreadInfoMetricDefinition metric: SpreadInfoMetricDefinition
@@ -366,15 +333,6 @@ function readNumberLike(value: unknown): number | null {
return null; return null;
} }
function readDisplayNumber(value: string | undefined): number | null {
if (!hasTextValue(value)) {
return null;
}
const parsedValue = Number(value.replace(/[% ,]/g, ""));
return Number.isFinite(parsedValue) ? parsedValue : null;
}
function formatBasisPointPercent(value: number | null): string | undefined { function formatBasisPointPercent(value: number | null): string | undefined {
if (value === null) { if (value === null) {
return undefined; return undefined;
-24
View File
@@ -21,30 +21,6 @@ export interface SpreadInfoConfig {
type: 1 | 2; type: 1 | 2;
} }
export type SpreadFilterMetric = "finishRate" | "interactionRate";
export interface SpreadMetricFilterRule {
config: SpreadInfoConfig;
metric: SpreadFilterMetric;
threshold: number;
}
export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
export type MetricFilterOperator = "gte" | "lte";
export interface MetricFilterRule {
field: string;
operator: MetricFilterOperator;
threshold: number;
}
export interface MetricThresholdFilter {
rules: MetricFilterRule[];
}
export type MarketSortField = export type MarketSortField =
| keyof Required<AfterSearchRates> | keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>; | keyof Required<BackendMetrics>;
+156 -6
View File
@@ -18,16 +18,166 @@ describe("audience-profile-field-dialog", () => {
); );
const fields = Array.from( const fields = Array.from(
document.querySelectorAll('[data-audience-profile-field-dialog-field="checkbox"]') document.querySelectorAll(
'[data-audience-profile-field-dialog-field="checkbox"]'
)
) as HTMLInputElement[]; ) as HTMLInputElement[];
expect(fields).toHaveLength(2); expect(fields).toHaveLength(2);
expect(fields.every((field) => !field.checked)).toBe(true); expect(fields.every((field) => !field.checked)).toBe(true);
expect(
document.querySelectorAll(
"[data-audience-profile-field-dialog-selected-field]"
)
).toHaveLength(0);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-selected-empty="text"]'
)?.hidden
).toBe(false);
const saveButton = document.querySelector( clickSave();
'[data-audience-profile-field-dialog-save="button"]'
) as HTMLButtonElement;
saveButton.click();
await expect(result).resolves.toEqual([]); await expect(result).resolves.toEqual([]);
}); });
test("keeps selected fields visible while searching and synchronizes both panes", async () => {
const result = promptForAudienceProfileFields(
document,
[
{
headers: ["粉丝数"],
label: "列表字段"
},
{
headers: ["内容数据-近30天-完播率", "内容数据-近30天-播放量"],
label: "内容数据"
}
],
["粉丝数", "内容数据-近30天-播放量"]
);
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-播放量"
]);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-selected-count="text"]'
)?.textContent
).toBe("2 个");
const search = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement;
search.value = "完播率";
search.dispatchEvent(new Event("input"));
expect(readResultItem("内容数据-近30天-完播率")?.hidden).toBe(false);
expect(readResultItem("粉丝数")?.hidden).toBe(true);
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-播放量"
]);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 1 个");
const finishRateInput = readResultInput("内容数据-近30天-完播率");
finishRateInput.checked = true;
finishRateInput.dispatchEvent(new Event("change"));
expect(readSelectedFieldNames()).toEqual([
"粉丝数",
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
const selectedFansInput = readSelectedInput("粉丝数");
selectedFansInput.checked = false;
selectedFansInput.dispatchEvent(new Event("change"));
expect(readResultInput("粉丝数").checked).toBe(false);
expect(readSelectedFieldNames()).toEqual([
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
clickSave();
await expect(result).resolves.toEqual([
"内容数据-近30天-完播率",
"内容数据-近30天-播放量"
]);
});
test("shows a dedicated empty state when no field matches the keyword", async () => {
const result = promptForAudienceProfileFields(
document,
[{ headers: ["粉丝数"], label: "列表字段" }],
undefined
);
const search = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement;
search.value = "不存在的字段";
search.dispatchEvent(new Event("input"));
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-empty="text"]'
)?.hidden
).toBe(false);
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 0 个");
expect(readSelectedFieldNames()).toEqual(["粉丝数"]);
clickSave();
await expect(result).resolves.toEqual(["粉丝数"]);
});
}); });
function clickSave(): void {
const saveButton = document.querySelector(
'[data-audience-profile-field-dialog-save="button"]'
) as HTMLButtonElement;
saveButton.click();
}
function readResultItem(field: string): HTMLElement | undefined {
return Array.from(
document.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-result]"
)
).find(
(element) => element.dataset.audienceProfileFieldDialogResult === field
);
}
function readResultInput(field: string): HTMLInputElement {
const input = Array.from(
document.querySelectorAll<HTMLInputElement>(
'[data-audience-profile-field-dialog-field="checkbox"]'
)
).find((element) => element.value === field);
if (!input) throw new Error(`Missing result field input: ${field}`);
return input;
}
function readSelectedInput(field: string): HTMLInputElement {
const input = Array.from(
document.querySelectorAll<HTMLInputElement>(
'[data-audience-profile-field-dialog-selected-checkbox="checkbox"]'
)
).find((element) => element.value === field);
if (!input) throw new Error(`Missing selected field input: ${field}`);
return input;
}
function readSelectedFieldNames(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
"[data-audience-profile-field-dialog-selected-field]"
)
).map((element) => element.dataset.audienceProfileFieldDialogSelectedField ?? "");
}
+18 -433
View File
@@ -377,7 +377,7 @@ describe("market-content-entry", () => {
); );
expect(audienceProfileFieldButton?.textContent).toBe("选择字段"); expect(audienceProfileFieldButton?.textContent).toBe("选择字段");
expect(audienceProfileFieldButton?.title).toBe( expect(audienceProfileFieldButton?.title).toBe(
"勾选本次CSV需要导出的字段,设置会自动保存" "搜索并勾选本次CSV需要导出的字段,设置会自动保存"
); );
expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次"); expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
@@ -426,9 +426,6 @@ describe("market-content-entry", () => {
const primaryRow = document.querySelector( const primaryRow = document.querySelector(
'[data-plugin-toolbar-row="primary"]' '[data-plugin-toolbar-row="primary"]'
) as HTMLElement | null; ) as HTMLElement | null;
const thresholdRow = document.querySelector(
'[data-plugin-toolbar-row="thresholds"]'
) as HTMLElement | null;
const dataGroup = document.querySelector( const dataGroup = document.querySelector(
'[data-plugin-toolbar-group="data"]' '[data-plugin-toolbar-group="data"]'
) as HTMLElement | null; ) as HTMLElement | null;
@@ -441,18 +438,9 @@ describe("market-content-entry", () => {
const batchSubmitGroup = document.querySelector( const batchSubmitGroup = document.querySelector(
'[data-plugin-toolbar-action-group="batch-submit"]' '[data-plugin-toolbar-action-group="batch-submit"]'
) as HTMLElement | null; ) as HTMLElement | null;
const metricCatalog = document.querySelector(
'[data-plugin-metric-catalog="root"]'
) as HTMLElement | null;
const rulesGroup = document.querySelector(
'[data-plugin-metric-rules="root"]'
) as HTMLElement | null;
const statusText = document.querySelector( const statusText = document.querySelector(
'[data-plugin-export-status="text"]' '[data-plugin-export-status="text"]'
) as HTMLElement | null; ) as HTMLElement | null;
const titles = Array.from(
document.querySelectorAll("[data-plugin-toolbar-title]")
) as HTMLElement[];
const exportRangeSelect = document.querySelector( const exportRangeSelect = document.querySelector(
'[data-plugin-export-range="select"]' '[data-plugin-export-range="select"]'
) as HTMLSelectElement | null; ) as HTMLSelectElement | null;
@@ -471,24 +459,10 @@ describe("market-content-entry", () => {
const batchSubmitButton = document.querySelector( const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]' '[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
const operators = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-operator]")
).map((element) => element.textContent);
const ruleRows = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-rule]")
) as HTMLElement[];
const thresholdInputs = Array.from(
document.querySelectorAll("[data-plugin-metric-filter-threshold]")
) as HTMLInputElement[];
expect(toolbar?.style.flexWrap).toBe("nowrap"); expect(toolbar?.style.flexWrap).toBe("nowrap");
expect(panel?.style.flexDirection).toBe("column");
expect(panel?.style.alignItems).toBe("center"); expect(panel?.style.alignItems).toBe("center");
expect(primaryRow?.style.flexWrap).toBe("nowrap"); expect(primaryRow?.style.flexWrap).toBe("nowrap");
expect(thresholdRow?.style.flexWrap).toBe("nowrap"); expect(panel?.style.overflow).toBe("visible");
expect(thresholdRow?.style.alignItems).toBe("center");
expect(panel?.style.overflowX).toBe("visible");
expect(panel?.style.overflowY).toBe("visible");
expect(dataGroup?.parentElement).toBe(primaryRow); expect(dataGroup?.parentElement).toBe(primaryRow);
expect( expect(
document.querySelector('[data-plugin-toolbar-action-group="csv-export"]') document.querySelector('[data-plugin-toolbar-action-group="csv-export"]')
@@ -509,31 +483,11 @@ describe("market-content-entry", () => {
batchSubmitButton batchSubmitButton
]); ]);
expect(statusText?.parentElement).toBe(primaryRow); expect(statusText?.parentElement).toBe(primaryRow);
expect(metricCatalog?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.parentElement).toBe(thresholdRow);
expect(rulesGroup?.style.flexDirection).toBe("column");
expect(primaryRow?.style.justifyContent).toBe("flex-start"); expect(primaryRow?.style.justifyContent).toBe("flex-start");
expect(thresholdRow?.style.justifyContent).toBe("flex-start"); expect(document.querySelector('[data-plugin-toolbar-row="thresholds"]')).toBeNull();
expect(titles.map((element) => element.textContent)).toEqual([ expect(document.querySelector('[data-plugin-metric-catalog]')).toBeNull();
"指标筛选" expect(document.querySelector('[data-plugin-metric-filter-rule]')).toBeNull();
]); expect(document.body.textContent).not.toContain("指标筛选");
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
expect(operators).toEqual([]);
expect(
document.querySelector('[data-plugin-metric-catalog-trigger="button"]')
?.textContent
).toBe("添加筛选指标");
expect(
document.querySelector('[data-plugin-metric-selected-count="text"]')
?.textContent
).toBe("已选 0 项");
expect(
(document.querySelector(
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null)?.hidden
).toBe(true);
expect(ruleRows).toEqual([]);
expect(thresholdInputs).toEqual([]);
expect([ expect([
audienceProfileExportButton?.textContent, audienceProfileExportButton?.textContent,
audienceProfileByIdExportButton?.textContent, audienceProfileByIdExportButton?.textContent,
@@ -1357,237 +1311,6 @@ describe("market-content-entry", () => {
expect(customPagesInput?.hidden).toBe(false); expect(customPagesInput?.hidden).toBe(false);
}); });
test("removes the toolbar resize listener after its root is detached", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
try {
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
"../src/content/market/plugin-toolbar"
);
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers());
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
toolbar.root.remove();
expect(isPluginToolbarMounted(toolbar.root, document)).toBe(false);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("moves the toolbar resize listener when its root is adopted by another window", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
const alternateAddEventListener = vi.fn();
const alternateRemoveEventListener = vi.fn();
const alternateWindow = {
addEventListener: alternateAddEventListener,
removeEventListener: alternateRemoveEventListener
} as unknown as Window;
const alternateDocument = {
createTreeWalker: document.createTreeWalker.bind(document),
defaultView: alternateWindow
} as unknown as Document;
try {
const { ensurePluginToolbar, isPluginToolbarMounted } = await import(
"../src/content/market/plugin-toolbar"
);
const handlers = createNoopToolbarHandlers();
const toolbar = ensurePluginToolbar(document, handlers);
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
const originalOwnerDocument = toolbar.root.ownerDocument;
Object.defineProperty(toolbar.root, "ownerDocument", {
configurable: true,
value: alternateDocument
});
const reusedToolbar = ensurePluginToolbar(document, handlers);
expect(reusedToolbar.root).toBe(toolbar.root);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
expect(alternateAddEventListener).toHaveBeenCalledWith("resize", resizeHandler);
Object.defineProperty(toolbar.root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
toolbar.root.remove();
isPluginToolbarMounted(toolbar.root, document);
} finally {
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("removes the toolbar resize listener when its root has no owner window", async () => {
document.body.innerHTML = buildMarketFixture();
const addEventListener = vi.spyOn(window, "addEventListener");
const removeEventListener = vi.spyOn(window, "removeEventListener");
let root: HTMLElement | null = null;
let originalOwnerDocument: Document | null = null;
let isPluginToolbarMounted:
| ((toolbarRoot: HTMLElement, toolbarDocument: Document) => boolean)
| undefined;
try {
const toolbarModule = await import("../src/content/market/plugin-toolbar");
isPluginToolbarMounted = toolbarModule.isPluginToolbarMounted;
const handlers = createNoopToolbarHandlers();
const toolbar = toolbarModule.ensurePluginToolbar(document, handlers);
root = toolbar.root;
originalOwnerDocument = root.ownerDocument;
const resizeHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === "resize"
)?.[1];
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: {
createTreeWalker: document.createTreeWalker.bind(document),
defaultView: null
} as unknown as Document
});
toolbarModule.ensurePluginToolbar(document, handlers);
expect(removeEventListener).toHaveBeenCalledWith("resize", resizeHandler);
expect(addEventListener).toHaveBeenCalledTimes(1);
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
toolbarModule.ensurePluginToolbar(document, handlers);
const resizeCalls = addEventListener.mock.calls.filter(
([eventName]) => eventName === "resize"
);
expect(resizeCalls).toHaveLength(2);
expect(resizeCalls[1]).toEqual(["resize", expect.any(Function)]);
toolbarModule.ensurePluginToolbar(document, handlers);
expect(
addEventListener.mock.calls.filter(
([eventName]) => eventName === "resize"
)
).toHaveLength(2);
} finally {
if (root && originalOwnerDocument) {
Object.defineProperty(root, "ownerDocument", {
configurable: true,
value: originalOwnerDocument
});
root.remove();
isPluginToolbarMounted?.(root, document);
}
addEventListener.mockRestore();
removeEventListener.mockRestore();
}
});
test("catalog groups numeric fields, supports search, and adds one field rule", 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 field = "内容数据-个人视频-近30天-完播率";
const catalogPanel = document.querySelector(
'[data-plugin-metric-catalog-panel="root"]'
) as HTMLElement | null;
expect(catalogPanel?.hidden).toBe(true);
expect(catalogPanel?.style.zIndex).toBe("100");
expect(document.querySelector('[data-plugin-metric-selected-count="text"]')?.textContent).toBe("已选 0 项");
click('[data-plugin-metric-catalog-trigger="button"]');
const groupLabels = Array.from(
document.querySelectorAll('[data-plugin-metric-catalog-group] > strong')
).map((element) => element.textContent);
expect(groupLabels).toEqual(expect.arrayContaining([
"列表字段", "看后搜率", "秒思api数据", "内容数据", "效果预估", "观众画像", "粉丝画像", "铁粉画像"
]));
const search = document.querySelector(
'[data-plugin-metric-catalog-search="input"]'
) as HTMLInputElement | null;
expect(search).not.toBeNull();
search!.value = "个人视频-近30天-完播率";
search!.dispatchEvent(new Event("input"));
expect(findMetricCatalogItem(field)?.hidden).toBe(false);
expect(findMetricCatalogItem("商单视频看后搜率")?.hidden).toBe(true);
clickMetricCatalogAction(field);
expect(catalogPanel?.hidden).toBe(true);
expect(findMetricRule(field)).not.toBeNull();
expect(document.querySelectorAll("[data-plugin-spread-filter]")).toHaveLength(0);
expect(findMetricCatalogAction(field)?.disabled).toBe(true);
expect(findMetricCatalogAction(field)?.textContent).toBe("已添加");
clickMetricRuleRemove(field);
expect(findMetricRule(field)).toBeNull();
expect(findMetricCatalogAction(field)?.disabled).toBe(false);
expect(findMetricCatalogAction(field)?.textContent).toBe("添加");
});
test("reads field rules with selectable greater-than-or-equal and less-than-or-equal operators", async () => {
document.body.innerHTML = buildMarketFixture();
const { ensurePluginToolbar, readToolbarSpreadFilter } = await import(
"../src/content/market/plugin-toolbar"
);
const field = "内容数据-个人视频-近30天-完播率";
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
{ field, group: "内容数据" }
]);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(field);
expect(readToolbarSpreadFilter(toolbar)).toEqual({
error: `请输入有效的${field}筛选数值`
});
setInputValueForMetric(field, "30");
setMetricOperator(field, "lte");
expect(readToolbarSpreadFilter(toolbar)).toEqual({
filter: {
rules: [{ field, operator: "lte", threshold: 30 }]
}
});
});
test("busy state disables metric search, catalog actions, and added rule controls", async () => {
document.body.innerHTML = buildMarketFixture();
const { ensurePluginToolbar, setToolbarBusyState } = await import(
"../src/content/market/plugin-toolbar"
);
const field = "内容数据-个人视频-近30天-互动率";
const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers(), [
{ field, group: "内容数据" }
]);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(field);
setToolbarBusyState(toolbar, true);
expect(
(document.querySelector('[data-plugin-metric-catalog-trigger="button"]') as HTMLButtonElement | null)?.disabled
).toBe(true);
expect(
(document.querySelector('[data-plugin-metric-catalog-search="input"]') as HTMLInputElement | null)?.disabled
).toBe(true);
expect(findMetricRuleRemove(field)?.disabled).toBe(true);
expect(findMetricRuleOperator(field)?.disabled).toBe(true);
});
test("audience profile export requires selected creators outside of the all range", 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" }
@@ -2288,6 +2011,18 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
click('[data-plugin-audience-profile-fields="button"]'); click('[data-plugin-audience-profile-fields="button"]');
const searchInput = document.querySelector(
'[data-audience-profile-field-dialog-search="input"]'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput!.value = "看后搜数";
searchInput!.dispatchEvent(new Event("input"));
expect(
document.querySelector(
'[data-audience-profile-field-dialog-result-count="text"]'
)?.textContent
).toBe("找到 1 个");
const afterSearchCountInput = document.querySelector( const afterSearchCountInput = document.querySelector(
'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]' 'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]'
) as HTMLInputElement | null; ) as HTMLInputElement | null;
@@ -2333,8 +2068,6 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current"); setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50); await waitForMockCall(submitBatch, 40, 50);
@@ -2348,76 +2081,6 @@ describe("market-content-entry", () => {
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId"); expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
}); });
test("batch submit applies selected field rules using their fixed content-data headers", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
},
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const submitBatch = vi.fn(async () => ({ ok: true }));
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => {
return {
"内容数据-个人视频-近30天-互动率":
spreadAuthorId === "spread-a" ? "6%" : "4%",
"内容数据-个人视频-近30天-完播率":
spreadAuthorId === "spread-a" ? "35%" : "20%"
};
});
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
getAuthState: async () => ({
isAuthenticated: true,
resource: "https://talent-search.intelligrow.cn",
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
}),
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadMetrics,
promptBatchName: () => "筛选批次",
submitBatch,
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
const finishField = "内容数据-个人视频-近30天-完播率";
const interactionField = "内容数据-个人视频-近30天-互动率";
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(finishField);
click('[data-plugin-metric-catalog-trigger="button"]');
clickMetricCatalogAction(interactionField);
setInputValueForMetric(finishField, "30");
setInputValueForMetric(interactionField, "5");
click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 80, 50);
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
expect.objectContaining({
authorId: "a"
})
]);
});
test("opens a custom batch name dialog before submitting", async () => { test("opens a custom batch name dialog before submitting", async () => {
document.body.innerHTML = buildMarketFixture(); document.body.innerHTML = buildMarketFixture();
const submitBatch = vi.fn(async () => ({ ok: true })); const submitBatch = vi.fn(async () => ({ ok: true }));
@@ -2439,7 +2102,6 @@ describe("market-content-entry", () => {
})); }));
await controller.ready; await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
expect(submitBatch).not.toHaveBeenCalled(); expect(submitBatch).not.toHaveBeenCalled();
@@ -2483,7 +2145,6 @@ describe("market-content-entry", () => {
})); }));
await controller.ready; await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-confirm="button"]'); click('[data-plugin-batch-name-confirm="button"]');
await flush(); await flush();
@@ -2518,7 +2179,6 @@ describe("market-content-entry", () => {
})); }));
await controller.ready; await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
click('[data-plugin-batch-name-cancel="button"]'); click('[data-plugin-batch-name-cancel="button"]');
await flush(); await flush();
@@ -2559,8 +2219,6 @@ describe("market-content-entry", () => {
clickSelectionCheckboxForAuthor("222"); clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "current"); setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50); await waitForMockCall(submitBatch, 40, 50);
@@ -2646,8 +2304,6 @@ describe("market-content-entry", () => {
await controller.ready; await controller.ready;
clickSelectionCheckboxForAuthor("111"); clickSelectionCheckboxForAuthor("111");
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) {
if ( if (
@@ -2766,8 +2422,6 @@ describe("market-content-entry", () => {
const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111"); const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111");
rowSelectionCheckbox.checked = true; rowSelectionCheckbox.checked = true;
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) {
if ( if (
@@ -2848,8 +2502,6 @@ describe("market-content-entry", () => {
await flushWithTimers(); await flushWithTimers();
setSelectValue('[data-plugin-export-range="select"]', "current"); setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForMockCall(submitBatch, 40, 50); await waitForMockCall(submitBatch, 40, 50);
@@ -2906,7 +2558,6 @@ describe("market-content-entry", () => {
clickSelectionCheckboxForAuthor("111"); clickSelectionCheckboxForAuthor("111");
setSelectValue('[data-plugin-export-range="select"]', "all"); setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await waitForCondition(() => await waitForCondition(() =>
@@ -2994,8 +2645,6 @@ describe("market-content-entry", () => {
setSelectValue('[data-plugin-export-range="select"]', "all"); setSelectValue('[data-plugin-export-range="select"]', "all");
dispatchChange('[data-plugin-export-range="select"]'); dispatchChange('[data-plugin-export-range="select"]');
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) {
if ( if (
@@ -3054,7 +2703,6 @@ describe("market-content-entry", () => {
})); }));
await controller.ready; await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await flush(); await flush();
@@ -3087,7 +2735,6 @@ describe("market-content-entry", () => {
})); }));
await controller.ready; await controller.ready;
removeDefaultSpreadMetricFilter();
click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-submit="button"]');
await flush(); await flush();
@@ -4903,68 +4550,6 @@ function click(selector: string) {
element.click(); element.click();
} }
function findMetricCatalogAction(field: string): HTMLButtonElement | null {
return Array.from(
document.querySelectorAll<HTMLButtonElement>("[data-plugin-metric-catalog-action]")
).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null;
}
function findMetricCatalogItem(field: string): HTMLElement | null {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-item]")
).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null;
}
function findMetricRule(field: string): HTMLElement | null {
return Array.from(
document.querySelectorAll<HTMLElement>("[data-plugin-metric-filter-rule]")
).find((element) => element.dataset.pluginMetricFilterRule === field) ?? null;
}
function findMetricRuleRemove(field: string): HTMLButtonElement | null {
return findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-remove]"
) as HTMLButtonElement | null;
}
function findMetricRuleOperator(field: string): HTMLSelectElement | null {
return findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-operator]"
) as HTMLSelectElement | null;
}
function clickMetricCatalogAction(field: string) {
const action = findMetricCatalogAction(field);
if (!action) throw new Error(`Missing metric catalog action: ${field}`);
action.click();
}
function clickMetricRuleRemove(field: string) {
const removeButton = findMetricRuleRemove(field);
if (!removeButton) throw new Error(`Missing metric rule remove button: ${field}`);
removeButton.click();
}
function setInputValueForMetric(field: string, value: string) {
const input = findMetricRule(field)?.querySelector(
"[data-plugin-metric-filter-threshold]"
) as HTMLInputElement | null;
if (!input) throw new Error(`Missing metric threshold input: ${field}`);
input.value = value;
input.dispatchEvent(new Event("input"));
}
function setMetricOperator(field: string, value: "gte" | "lte") {
const select = findMetricRuleOperator(field);
if (!select) throw new Error(`Missing metric operator: ${field}`);
select.value = value;
select.dispatchEvent(new Event("change"));
}
function removeDefaultSpreadMetricFilter() {
// Metric filters now start empty, so batch-submit tests need no cleanup.
}
function createNoopToolbarHandlers() { function createNoopToolbarHandlers() {
return { return {
onConfigureAudienceProfileFields: vi.fn(), onConfigureAudienceProfileFields: vi.fn(),
-51
View File
@@ -1,51 +0,0 @@
import { describe, expect, test } from "vitest";
import {
listNumericMetricFilterGroups,
matchesMetricFilterRule,
parseDisplayNumber
} from "../src/content/market/metric-filter";
describe("metric-filter", () => {
test("keeps only numeric list fields while retaining numeric API and profile groups", () => {
const groups = listNumericMetricFilterGroups([
"达人信息",
"粉丝数",
"互动率",
"完播率",
"内容主题",
"21-60s报价"
]);
const listFields = groups.find((group) => group.label === "列表字段");
expect(listFields?.headers).toEqual([
"粉丝数",
"互动率",
"完播率",
"21-60s报价"
]);
expect(groups.map((group) => group.label)).toEqual(expect.arrayContaining([
"内容数据",
"效果预估",
"观众画像",
"粉丝画像",
"铁粉画像"
]));
});
test("compares displayed percentages, prices, and wan-unit values with both operators", () => {
expect(parseDisplayNumber("28.5%")).toBe(28.5);
expect(parseDisplayNumber("¥12,000")).toBe(12000);
expect(parseDisplayNumber("6.2w")).toBe(62000);
expect(parseDisplayNumber("缺失")).toBeNull();
expect(matchesMetricFilterRule(
{ : "28.5%" },
{ field: "完播率", operator: "gte", threshold: 28 }
)).toBe(true);
expect(matchesMetricFilterRule(
{ : "28.5%" },
{ field: "完播率", operator: "lte", threshold: 28 }
)).toBe(false);
});
});
+24 -110
View File
@@ -1,14 +1,11 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { import {
buildSpreadInfoConfigKey,
buildSpreadInfoColumns, buildSpreadInfoColumns,
buildSpreadInfoUrl, buildSpreadInfoUrl,
createSpreadInfoClient, createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS, DEFAULT_SPREAD_INFO_CONFIGS,
filterUnloadedSpreadInfoConfigs, filterUnloadedSpreadInfoConfigs,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
mapSpreadInfoResponse, mapSpreadInfoResponse,
selectSpreadInfoConfigsForHeaders selectSpreadInfoConfigsForHeaders
} from "../src/content/market/spread-info"; } from "../src/content/market/spread-info";
@@ -246,12 +243,14 @@ describe("spread-info", () => {
}); });
await expect( await expect(
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { client.loadAuthorSpreadMetrics("7361012802036695050", [
flowType: 0, {
onlyAssign: true, flowType: 0,
range: 2, onlyAssign: true,
type: 2 range: 2,
}) type: 2
}
])
).rejects.toThrow("HTTP 429: Too Many Requests"); ).rejects.toThrow("HTTP 429: Too Many Requests");
}); });
@@ -269,12 +268,14 @@ describe("spread-info", () => {
}); });
await expect( await expect(
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { client.loadAuthorSpreadMetrics("7361012802036695050", [
flowType: 0, {
onlyAssign: true, flowType: 0,
range: 2, onlyAssign: true,
type: 2 range: 2,
}) type: 2
}
])
).rejects.toThrow( ).rejects.toThrow(
"星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试" "星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试"
); );
@@ -288,101 +289,14 @@ describe("spread-info", () => {
}); });
await expect( await expect(
client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { client.loadAuthorSpreadMetrics("7361012802036695050", [
flowType: 0, {
onlyAssign: true, flowType: 0,
range: 2, onlyAssign: true,
type: 2 range: 2,
}) type: 2
}
])
).rejects.toThrow("network unavailable"); ).rejects.toThrow("network unavailable");
}); });
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);
});
}); });