feat: streamline export field selection
This commit is contained in:
+24
-49
@@ -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) {
|
||||||
|
activeDialog.focus();
|
||||||
|
return activeDialog.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureResponsiveStyles(document);
|
||||||
|
const fields = listSelectableFields(groups);
|
||||||
|
const selectableHeaderSet = new Set(fields.map((field) => field.header));
|
||||||
const selectedHeaderSet = new Set(
|
const selectedHeaderSet = new Set(
|
||||||
selectedHeaders === undefined
|
selectedHeaders === undefined
|
||||||
? selectableHeaders
|
? fields.map((field) => field.header)
|
||||||
: selectedHeaders.filter((header) => selectableHeaders.includes(header))
|
: selectedHeaders.filter((header) => selectableHeaderSet.has(header))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let resolveDialog: (value: string[] | null) => void = () => undefined;
|
||||||
|
const promise = new Promise<string[] | null>((resolve) => {
|
||||||
|
resolveDialog = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
overlay.dataset.audienceProfileFieldDialog = "overlay";
|
||||||
applyOverlayStyles(overlay);
|
applyOverlayStyles(overlay);
|
||||||
|
|
||||||
const dialog = document.createElement("section");
|
const dialog = document.createElement("section");
|
||||||
|
dialog.dataset.audienceProfileFieldDialog = "dialog";
|
||||||
|
dialog.setAttribute("aria-modal", "true");
|
||||||
|
dialog.setAttribute("role", "dialog");
|
||||||
applyDialogStyles(dialog);
|
applyDialogStyles(dialog);
|
||||||
|
|
||||||
|
const header = document.createElement("header");
|
||||||
|
applyDialogHeaderStyles(header);
|
||||||
|
const headingGroup = document.createElement("div");
|
||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
|
title.id = "sces-audience-profile-field-dialog-title";
|
||||||
|
title.textContent = "选择导出字段";
|
||||||
applyTitleStyles(title);
|
applyTitleStyles(title);
|
||||||
|
dialog.setAttribute("aria-labelledby", title.id);
|
||||||
const hint = document.createElement("p");
|
const hint = document.createElement("p");
|
||||||
hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。";
|
hint.textContent = "基础字段会固定保留。字段设置会用于本次及后续 CSV 导出。";
|
||||||
applyHintStyles(hint);
|
applyHintStyles(hint);
|
||||||
|
headingGroup.append(title, hint);
|
||||||
|
|
||||||
const toolbar = document.createElement("div");
|
const closeButton = document.createElement("button");
|
||||||
applyToolbarStyles(toolbar);
|
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");
|
const selectAllButton = document.createElement("button");
|
||||||
selectAllButton.type = "button";
|
selectAllButton.type = "button";
|
||||||
selectAllButton.textContent = "全选";
|
selectAllButton.textContent = "全选";
|
||||||
applySecondaryButtonStyles(selectAllButton);
|
applyTextButtonStyles(selectAllButton);
|
||||||
|
|
||||||
const resetButton = document.createElement("button");
|
const resetButton = document.createElement("button");
|
||||||
resetButton.type = "button";
|
resetButton.type = "button";
|
||||||
resetButton.textContent = "恢复默认";
|
resetButton.textContent = "恢复默认";
|
||||||
applySecondaryButtonStyles(resetButton);
|
applyTextButtonStyles(resetButton);
|
||||||
|
selectionActions.append(selectAllButton, resetButton);
|
||||||
|
|
||||||
toolbar.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 groupContainer = document.createElement("div");
|
const resultPane = document.createElement("section");
|
||||||
applyGroupContainerStyles(groupContainer);
|
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 fieldInputs: HTMLInputElement[] = [];
|
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) => {
|
groups.forEach((group) => {
|
||||||
|
const groupFields = fields.filter((field) => field.group === group.label);
|
||||||
|
if (groupFields.length === 0) return;
|
||||||
|
|
||||||
const groupSection = document.createElement("section");
|
const groupSection = document.createElement("section");
|
||||||
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
|
groupSection.dataset.audienceProfileFieldDialogGroup = "section";
|
||||||
|
groupSection.dataset.audienceProfileFieldDialogGroupLabel = group.label;
|
||||||
applyGroupSectionStyles(groupSection);
|
applyGroupSectionStyles(groupSection);
|
||||||
|
|
||||||
const groupHeader = document.createElement("label");
|
const groupHeader = document.createElement("label");
|
||||||
applyGroupHeaderStyles(groupHeader);
|
applyGroupHeaderStyles(groupHeader);
|
||||||
|
|
||||||
const groupInput = document.createElement("input");
|
const groupInput = document.createElement("input");
|
||||||
groupInput.type = "checkbox";
|
groupInput.type = "checkbox";
|
||||||
|
groupInput.dataset.audienceProfileFieldDialogGroupToggle = group.label;
|
||||||
|
applyCheckboxStyles(groupInput);
|
||||||
const groupTitle = document.createElement("span");
|
const groupTitle = document.createElement("span");
|
||||||
groupTitle.textContent = group.label;
|
groupTitle.textContent = group.label;
|
||||||
|
|
||||||
groupHeader.append(groupInput, groupTitle);
|
groupHeader.append(groupInput, groupTitle);
|
||||||
|
|
||||||
const fieldList = document.createElement("div");
|
const fieldList = document.createElement("div");
|
||||||
applyFieldListStyles(fieldList);
|
applyFieldListStyles(fieldList);
|
||||||
|
groupFields.forEach((field) => {
|
||||||
const groupFieldInputs = group.headers.map((header) => {
|
|
||||||
const fieldLabel = document.createElement("label");
|
const fieldLabel = document.createElement("label");
|
||||||
|
fieldLabel.dataset.audienceProfileFieldDialogResult = field.header;
|
||||||
|
fieldLabel.dataset.audienceProfileFieldDialogResultGroup = field.group;
|
||||||
applyFieldLabelStyles(fieldLabel);
|
applyFieldLabelStyles(fieldLabel);
|
||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "checkbox";
|
input.type = "checkbox";
|
||||||
input.value = header;
|
input.value = field.header;
|
||||||
input.dataset.audienceProfileFieldDialogField = "checkbox";
|
input.dataset.audienceProfileFieldDialogField = "checkbox";
|
||||||
input.checked = selectedHeaderSet.has(header);
|
applyCheckboxStyles(input);
|
||||||
|
|
||||||
const text = document.createElement("span");
|
const text = document.createElement("span");
|
||||||
text.textContent = header;
|
text.textContent = field.header;
|
||||||
|
|
||||||
fieldLabel.append(input, text);
|
fieldLabel.append(input, text);
|
||||||
fieldList.append(fieldLabel);
|
fieldList.append(fieldLabel);
|
||||||
fieldInputs.push(input);
|
resultInputs.set(field.header, input);
|
||||||
return input;
|
resultItems.set(field.header, fieldLabel);
|
||||||
});
|
|
||||||
|
|
||||||
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", () => {
|
input.addEventListener("change", () => {
|
||||||
syncGroupInput();
|
setFieldSelected(field.header, input.checked);
|
||||||
syncTitle();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
syncGroupInput();
|
|
||||||
|
|
||||||
|
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);
|
groupSection.append(groupHeader, fieldList);
|
||||||
groupContainer.append(groupSection);
|
resultList.append(groupSection);
|
||||||
});
|
});
|
||||||
|
resultPane.append(
|
||||||
|
resultHeader,
|
||||||
|
searchInput,
|
||||||
|
resultList,
|
||||||
|
resultEmptyState
|
||||||
|
);
|
||||||
|
content.append(selectedPane, resultPane);
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("footer");
|
||||||
applyActionsStyles(actions);
|
applyActionsStyles(actions);
|
||||||
|
|
||||||
const cancelButton = document.createElement("button");
|
const cancelButton = document.createElement("button");
|
||||||
cancelButton.type = "button";
|
cancelButton.type = "button";
|
||||||
cancelButton.textContent = "取消";
|
cancelButton.textContent = "取消";
|
||||||
applySecondaryButtonStyles(cancelButton);
|
applySecondaryButtonStyles(cancelButton);
|
||||||
|
|
||||||
const confirmButton = document.createElement("button");
|
const confirmButton = document.createElement("button");
|
||||||
confirmButton.type = "button";
|
confirmButton.type = "button";
|
||||||
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
|
confirmButton.dataset.audienceProfileFieldDialogSave = "button";
|
||||||
confirmButton.textContent = "保存";
|
confirmButton.textContent = "保存";
|
||||||
applyPrimaryButtonStyles(confirmButton);
|
applyPrimaryButtonStyles(confirmButton);
|
||||||
|
|
||||||
actions.append(cancelButton, confirmButton);
|
actions.append(cancelButton, confirmButton);
|
||||||
dialog.append(title, hint, toolbar, groupContainer, actions);
|
|
||||||
|
dialog.append(header, content, actions);
|
||||||
overlay.append(dialog);
|
overlay.append(dialog);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
function syncTitle() {
|
function setFieldSelected(fieldHeader: string, isSelected: boolean): void {
|
||||||
const checkedCount = fieldInputs.filter((input) => input.checked).length;
|
if (isSelected) selectedHeaderSet.add(fieldHeader);
|
||||||
title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`;
|
else selectedHeaderSet.delete(fieldHeader);
|
||||||
|
syncSelectionView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function close(value: string[] | null) {
|
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();
|
overlay.remove();
|
||||||
resolve(value);
|
resolveDialog(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === "Escape") close(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectAllButton.addEventListener("click", () => {
|
selectAllButton.addEventListener("click", () => {
|
||||||
fieldInputs.forEach((input) => {
|
fields.forEach((field) => selectedHeaderSet.add(field.header));
|
||||||
input.checked = true;
|
syncSelectionView();
|
||||||
});
|
|
||||||
syncTitle();
|
|
||||||
syncAllGroupInputs(dialog);
|
|
||||||
});
|
});
|
||||||
resetButton.addEventListener("click", () => {
|
resetButton.addEventListener("click", () => {
|
||||||
fieldInputs.forEach((input) => {
|
selectedHeaderSet.clear();
|
||||||
input.checked = true;
|
fields.forEach((field) => selectedHeaderSet.add(field.header));
|
||||||
});
|
syncSelectionView();
|
||||||
syncTitle();
|
|
||||||
syncAllGroupInputs(dialog);
|
|
||||||
});
|
});
|
||||||
|
searchInput.addEventListener("input", syncSearchResults);
|
||||||
|
closeButton.addEventListener("click", () => close(null));
|
||||||
cancelButton.addEventListener("click", () => close(null));
|
cancelButton.addEventListener("click", () => close(null));
|
||||||
confirmButton.addEventListener("click", () => {
|
confirmButton.addEventListener("click", () => {
|
||||||
const nextHeaders = fieldInputs
|
close(
|
||||||
.filter((input) => input.checked)
|
fields
|
||||||
.map((input) => input.value);
|
.filter((field) => selectedHeaderSet.has(field.header))
|
||||||
close(nextHeaders);
|
.map((field) => field.header)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
overlay.addEventListener("click", (event) => {
|
overlay.addEventListener("click", (event) => {
|
||||||
if (event.target === overlay) {
|
if (event.target === overlay) close(null);
|
||||||
close(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
syncTitle();
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { MetricFilterDefinition } from "./metric-filter";
|
import type { MarketExportScope, MarketExportTarget } from "./types";
|
||||||
import type {
|
|
||||||
MarketExportScope,
|
|
||||||
MarketExportTarget,
|
|
||||||
MetricFilterOperator,
|
|
||||||
MetricFilterRule,
|
|
||||||
MetricThresholdFilter
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onExportAudienceProfile(): Promise<void> | void;
|
onExportAudienceProfile(): Promise<void> | void;
|
||||||
@@ -14,31 +7,6 @@ export interface PluginToolbarHandlers {
|
|||||||
onSubmitBatch(): Promise<void> | void;
|
onSubmitBatch(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetricFilterRuleDom {
|
|
||||||
definition: MetricFilterDefinition;
|
|
||||||
operatorSelect: HTMLSelectElement;
|
|
||||||
removeButton: HTMLButtonElement;
|
|
||||||
root: HTMLElement;
|
|
||||||
thresholdInput: HTMLInputElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricCatalogDom {
|
|
||||||
actions: Map<string, HTMLButtonElement>;
|
|
||||||
addButton: HTMLButtonElement;
|
|
||||||
closeButton: HTMLButtonElement;
|
|
||||||
emptyState: HTMLElement;
|
|
||||||
items: Map<string, HTMLElement>;
|
|
||||||
panel: HTMLElement;
|
|
||||||
root: HTMLElement;
|
|
||||||
searchInput: HTMLInputElement;
|
|
||||||
selectedCount: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolbarResizeListener {
|
|
||||||
handler: EventListener;
|
|
||||||
window: Window;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
audienceProfileExportButton: HTMLButtonElement;
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
@@ -47,18 +15,14 @@ export interface PluginToolbarDom {
|
|||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
exportStatusText: HTMLElement;
|
exportStatusText: HTMLElement;
|
||||||
metricCatalog: MetricCatalogDom;
|
|
||||||
metricRules: Map<string, MetricFilterRuleDom>;
|
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
|
const PLUGIN_ACTION_BUTTON_STYLE_ID = "sces-plugin-action-button-style";
|
||||||
const TOOLBAR_RESIZE_LISTENER = Symbol("sces-toolbar-resize-listener");
|
|
||||||
const TOOLBAR_DOM = Symbol("sces-toolbar-dom");
|
const TOOLBAR_DOM = Symbol("sces-toolbar-dom");
|
||||||
|
|
||||||
type ToolbarRootWithResizeListener = HTMLElement & {
|
type ToolbarRootWithDom = HTMLElement & {
|
||||||
[TOOLBAR_DOM]?: PluginToolbarDom;
|
[TOOLBAR_DOM]?: PluginToolbarDom;
|
||||||
[TOOLBAR_RESIZE_LISTENER]?: ToolbarResizeListener;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isPluginToolbarMounted(
|
export function isPluginToolbarMounted(
|
||||||
@@ -66,50 +30,28 @@ export function isPluginToolbarMounted(
|
|||||||
document: Document
|
document: Document
|
||||||
): boolean {
|
): boolean {
|
||||||
const actionRow = findNativeActionRow(document);
|
const actionRow = findNativeActionRow(document);
|
||||||
const isMounted = Boolean(
|
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
|
||||||
actionRow && root.parentElement === actionRow && !root.hidden
|
|
||||||
);
|
|
||||||
if (!isMounted) {
|
|
||||||
cleanupToolbarResizeListener(root);
|
|
||||||
}
|
|
||||||
return isMounted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensurePluginToolbar(
|
export function ensurePluginToolbar(
|
||||||
document: Document,
|
document: Document,
|
||||||
handlers: PluginToolbarHandlers,
|
handlers: PluginToolbarHandlers
|
||||||
metricDefinitions: MetricFilterDefinition[] = []
|
|
||||||
): PluginToolbarDom {
|
): PluginToolbarDom {
|
||||||
ensurePluginActionButtonTheme(document);
|
ensurePluginActionButtonTheme(document);
|
||||||
|
|
||||||
const catalogVersion = metricDefinitions
|
|
||||||
.map(({ field, group }) => `${group}\u0001${field}`)
|
|
||||||
.join("\u0002");
|
|
||||||
const existingRoot = document.querySelector(
|
const existingRoot = document.querySelector(
|
||||||
"[data-plugin-toolbar='root']"
|
"[data-plugin-toolbar='root']"
|
||||||
) as HTMLElement | null;
|
) as ToolbarRootWithDom | null;
|
||||||
const existingToolbar = (existingRoot as ToolbarRootWithResizeListener | null)?.[
|
const existingToolbar = existingRoot?.[TOOLBAR_DOM];
|
||||||
TOOLBAR_DOM
|
if (existingRoot && existingToolbar) {
|
||||||
];
|
|
||||||
if (
|
|
||||||
existingRoot &&
|
|
||||||
existingToolbar &&
|
|
||||||
existingRoot.dataset.pluginMetricCatalogVersion === catalogVersion &&
|
|
||||||
existingRoot.querySelector('[data-plugin-metric-catalog-trigger="button"]')
|
|
||||||
) {
|
|
||||||
ensureToolbarMounted(existingRoot, document);
|
ensureToolbarMounted(existingRoot, document);
|
||||||
ensureToolbarResizeListener(existingToolbar);
|
|
||||||
return existingToolbar;
|
return existingToolbar;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingRoot) {
|
existingRoot?.remove();
|
||||||
cleanupToolbarResizeListener(existingRoot);
|
|
||||||
existingRoot.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.createElement("section");
|
const root = document.createElement("section") as ToolbarRootWithDom;
|
||||||
root.dataset.pluginToolbar = "root";
|
root.dataset.pluginToolbar = "root";
|
||||||
root.dataset.pluginMetricCatalogVersion = catalogVersion;
|
|
||||||
applyToolbarRootStyles(root);
|
applyToolbarRootStyles(root);
|
||||||
|
|
||||||
const exportRangeSelect = document.createElement("select");
|
const exportRangeSelect = document.createElement("select");
|
||||||
@@ -132,64 +74,56 @@ export function ensurePluginToolbar(
|
|||||||
const audienceProfileExportButton = createActionButton(
|
const audienceProfileExportButton = createActionButton(
|
||||||
document,
|
document,
|
||||||
"导出选中达人数据",
|
"导出选中达人数据",
|
||||||
"pluginExportAudienceProfile",
|
"pluginExportAudienceProfile"
|
||||||
"button"
|
|
||||||
);
|
);
|
||||||
audienceProfileExportButton.title = "仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
audienceProfileExportButton.title = "仅导出已勾选达人,包含内容数据、效果预估、画像等维度";
|
||||||
|
|
||||||
const audienceProfileByIdExportButton = createActionButton(
|
|
||||||
document,
|
|
||||||
"按星图ID导出",
|
|
||||||
"pluginExportAudienceProfileById",
|
|
||||||
"button"
|
|
||||||
);
|
|
||||||
audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
|
||||||
|
|
||||||
const audienceProfileFieldButton = createActionButton(
|
const audienceProfileFieldButton = createActionButton(
|
||||||
document,
|
document,
|
||||||
"选择字段",
|
"选择字段",
|
||||||
"pluginAudienceProfileFields",
|
"pluginAudienceProfileFields"
|
||||||
"button"
|
|
||||||
);
|
);
|
||||||
audienceProfileFieldButton.title = "勾选本次CSV需要导出的字段,设置会自动保存";
|
audienceProfileFieldButton.title = "搜索并勾选本次CSV需要导出的字段,设置会自动保存";
|
||||||
|
|
||||||
|
const audienceProfileByIdExportButton = createActionButton(
|
||||||
|
document,
|
||||||
|
"按星图ID导出",
|
||||||
|
"pluginExportAudienceProfileById"
|
||||||
|
);
|
||||||
|
audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选";
|
||||||
|
|
||||||
const batchSubmitButton = createActionButton(
|
const batchSubmitButton = createActionButton(
|
||||||
document,
|
document,
|
||||||
"提交批次",
|
"提交批次",
|
||||||
"pluginBatchSubmit",
|
"pluginBatchSubmit"
|
||||||
"button"
|
|
||||||
);
|
);
|
||||||
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
|
batchSubmitButton.title = "将当前选中的达人提交到后续业务批次";
|
||||||
|
|
||||||
const exportStatusText = document.createElement("span");
|
const exportStatusText = document.createElement("span");
|
||||||
exportStatusText.dataset.pluginExportStatus = "text";
|
exportStatusText.dataset.pluginExportStatus = "text";
|
||||||
|
exportStatusText.setAttribute("aria-live", "polite");
|
||||||
applyStatusStyles(exportStatusText);
|
applyStatusStyles(exportStatusText);
|
||||||
|
|
||||||
const rulesGroup = document.createElement("div");
|
|
||||||
rulesGroup.dataset.pluginMetricRules = "root";
|
|
||||||
applyRulesGroupStyles(rulesGroup);
|
|
||||||
const metricRules = new Map<string, MetricFilterRuleDom>();
|
|
||||||
const metricCatalog = createMetricCatalog(document, metricDefinitions);
|
|
||||||
|
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.dataset.pluginToolbarPanel = "root";
|
panel.dataset.pluginToolbarPanel = "root";
|
||||||
applyToolbarPanelStyles(panel);
|
applyToolbarPanelStyles(panel);
|
||||||
const firstRow = document.createElement("div");
|
const primaryRow = document.createElement("div");
|
||||||
firstRow.dataset.pluginToolbarRow = "primary";
|
primaryRow.dataset.pluginToolbarRow = "primary";
|
||||||
applyToolbarRowStyles(firstRow);
|
applyToolbarRowStyles(primaryRow);
|
||||||
const secondRow = document.createElement("div");
|
|
||||||
secondRow.dataset.pluginToolbarRow = "thresholds";
|
|
||||||
applyToolbarRowStyles(secondRow);
|
|
||||||
|
|
||||||
const dataGroup = document.createElement("div");
|
const dataGroup = document.createElement("div");
|
||||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||||
applyToolbarGroupStyles(dataGroup);
|
applyToolbarGroupStyles(dataGroup);
|
||||||
const selectedAudienceExportGroup = createToolbarActionGroup(document, "selected-audience-export", [
|
const selectedAudienceExportGroup = createToolbarActionGroup(
|
||||||
|
document,
|
||||||
|
"selected-audience-export",
|
||||||
|
[
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
audienceProfileFieldButton,
|
audienceProfileFieldButton,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput
|
exportCustomPagesInput
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
||||||
audienceProfileByIdExportButton
|
audienceProfileByIdExportButton
|
||||||
]);
|
]);
|
||||||
@@ -204,11 +138,8 @@ export function ensurePluginToolbar(
|
|||||||
batchSubmitGroup
|
batchSubmitGroup
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterTitle = createToolbarGroupTitle(document, "指标筛选");
|
primaryRow.append(dataGroup, exportStatusText);
|
||||||
const filterNote = createFilterNote(document);
|
panel.append(primaryRow);
|
||||||
firstRow.append(dataGroup, exportStatusText);
|
|
||||||
secondRow.append(filterTitle, metricCatalog.root, rulesGroup, filterNote);
|
|
||||||
panel.append(firstRow, secondRow);
|
|
||||||
root.append(panel);
|
root.append(panel);
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
|
|
||||||
@@ -220,11 +151,9 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
metricCatalog,
|
|
||||||
metricRules,
|
|
||||||
root
|
root
|
||||||
} satisfies PluginToolbarDom;
|
} satisfies PluginToolbarDom;
|
||||||
(root as ToolbarRootWithResizeListener)[TOOLBAR_DOM] = toolbarDom;
|
root[TOOLBAR_DOM] = toolbarDom;
|
||||||
|
|
||||||
applyNativeControlStyles(document, toolbarDom);
|
applyNativeControlStyles(document, toolbarDom);
|
||||||
ensureToolbarMounted(root, document);
|
ensureToolbarMounted(root, document);
|
||||||
@@ -244,42 +173,65 @@ export function ensurePluginToolbar(
|
|||||||
exportRangeSelect.addEventListener("change", () => {
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
syncCustomPagesInputVisibility(toolbarDom);
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
});
|
});
|
||||||
metricCatalog.addButton.addEventListener("click", () => {
|
|
||||||
setMetricCatalogOpen(metricCatalog, metricCatalog.panel.hidden);
|
|
||||||
});
|
|
||||||
metricCatalog.closeButton.addEventListener("click", () => {
|
|
||||||
setMetricCatalogOpen(metricCatalog, false);
|
|
||||||
});
|
|
||||||
metricCatalog.searchInput.addEventListener("input", () => {
|
|
||||||
syncMetricCatalogSearch(metricCatalog);
|
|
||||||
});
|
|
||||||
metricDefinitions.forEach((definition) => {
|
|
||||||
const action = metricCatalog.actions.get(definition.field);
|
|
||||||
action?.addEventListener("click", () => {
|
|
||||||
addMetricRule(toolbarDom, definition, rulesGroup);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ensureToolbarResizeListener(toolbarDom);
|
|
||||||
syncCustomPagesInputVisibility(toolbarDom);
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
syncMetricCatalog(toolbarDom);
|
|
||||||
return toolbarDom;
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readToolbarExportTarget(
|
||||||
|
toolbar: PluginToolbarDom
|
||||||
|
): { error?: string; target?: MarketExportTarget } {
|
||||||
|
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
|
||||||
|
if (scope === "all") return { target: { mode: "all" } };
|
||||||
|
if (scope === "current") return { target: { mode: "count", pageCount: 1 } };
|
||||||
|
if (scope === "first-5") return { target: { mode: "count", pageCount: 5 } };
|
||||||
|
if (scope === "first-10") return { target: { mode: "count", pageCount: 10 } };
|
||||||
|
const pageCount = Number(toolbar.exportCustomPagesInput.value);
|
||||||
|
return Number.isInteger(pageCount) && pageCount >= 1
|
||||||
|
? { target: { mode: "count", pageCount } }
|
||||||
|
: { error: "请输入有效页数" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToolbarBusyState(
|
||||||
|
toolbar: PluginToolbarDom,
|
||||||
|
isBusy: boolean
|
||||||
|
): void {
|
||||||
|
[
|
||||||
|
toolbar.batchSubmitButton,
|
||||||
|
toolbar.audienceProfileFieldButton,
|
||||||
|
toolbar.audienceProfileByIdExportButton,
|
||||||
|
toolbar.audienceProfileExportButton,
|
||||||
|
toolbar.exportRangeSelect,
|
||||||
|
toolbar.exportCustomPagesInput
|
||||||
|
].forEach((element) => {
|
||||||
|
element.disabled = isBusy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToolbarExportStatus(
|
||||||
|
toolbar: PluginToolbarDom,
|
||||||
|
text: string
|
||||||
|
): void {
|
||||||
|
toolbar.exportStatusText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
function createActionButton(
|
function createActionButton(
|
||||||
document: Document,
|
document: Document,
|
||||||
label: string,
|
label: string,
|
||||||
datasetKey: string,
|
datasetKey: string
|
||||||
datasetValue: string
|
|
||||||
): HTMLButtonElement {
|
): HTMLButtonElement {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.dataset[datasetKey] = datasetValue;
|
button.dataset[datasetKey] = "button";
|
||||||
button.textContent = label;
|
button.textContent = label;
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendOption(select: HTMLSelectElement, value: string, label: string): void {
|
function appendOption(
|
||||||
|
select: HTMLSelectElement,
|
||||||
|
value: string,
|
||||||
|
label: string
|
||||||
|
): void {
|
||||||
const option = select.ownerDocument.createElement("option");
|
const option = select.ownerDocument.createElement("option");
|
||||||
option.value = value;
|
option.value = value;
|
||||||
option.textContent = label;
|
option.textContent = label;
|
||||||
@@ -306,290 +258,10 @@ function createToolbarActionDivider(document: Document): HTMLElement {
|
|||||||
return divider;
|
return divider;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMetricCatalog(
|
|
||||||
document: Document,
|
|
||||||
definitions: MetricFilterDefinition[]
|
|
||||||
): MetricCatalogDom {
|
|
||||||
const root = document.createElement("div");
|
|
||||||
root.dataset.pluginMetricCatalog = "root";
|
|
||||||
applyMetricCatalogStyles(root);
|
|
||||||
const addButton = document.createElement("button");
|
|
||||||
addButton.type = "button";
|
|
||||||
addButton.dataset.pluginMetricCatalogTrigger = "button";
|
|
||||||
addButton.textContent = "添加筛选指标";
|
|
||||||
addButton.setAttribute("aria-expanded", "false");
|
|
||||||
applyMetricCatalogTriggerStyles(addButton);
|
|
||||||
const selectedCount = document.createElement("span");
|
|
||||||
selectedCount.dataset.pluginMetricSelectedCount = "text";
|
|
||||||
selectedCount.setAttribute("aria-live", "polite");
|
|
||||||
applyMetricCatalogCountStyles(selectedCount);
|
|
||||||
const panel = document.createElement("div");
|
|
||||||
panel.dataset.pluginMetricCatalogPanel = "root";
|
|
||||||
panel.hidden = true;
|
|
||||||
applyMetricCatalogPanelStyles(panel);
|
|
||||||
|
|
||||||
const panelHeader = document.createElement("div");
|
|
||||||
applyMetricCatalogHeaderStyles(panelHeader);
|
|
||||||
const searchInput = document.createElement("input");
|
|
||||||
searchInput.type = "search";
|
|
||||||
searchInput.placeholder = "搜索指标";
|
|
||||||
searchInput.dataset.pluginMetricCatalogSearch = "input";
|
|
||||||
searchInput.setAttribute("aria-label", "搜索筛选指标");
|
|
||||||
applyMetricCatalogSearchStyles(searchInput);
|
|
||||||
const closeButton = document.createElement("button");
|
|
||||||
closeButton.type = "button";
|
|
||||||
closeButton.dataset.pluginMetricCatalogClose = "button";
|
|
||||||
closeButton.textContent = "×";
|
|
||||||
closeButton.title = "关闭指标目录";
|
|
||||||
closeButton.setAttribute("aria-label", "关闭指标目录");
|
|
||||||
applyMetricCatalogCloseButtonStyles(closeButton);
|
|
||||||
panelHeader.append(searchInput, closeButton);
|
|
||||||
|
|
||||||
const actions = new Map<string, HTMLButtonElement>();
|
|
||||||
const items = new Map<string, HTMLElement>();
|
|
||||||
const definitionsByGroup = new Map<string, MetricFilterDefinition[]>();
|
|
||||||
definitions.forEach((definition) => {
|
|
||||||
const group = definitionsByGroup.get(definition.group) ?? [];
|
|
||||||
group.push(definition);
|
|
||||||
definitionsByGroup.set(definition.group, group);
|
|
||||||
});
|
|
||||||
const groupsRoot = document.createElement("div");
|
|
||||||
groupsRoot.dataset.pluginMetricCatalogGroups = "root";
|
|
||||||
applyMetricCatalogGroupsStyles(groupsRoot);
|
|
||||||
definitionsByGroup.forEach((groupDefinitions, groupLabel) => {
|
|
||||||
const group = document.createElement("section");
|
|
||||||
group.dataset.pluginMetricCatalogGroup = groupLabel;
|
|
||||||
applyMetricCatalogGroupStyles(group);
|
|
||||||
const groupTitle = document.createElement("strong");
|
|
||||||
groupTitle.textContent = groupLabel;
|
|
||||||
applyMetricCatalogGroupTitleStyles(groupTitle);
|
|
||||||
group.append(groupTitle);
|
|
||||||
groupDefinitions.forEach((definition) => {
|
|
||||||
const item = document.createElement("div");
|
|
||||||
item.dataset.pluginMetricCatalogItem = definition.field;
|
|
||||||
item.dataset.pluginMetricCatalogGroupItem = groupLabel;
|
|
||||||
applyMetricCatalogItemStyles(item);
|
|
||||||
const name = document.createElement("span");
|
|
||||||
name.textContent = definition.field;
|
|
||||||
const action = document.createElement("button");
|
|
||||||
action.type = "button";
|
|
||||||
action.dataset.pluginMetricCatalogAction = definition.field;
|
|
||||||
applyMetricCatalogActionStyles(action);
|
|
||||||
item.append(name, action);
|
|
||||||
group.append(item);
|
|
||||||
actions.set(definition.field, action);
|
|
||||||
items.set(definition.field, item);
|
|
||||||
});
|
|
||||||
groupsRoot.append(group);
|
|
||||||
});
|
|
||||||
const emptyState = document.createElement("p");
|
|
||||||
emptyState.textContent = "没有匹配的数值指标";
|
|
||||||
emptyState.hidden = true;
|
|
||||||
applyMetricCatalogEmptyStateStyles(emptyState);
|
|
||||||
panel.append(panelHeader, groupsRoot, emptyState);
|
|
||||||
root.append(addButton, selectedCount, panel);
|
|
||||||
return { actions, addButton, closeButton, emptyState, items, panel, root, searchInput, selectedCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
function addMetricRule(
|
|
||||||
toolbar: PluginToolbarDom,
|
|
||||||
definition: MetricFilterDefinition,
|
|
||||||
rulesGroup: HTMLElement
|
|
||||||
): void {
|
|
||||||
if (toolbar.metricRules.has(definition.field)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rule = createMetricFilterRuleDom(toolbar.root.ownerDocument, definition);
|
|
||||||
toolbar.metricRules.set(definition.field, rule);
|
|
||||||
rulesGroup.appendChild(rule.root);
|
|
||||||
rule.removeButton.addEventListener("click", () => {
|
|
||||||
toolbar.metricRules.delete(definition.field);
|
|
||||||
rule.root.remove();
|
|
||||||
syncMetricCatalog(toolbar);
|
|
||||||
});
|
|
||||||
applyRuleControlStyles(toolbar.root.ownerDocument, rule);
|
|
||||||
syncMetricCatalog(toolbar);
|
|
||||||
setMetricCatalogOpen(toolbar.metricCatalog, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMetricFilterRuleDom(
|
|
||||||
document: Document,
|
|
||||||
definition: MetricFilterDefinition
|
|
||||||
): MetricFilterRuleDom {
|
|
||||||
const root = document.createElement("div");
|
|
||||||
root.dataset.pluginMetricFilterRule = definition.field;
|
|
||||||
applyMetricFilterRuleStyles(root);
|
|
||||||
const label = document.createElement("strong");
|
|
||||||
label.textContent = definition.field;
|
|
||||||
label.title = definition.field;
|
|
||||||
applyMetricFilterRuleLabelStyles(label);
|
|
||||||
const thresholdControl = document.createElement("label");
|
|
||||||
thresholdControl.dataset.pluginMetricFilterThresholdControl = definition.field;
|
|
||||||
applyMetricThresholdControlStyles(thresholdControl);
|
|
||||||
const operatorSelect = document.createElement("select");
|
|
||||||
operatorSelect.dataset.pluginMetricFilterOperator = definition.field;
|
|
||||||
operatorSelect.setAttribute("aria-label", `${definition.field}比较方式`);
|
|
||||||
appendOption(operatorSelect, "gte", "≥");
|
|
||||||
appendOption(operatorSelect, "lte", "≤");
|
|
||||||
const thresholdInput = document.createElement("input");
|
|
||||||
thresholdInput.type = "number";
|
|
||||||
thresholdInput.step = "any";
|
|
||||||
thresholdInput.dataset.pluginMetricFilterThreshold = definition.field;
|
|
||||||
thresholdInput.setAttribute("aria-label", `${definition.field}筛选数值`);
|
|
||||||
thresholdInput.placeholder = "数值";
|
|
||||||
thresholdControl.append(operatorSelect, thresholdInput);
|
|
||||||
const removeButton = document.createElement("button");
|
|
||||||
removeButton.type = "button";
|
|
||||||
removeButton.dataset.pluginMetricFilterRemove = definition.field;
|
|
||||||
removeButton.textContent = "×";
|
|
||||||
removeButton.title = `删除${definition.field}筛选`;
|
|
||||||
removeButton.setAttribute("aria-label", `删除${definition.field}筛选`);
|
|
||||||
applyMetricFilterRemoveButtonStyles(removeButton);
|
|
||||||
root.append(label, thresholdControl, removeButton);
|
|
||||||
return { definition, operatorSelect, removeButton, root, thresholdInput };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFilterNote(document: Document): HTMLElement {
|
|
||||||
const note = document.createElement("span");
|
|
||||||
note.dataset.pluginMetricFilterNote = "and";
|
|
||||||
note.textContent = "全部规则都达标才保留达人";
|
|
||||||
note.hidden = true;
|
|
||||||
applyFilterNoteStyles(note);
|
|
||||||
return note;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readToolbarExportTarget(
|
|
||||||
toolbar: PluginToolbarDom
|
|
||||||
): { error?: string; target?: MarketExportTarget } {
|
|
||||||
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
|
|
||||||
if (scope === "all") return { target: { mode: "all" } };
|
|
||||||
if (scope === "current") return { target: { mode: "count", pageCount: 1 } };
|
|
||||||
if (scope === "first-5") return { target: { mode: "count", pageCount: 5 } };
|
|
||||||
if (scope === "first-10") return { target: { mode: "count", pageCount: 10 } };
|
|
||||||
const pageCount = Number(toolbar.exportCustomPagesInput.value);
|
|
||||||
return Number.isInteger(pageCount) && pageCount >= 1
|
|
||||||
? { target: { mode: "count", pageCount } }
|
|
||||||
: { error: "请输入有效页数" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readToolbarSpreadFilter(
|
|
||||||
toolbar: PluginToolbarDom
|
|
||||||
): { error?: string; filter?: MetricThresholdFilter } {
|
|
||||||
const rules: MetricFilterRule[] = [];
|
|
||||||
for (const rule of toolbar.metricRules.values()) {
|
|
||||||
clearMetricRuleValidation(rule);
|
|
||||||
const rawValue = rule.thresholdInput.value.trim();
|
|
||||||
const threshold = Number(rawValue);
|
|
||||||
if (!rawValue || !Number.isFinite(threshold) || threshold < 0) {
|
|
||||||
markMetricRuleInvalid(rule);
|
|
||||||
return { error: `请输入有效的${rule.definition.field}筛选数值` };
|
|
||||||
}
|
|
||||||
rules.push({
|
|
||||||
field: rule.definition.field,
|
|
||||||
operator: rule.operatorSelect.value === "lte" ? "lte" : "gte",
|
|
||||||
threshold
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { filter: { rules } };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToolbarBusyState(toolbar: PluginToolbarDom, isBusy: boolean): void {
|
|
||||||
[
|
|
||||||
toolbar.batchSubmitButton,
|
|
||||||
toolbar.audienceProfileFieldButton,
|
|
||||||
toolbar.audienceProfileByIdExportButton,
|
|
||||||
toolbar.audienceProfileExportButton,
|
|
||||||
toolbar.exportRangeSelect,
|
|
||||||
toolbar.exportCustomPagesInput,
|
|
||||||
toolbar.metricCatalog.addButton,
|
|
||||||
toolbar.metricCatalog.closeButton,
|
|
||||||
toolbar.metricCatalog.searchInput,
|
|
||||||
...toolbar.metricCatalog.actions.values(),
|
|
||||||
...Array.from(toolbar.metricRules.values()).flatMap((rule) => [
|
|
||||||
rule.operatorSelect,
|
|
||||||
rule.thresholdInput,
|
|
||||||
rule.removeButton
|
|
||||||
])
|
|
||||||
].forEach((element) => {
|
|
||||||
element.disabled = isBusy;
|
|
||||||
});
|
|
||||||
if (!isBusy) syncMetricCatalog(toolbar);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToolbarExportStatus(toolbar: PluginToolbarDom, text: string): void {
|
|
||||||
toolbar.exportStatusText.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||||
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
|
toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom";
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncMetricCatalog(toolbar: PluginToolbarDom): void {
|
|
||||||
toolbar.metricCatalog.selectedCount.textContent = `已选 ${toolbar.metricRules.size} 项`;
|
|
||||||
toolbar.metricCatalog.actions.forEach((action, field) => {
|
|
||||||
const isSelected = toolbar.metricRules.has(field);
|
|
||||||
action.disabled = isSelected;
|
|
||||||
action.textContent = isSelected ? "已添加" : "添加";
|
|
||||||
});
|
|
||||||
syncMetricCatalogSearch(toolbar.metricCatalog);
|
|
||||||
const note = toolbar.root.querySelector('[data-plugin-metric-filter-note="and"]') as HTMLElement | null;
|
|
||||||
if (note) note.hidden = toolbar.metricRules.size < 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncMetricCatalogSearch(catalog: MetricCatalogDom): void {
|
|
||||||
const query = catalog.searchInput.value.trim().toLocaleLowerCase();
|
|
||||||
const visibleGroups = new Set<string>();
|
|
||||||
catalog.items.forEach((item, field) => {
|
|
||||||
const isVisible = !query || field.toLocaleLowerCase().includes(query);
|
|
||||||
item.hidden = !isVisible;
|
|
||||||
if (isVisible && item.dataset.pluginMetricCatalogGroupItem) {
|
|
||||||
visibleGroups.add(item.dataset.pluginMetricCatalogGroupItem);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
catalog.root.querySelectorAll<HTMLElement>("[data-plugin-metric-catalog-group]").forEach((group) => {
|
|
||||||
group.hidden = !visibleGroups.has(group.dataset.pluginMetricCatalogGroup ?? "");
|
|
||||||
});
|
|
||||||
catalog.emptyState.hidden = visibleGroups.size > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMetricCatalogOpen(catalog: MetricCatalogDom, isOpen: boolean): void {
|
|
||||||
catalog.panel.hidden = !isOpen;
|
|
||||||
catalog.addButton.setAttribute("aria-expanded", String(isOpen));
|
|
||||||
if (isOpen) catalog.searchInput.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearMetricRuleValidation(rule: MetricFilterRuleDom): void {
|
|
||||||
delete rule.root.dataset.pluginMetricFilterRuleInvalid;
|
|
||||||
rule.thresholdInput.removeAttribute("aria-invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
function markMetricRuleInvalid(rule: MetricFilterRuleDom): void {
|
|
||||||
rule.root.dataset.pluginMetricFilterRuleInvalid = "true";
|
|
||||||
rule.thresholdInput.setAttribute("aria-invalid", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureToolbarResizeListener(toolbar: PluginToolbarDom): void {
|
|
||||||
const root = toolbar.root as ToolbarRootWithResizeListener;
|
|
||||||
const currentWindow = root.ownerDocument.defaultView;
|
|
||||||
const existingListener = root[TOOLBAR_RESIZE_LISTENER];
|
|
||||||
if (!currentWindow) {
|
|
||||||
cleanupToolbarResizeListener(root);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (existingListener?.window === currentWindow) return;
|
|
||||||
if (existingListener) existingListener.window.removeEventListener("resize", existingListener.handler);
|
|
||||||
const handler = existingListener?.handler ?? (() => syncMetricCatalog(toolbar));
|
|
||||||
root[TOOLBAR_RESIZE_LISTENER] = { handler, window: currentWindow };
|
|
||||||
currentWindow.addEventListener("resize", handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupToolbarResizeListener(root: HTMLElement): void {
|
|
||||||
const listener = (root as ToolbarRootWithResizeListener)[TOOLBAR_RESIZE_LISTENER];
|
|
||||||
if (!listener) return;
|
|
||||||
listener.window.removeEventListener("resize", listener.handler);
|
|
||||||
delete (root as ToolbarRootWithResizeListener)[TOOLBAR_RESIZE_LISTENER];
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||||
const actionRow = findNativeActionRow(document);
|
const actionRow = findNativeActionRow(document);
|
||||||
if (!actionRow) {
|
if (!actionRow) {
|
||||||
@@ -597,7 +269,9 @@ function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
|
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
|
||||||
const anchor = customizeButton ? findDirectChildAnchor(actionRow, customizeButton) : null;
|
const anchor = customizeButton
|
||||||
|
? findDirectChildAnchor(actionRow, customizeButton)
|
||||||
|
: null;
|
||||||
if (anchor) actionRow.insertBefore(root, anchor);
|
if (anchor) actionRow.insertBefore(root, anchor);
|
||||||
else if (root.parentElement !== actionRow) actionRow.prepend(root);
|
else if (root.parentElement !== actionRow) actionRow.prepend(root);
|
||||||
root.hidden = false;
|
root.hidden = false;
|
||||||
@@ -611,24 +285,30 @@ function findNativeActionRow(document: Document): HTMLElement | null {
|
|||||||
customizeButton && exportButton
|
customizeButton && exportButton
|
||||||
? findSmallestSharedActionRow(customizeButton, exportButton, header)
|
? findSmallestSharedActionRow(customizeButton, exportButton, header)
|
||||||
: null;
|
: null;
|
||||||
if (sharedActionRow) {
|
if (sharedActionRow) return sharedActionRow;
|
||||||
return sharedActionRow;
|
|
||||||
}
|
|
||||||
const scope = header ?? document;
|
const scope = header ?? document;
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header")
|
scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header")
|
||||||
).filter((element): element is HTMLElement =>
|
).filter((element): element is HTMLElement =>
|
||||||
element instanceof document.defaultView!.HTMLElement
|
element instanceof document.defaultView!.HTMLElement
|
||||||
);
|
);
|
||||||
return candidates
|
return (
|
||||||
|
candidates
|
||||||
.filter((candidate) =>
|
.filter((candidate) =>
|
||||||
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
|
||||||
)
|
)
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const depthDelta =
|
const depthDelta =
|
||||||
getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header);
|
getDepthWithinAncestor(right, header) -
|
||||||
return depthDelta || normalizeText(left.textContent).length - normalizeText(right.textContent).length;
|
getDepthWithinAncestor(left, header);
|
||||||
})[0] ?? null;
|
return (
|
||||||
|
depthDelta ||
|
||||||
|
normalizeText(left.textContent).length -
|
||||||
|
normalizeText(right.textContent).length
|
||||||
|
);
|
||||||
|
})[0] ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findHeaderContainer(
|
function findHeaderContainer(
|
||||||
@@ -685,7 +365,10 @@ function isNativeActionRowCandidate(
|
|||||||
...Array.from(child.querySelectorAll("button"))
|
...Array.from(child.querySelectorAll("button"))
|
||||||
])
|
])
|
||||||
.map((button) => normalizeText(button.textContent));
|
.map((button) => normalizeText(button.textContent));
|
||||||
return labels.includes("导出") && (labels.includes("自定义指标") || Boolean(customizeButton));
|
return (
|
||||||
|
labels.includes("导出") &&
|
||||||
|
(labels.includes("自定义指标") || Boolean(customizeButton))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDepthWithinAncestor(
|
function getDepthWithinAncestor(
|
||||||
@@ -701,18 +384,29 @@ function getDepthWithinAncestor(
|
|||||||
return depth;
|
return depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findNativeActionButton(root: ParentNode, text: string): HTMLButtonElement | null {
|
function findNativeActionButton(
|
||||||
|
root: ParentNode,
|
||||||
|
text: string
|
||||||
|
): HTMLButtonElement | null {
|
||||||
const document = root instanceof Document ? root : root.ownerDocument;
|
const document = root instanceof Document ? root : root.ownerDocument;
|
||||||
if (!document) return null;
|
if (!document) return null;
|
||||||
return Array.from(root.querySelectorAll("button")).find(
|
return (
|
||||||
|
Array.from(root.querySelectorAll("button")).find(
|
||||||
(element): element is HTMLButtonElement =>
|
(element): element is HTMLButtonElement =>
|
||||||
element instanceof document.defaultView!.HTMLButtonElement && normalizeText(element.textContent) === text
|
element instanceof document.defaultView!.HTMLButtonElement &&
|
||||||
) ?? null;
|
normalizeText(element.textContent) === text
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findDirectChildAnchor(parent: HTMLElement, target: HTMLElement): Element | null {
|
function findDirectChildAnchor(
|
||||||
|
parent: HTMLElement,
|
||||||
|
target: HTMLElement
|
||||||
|
): Element | null {
|
||||||
let current: Element | null = target;
|
let current: Element | null = target;
|
||||||
while (current?.parentElement && current.parentElement !== parent) current = current.parentElement;
|
while (current?.parentElement && current.parentElement !== parent) {
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
return current?.parentElement === parent ? current : null;
|
return current?.parentElement === parent ? current : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,172 +415,167 @@ function normalizeText(value: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarRootStyles(root: HTMLElement): void {
|
function applyToolbarRootStyles(root: HTMLElement): void {
|
||||||
Object.assign(root.style, { alignItems: "center", columnGap: "10px", display: "inline-flex", flex: "1 1 auto", flexWrap: "nowrap", minWidth: "0" });
|
Object.assign(root.style, {
|
||||||
|
alignItems: "center",
|
||||||
|
columnGap: "10px",
|
||||||
|
display: "inline-flex",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
minWidth: "0"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
||||||
Object.assign(panel.style, { alignItems: "center", display: "flex", flex: "1 1 auto", flexDirection: "column", gap: "6px", minWidth: "0", overflowX: "visible", overflowY: "visible", padding: "0" });
|
Object.assign(panel.style, {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minWidth: "0",
|
||||||
|
overflow: "visible",
|
||||||
|
padding: "0"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarRowStyles(row: HTMLElement): void {
|
function applyToolbarRowStyles(row: HTMLElement): void {
|
||||||
Object.assign(row.style, { alignItems: "center", display: "flex", flexWrap: "nowrap", gap: "6px", justifyContent: "flex-start", minHeight: "32px", minWidth: "0", width: "100%" });
|
Object.assign(row.style, {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
gap: "8px",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
minHeight: "32px",
|
||||||
|
minWidth: "0",
|
||||||
|
width: "100%"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarGroupStyles(group: HTMLElement): void {
|
function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||||
Object.assign(group.style, { alignItems: "center", display: "flex", flex: "0 0 auto", flexWrap: "nowrap", gap: "8px", minWidth: "0" });
|
Object.assign(group.style, {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
gap: "8px",
|
||||||
|
minWidth: "0"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarActionGroupStyles(group: HTMLElement): void {
|
function applyToolbarActionGroupStyles(group: HTMLElement): void {
|
||||||
Object.assign(group.style, { alignItems: "center", display: "flex", flex: "0 0 auto", flexWrap: "nowrap", gap: "8px" });
|
Object.assign(group.style, {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
gap: "8px"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToolbarActionDividerStyles(divider: HTMLElement): void {
|
function applyToolbarActionDividerStyles(divider: HTMLElement): void {
|
||||||
Object.assign(divider.style, { background: "#d0d5dd", flex: "0 0 auto", height: "20px", width: "1px" });
|
Object.assign(divider.style, {
|
||||||
|
background: "#d0d5dd",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
height: "20px",
|
||||||
|
width: "1px"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMetricCatalogStyles(catalog: HTMLElement): void {
|
function applyNativeControlStyles(
|
||||||
Object.assign(catalog.style, { alignItems: "center", display: "inline-flex", flex: "0 0 auto", gap: "8px", position: "relative" });
|
document: Document,
|
||||||
}
|
toolbar: PluginToolbarDom
|
||||||
|
): void {
|
||||||
function applyMetricCatalogTriggerStyles(button: HTMLButtonElement): void {
|
const nativeButton =
|
||||||
applySecondaryButtonStyles(button);
|
findNativeActionButton(document, "自定义指标") ??
|
||||||
Object.assign(button.style, { fontSize: "12px", height: "32px", padding: "0 10px" });
|
findNativeActionButton(document, "导出");
|
||||||
}
|
[
|
||||||
|
toolbar.audienceProfileExportButton,
|
||||||
function applyMetricCatalogCountStyles(count: HTMLElement): void {
|
toolbar.audienceProfileByIdExportButton,
|
||||||
Object.assign(count.style, { color: "#64748b", fontSize: "12px", fontWeight: "700", whiteSpace: "nowrap" });
|
toolbar.audienceProfileFieldButton,
|
||||||
}
|
toolbar.batchSubmitButton
|
||||||
|
].forEach((button) => {
|
||||||
function applyMetricCatalogPanelStyles(panel: HTMLElement): void {
|
|
||||||
Object.assign(panel.style, { background: "#ffffff", border: "1px solid #cbd5e1", borderRadius: "6px", boxShadow: "0 10px 20px rgba(15, 23, 42, 0.14)", left: "0", maxHeight: "420px", overflow: "auto", padding: "10px", position: "absolute", top: "calc(100% + 6px)", width: "420px", zIndex: "100" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogHeaderStyles(header: HTMLElement): void {
|
|
||||||
Object.assign(header.style, { alignItems: "center", display: "flex", gap: "8px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogSearchStyles(input: HTMLInputElement): void {
|
|
||||||
Object.assign(input.style, { border: "1px solid #cbd5e1", borderRadius: "5px", flex: "1 1 auto", height: "30px", minWidth: "0", padding: "0 8px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogCloseButtonStyles(button: HTMLButtonElement): void {
|
|
||||||
Object.assign(button.style, { background: "transparent", border: "0", color: "#64748b", fontSize: "20px", height: "30px", padding: "0", width: "30px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogGroupsStyles(groups: HTMLElement): void {
|
|
||||||
Object.assign(groups.style, { display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogGroupStyles(group: HTMLElement): void {
|
|
||||||
Object.assign(group.style, { display: "flex", flexDirection: "column", gap: "4px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogGroupTitleStyles(title: HTMLElement): void {
|
|
||||||
Object.assign(title.style, { color: "#64748b", fontSize: "12px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogItemStyles(item: HTMLElement): void {
|
|
||||||
Object.assign(item.style, { alignItems: "center", color: "#334155", display: "flex", fontSize: "13px", gap: "10px", justifyContent: "space-between", minHeight: "32px" });
|
|
||||||
const name = item.firstElementChild as HTMLElement | null;
|
|
||||||
if (name) Object.assign(name.style, { minWidth: "0", overflowWrap: "anywhere" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogActionStyles(button: HTMLButtonElement): void {
|
|
||||||
Object.assign(button.style, { background: "#ffffff", border: "1px solid #94a3b8", borderRadius: "5px", color: "#334155", flex: "0 0 auto", fontSize: "12px", height: "28px", minWidth: "56px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricCatalogEmptyStateStyles(state: HTMLElement): void {
|
|
||||||
Object.assign(state.style, { color: "#64748b", fontSize: "13px", margin: "14px 0 4px", textAlign: "center" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRulesGroupStyles(group: HTMLElement): void {
|
|
||||||
Object.assign(group.style, { alignItems: "stretch", display: "flex", flex: "1 1 auto", flexDirection: "column", gap: "6px", minWidth: "0", overflow: "visible" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricFilterRuleStyles(rule: HTMLElement): void {
|
|
||||||
Object.assign(rule.style, { alignItems: "center", display: "flex", flexWrap: "wrap", gap: "7px", minHeight: "32px", minWidth: "0" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricFilterRuleLabelStyles(label: HTMLElement): void {
|
|
||||||
Object.assign(label.style, { color: "#344054", flex: "0 1 420px", fontSize: "12px", fontWeight: "800", lineHeight: "18px", minWidth: "180px", overflowWrap: "anywhere" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricThresholdControlStyles(control: HTMLElement): void {
|
|
||||||
Object.assign(control.style, { alignItems: "center", background: "#ffffff", border: "1px solid #dbe2ec", borderRadius: "6px", display: "flex", flex: "0 0 auto", gap: "6px", height: "32px", padding: "0 8px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyMetricFilterRemoveButtonStyles(button: HTMLButtonElement): void {
|
|
||||||
Object.assign(button.style, { background: "transparent", border: "0", borderRadius: "4px", color: "#b91c1c", fontSize: "20px", height: "28px", padding: "0", width: "28px" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilterNoteStyles(note: HTMLElement): void {
|
|
||||||
Object.assign(note.style, { color: "#0f8a5f", flex: "0 0 auto", fontSize: "12px", fontWeight: "800", whiteSpace: "nowrap" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
|
||||||
const title = document.createElement("span");
|
|
||||||
title.dataset.pluginToolbarTitle = label;
|
|
||||||
title.textContent = label;
|
|
||||||
Object.assign(title.style, { alignItems: "center", background: "#eef5ff", border: "1px solid #cfe0ff", borderRadius: "8px", color: "#2563eb", display: "flex", flex: "0 0 auto", fontSize: "12px", fontWeight: "900", height: "32px", padding: "0 10px", whiteSpace: "nowrap" });
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyNativeControlStyles(document: Document, toolbar: PluginToolbarDom): void {
|
|
||||||
const nativeButton = findNativeActionButton(document, "自定义指标") ?? findNativeActionButton(document, "导出");
|
|
||||||
[toolbar.audienceProfileExportButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileFieldButton, toolbar.batchSubmitButton].forEach((button) => {
|
|
||||||
if (nativeButton) button.className = nativeButton.className;
|
if (nativeButton) button.className = nativeButton.className;
|
||||||
});
|
});
|
||||||
[toolbar.audienceProfileExportButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileFieldButton].forEach(applySecondaryButtonStyles);
|
[
|
||||||
|
toolbar.audienceProfileExportButton,
|
||||||
|
toolbar.audienceProfileByIdExportButton,
|
||||||
|
toolbar.audienceProfileFieldButton
|
||||||
|
].forEach(applySecondaryButtonStyles);
|
||||||
applyPrimaryButtonStyles(toolbar.batchSubmitButton);
|
applyPrimaryButtonStyles(toolbar.batchSubmitButton);
|
||||||
[toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach(applyInputStyles);
|
[toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach(
|
||||||
|
applyInputStyles
|
||||||
|
);
|
||||||
toolbar.exportRangeSelect.style.minWidth = "104px";
|
toolbar.exportRangeSelect.style.minWidth = "104px";
|
||||||
toolbar.exportCustomPagesInput.style.width = "72px";
|
toolbar.exportCustomPagesInput.style.width = "72px";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRuleControlStyles(document: Document, rule: MetricFilterRuleDom): void {
|
|
||||||
applyInputStyles(rule.operatorSelect);
|
|
||||||
applyInputStyles(rule.thresholdInput);
|
|
||||||
rule.operatorSelect.style.height = "26px";
|
|
||||||
rule.operatorSelect.style.minWidth = "50px";
|
|
||||||
rule.operatorSelect.style.padding = "0 2px";
|
|
||||||
rule.thresholdInput.style.border = "0";
|
|
||||||
rule.thresholdInput.style.height = "26px";
|
|
||||||
rule.thresholdInput.style.minWidth = "76px";
|
|
||||||
rule.thresholdInput.style.outline = "0";
|
|
||||||
rule.thresholdInput.style.padding = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
function applyPrimaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
Object.assign(button.style, { backgroundColor: "#7f1d2d", border: "1px solid #7f1d2d", borderRadius: "8px", boxSizing: "border-box", color: "#ffffff", fontWeight: "600", height: "32px", padding: "0 15px", whiteSpace: "nowrap" });
|
Object.assign(button.style, {
|
||||||
|
backgroundColor: "#7f1d2d",
|
||||||
|
border: "1px solid #7f1d2d",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: "600",
|
||||||
|
height: "32px",
|
||||||
|
padding: "0 15px",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
function applySecondaryButtonStyles(button: HTMLButtonElement): void {
|
||||||
Object.assign(button.style, { backgroundColor: "#ffffff", border: "1px solid #cbd5e1", borderRadius: "8px", boxSizing: "border-box", color: "#344054", fontWeight: "600", height: "32px", padding: "0 15px", whiteSpace: "nowrap" });
|
Object.assign(button.style, {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
color: "#344054",
|
||||||
|
fontWeight: "600",
|
||||||
|
height: "32px",
|
||||||
|
padding: "0 15px",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyInputStyles(element: HTMLInputElement | HTMLSelectElement): void {
|
function applyInputStyles(
|
||||||
Object.assign(element.style, { background: "#ffffff", border: "1px solid #d0d7de", borderRadius: "6px", boxSizing: "border-box", color: "#1f2329", height: "32px", padding: "0 8px" });
|
element: HTMLInputElement | HTMLSelectElement
|
||||||
|
): void {
|
||||||
|
Object.assign(element.style, {
|
||||||
|
background: "#ffffff",
|
||||||
|
border: "1px solid #d0d7de",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
color: "#1f2329",
|
||||||
|
height: "32px",
|
||||||
|
padding: "0 8px"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStatusStyles(statusText: HTMLElement): void {
|
function applyStatusStyles(statusText: HTMLElement): void {
|
||||||
Object.assign(statusText.style, { color: "#64748b", flex: "1 1 auto", fontSize: "12px", lineHeight: "20px", minWidth: "120px", textAlign: "center", whiteSpace: "nowrap" });
|
Object.assign(statusText.style, {
|
||||||
|
color: "#64748b",
|
||||||
|
flex: "1 1 auto",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "20px",
|
||||||
|
minWidth: "120px",
|
||||||
|
textAlign: "center",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensurePluginActionButtonTheme(document: Document): void {
|
function ensurePluginActionButtonTheme(document: Document): void {
|
||||||
if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) return;
|
let style = document.getElementById(
|
||||||
const style = document.createElement("style");
|
PLUGIN_ACTION_BUTTON_STYLE_ID
|
||||||
|
) as HTMLStyleElement | null;
|
||||||
|
if (!style) {
|
||||||
|
style = document.createElement("style");
|
||||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||||
style.textContent = `
|
|
||||||
[data-plugin-metric-filter-rule-invalid="true"] [data-plugin-metric-filter-threshold-control] { border-color: #dc2626 !important; box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12) !important; }
|
|
||||||
[data-plugin-metric-catalog-action]:disabled { color: #98a2b3 !important; cursor: not-allowed !important; }
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
[data-plugin-toolbar='root'], [data-plugin-toolbar-row='thresholds'], [data-plugin-metric-catalog='root'] { width: 100% !important; }
|
|
||||||
[data-plugin-toolbar-row='thresholds'], [data-plugin-toolbar-group='data'], [data-plugin-toolbar-action-group] { align-items: flex-start !important; flex-wrap: wrap !important; }
|
|
||||||
[data-plugin-toolbar-action-divider] { display: none !important; }
|
|
||||||
[data-plugin-metric-catalog-panel='root'] { margin-top: 6px !important; max-width: none !important; position: static !important; width: 100% !important; }
|
|
||||||
[data-plugin-metric-filter-rule] { width: 100% !important; }
|
|
||||||
[data-plugin-metric-filter-rule] strong { flex-basis: 100% !important; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
style.textContent = `
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
[data-plugin-toolbar='root'], [data-plugin-toolbar-panel='root'], [data-plugin-toolbar-row='primary'] { width: 100% !important; }
|
||||||
|
[data-plugin-toolbar-row='primary'], [data-plugin-toolbar-group='data'], [data-plugin-toolbar-action-group] { align-items: flex-start !important; flex-wrap: wrap !important; }
|
||||||
|
[data-plugin-toolbar-action-divider] { display: none !important; }
|
||||||
|
[data-plugin-export-status='text'] { flex-basis: 100% !important; text-align: left !important; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
clickSave();
|
||||||
|
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(
|
const saveButton = document.querySelector(
|
||||||
'[data-audience-profile-field-dialog-save="button"]'
|
'[data-audience-profile-field-dialog-save="button"]'
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
saveButton.click();
|
saveButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
await expect(result).resolves.toEqual([]);
|
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 ?? "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+12
-98
@@ -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,
|
flowType: 0,
|
||||||
onlyAssign: true,
|
onlyAssign: true,
|
||||||
range: 2,
|
range: 2,
|
||||||
type: 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,
|
flowType: 0,
|
||||||
onlyAssign: true,
|
onlyAssign: true,
|
||||||
range: 2,
|
range: 2,
|
||||||
type: 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,
|
flowType: 0,
|
||||||
onlyAssign: true,
|
onlyAssign: true,
|
||||||
range: 2,
|
range: 2,
|
||||||
type: 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user