From d121849d3ef6755629b66fc6b4bc497c99204645 Mon Sep 17 00:00:00 2001 From: wxs Date: Wed, 29 Jul 2026 10:51:24 +0800 Subject: [PATCH] feat: streamline export field selection --- docs/项目流程说明文档.md | 73 +- src/content/market/audience-profile-csv.ts | 14 - .../market/audience-profile-field-dialog.ts | 921 +++++++++++++----- src/content/market/index.ts | 100 +- src/content/market/metric-filter.ts | 80 -- src/content/market/plugin-toolbar.ts | 803 +++++---------- src/content/market/spread-info.ts | 44 +- src/content/market/types.ts | 24 - tests/audience-profile-field-dialog.test.ts | 162 ++- tests/market-content-entry.test.ts | 451 +-------- tests/metric-filter.test.ts | 51 - tests/spread-info.test.ts | 134 +-- 12 files changed, 1157 insertions(+), 1700 deletions(-) delete mode 100644 src/content/market/metric-filter.ts delete mode 100644 tests/metric-filter.test.ts diff --git a/docs/项目流程说明文档.md b/docs/项目流程说明文档.md index be931bc..119103e 100644 --- a/docs/项目流程说明文档.md +++ b/docs/项目流程说明文档.md @@ -12,14 +12,14 @@ - 使用者在页面上勾选的达人(可选); - 使用者粘贴的达人星图 ID; - 使用者填写的批次名称; -- 使用者选择的导出字段和传播指标筛选规则; +- 使用者选择的导出字段; - 当前插件登录用户的 Logto 身份和访问令牌。 项目处理过程包括: - 在星图达人市场页面挂载插件工具栏; - 读取当前页面或星图列表接口返回的达人数据; -- 根据勾选范围、分页范围、传播指标规则确定最终达人集合; +- 根据勾选范围和分页范围确定最终达人集合; - 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息; - 调用公司后端接口补充秒思 api 指标; - 生成 CSV 文件,或组装批次 payload 提交到后端。 @@ -55,9 +55,9 @@ 3. 打开巨量星图达人市场页面; 4. 插件读取登录状态并挂载工具栏; 5. 插件读取当前星图达人列表,并补充页面展示指标; -6. 使用者选择达人、字段、传播指标筛选规则或输入星图 ID; +6. 使用者选择达人、检索并勾选导出字段,或输入星图 ID; 7. 使用者触发导出或提交批次; -8. 插件收集达人数据,按规则过滤、去重、补充字段; +8. 插件收集达人数据,去重并补充已选字段; 9. 插件调用星图接口和公司后端接口补充数据; 10. 插件下载 CSV,或把批次提交给后端; 11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。 @@ -105,7 +105,7 @@ ### 2.5 导出选中达人数据 - 触发者:使用者点击 `导出选中达人数据`。 -- 输入:可选的当前勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 +- 输入:可选的当前勾选达人、当前导出范围和字段选择配置。 - 处理:选择 `全部` 且没有勾选达人时,插件从第 1 页收集当前筛选结果的全部分页并全部导出;只要存在勾选达人,则勾选优先,只保留导出范围内已勾选的达人。其他范围仍需要勾选达人。随后按字段选择配置补充内容数据、效果预估、画像、看后搜率和秒思指标。未勾选的数据类别不会调用对应补充接口;从未保存过字段配置时保持默认全量导出。 - 输出:CSV 文件下载到浏览器默认下载目录。 - 下一步:使用者检查 CSV 内容。 @@ -126,7 +126,7 @@ - 触发者:使用者点击 `提交批次`。 - 输入:当前范围或已勾选达人、批次名称、登录用户信息。 -- 处理:插件先要求输入批次名称,再收集达人数据并应用传播指标规则。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。 +- 处理:插件先要求输入批次名称,再按当前范围收集达人数据。选择 `全部` 且没有勾选达人时,提交当前筛选结果的全部分页;只要存在勾选达人,则勾选优先,不会回退为全部结果。最后检查登录状态,组装批次 payload,提交到后端。 - 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。 - 下一步:在后端系统中继续处理批次。 - 人工操作:需要使用者输入批次名称。 @@ -211,7 +211,7 @@ ### 3.6 导出选中达人数据 - 步骤目的:把使用者选定的达人数据导出为 CSV。 -- 输入内容:可选的已勾选达人、当前导出范围、字段选择配置、传播指标筛选规则。 +- 输入内容:可选的已勾选达人、当前导出范围和字段选择配置。 - 处理规则: - 选择 `全部` 且没有勾选达人时,从第 1 页收集并导出当前筛选结果的全部分页; - 存在勾选达人时,勾选优先,严格保留导出范围内已勾选的达人; @@ -248,31 +248,10 @@ - 整体流程异常:提示按 ID 导出失败。 - 是否影响后续步骤:不写入外部系统,不影响批次。 -### 3.8 传播指标规则筛选 - -- 步骤目的:在导出或提交前按内容传播表现过滤达人。 -- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。 -- 处理规则: - - 默认不选择任何指标,不启用二次筛选; - - 选择指标后必须填写该指标阈值,并为该指标选择一套独立视频口径; - - 同时选择完播率和互动率时,两条规则必须全部满足; - - 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求; - - 每条个人视频规则固定为不限指派、不排除营销流量; - - 每条星图视频规则可独立选择只看指派、不限指派、排除营销流量或不排除营销流量; - - 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`; - - 请求失败、缺少传播指标请求 ID 或缺少已选指标的达人视为不满足筛选。 -- 输出结果:过滤后的达人集合。 -- 外部依赖:星图传播指标接口。 -- 失败后如何处理: - - 已选指标阈值为空或非法:阻止导出或提交,并提示具体指标; - - 单个达人筛选请求失败:跳过该达人; - - 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。 -- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。 - -### 3.9 批次提交 +### 3.8 批次提交 - 步骤目的:把达人集合提交给后续业务系统。 -- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称、登录用户信息。 +- 输入内容:导出范围内达人、已选达人、批次名称和登录用户信息。 - 处理规则: - 点击后先输入批次名称; - 取消输入则停止; @@ -293,7 +272,7 @@ - 网络失败:提交失败。 - 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。 -### 3.10 CSV 下载 +### 3.9 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_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 | | `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 仅选择效果预估字段时调用;与已选画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入该行失败原因,画像失败或未选择画像都不阻止效果预估结果下载 | -| `get_author_spread_info` | 获取内容传播指标,并用于规则筛选 | 内容数据导出、传播指标筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 只请求已勾选内容字段所属的参数组合;同一参数组合下勾选多个字段仍只请求一次;单个达人的多组已选参数串行,达人之间并发;筛选对达人并发,同一达人相同口径的规则复用一次响应 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败、缺少请求 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 | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 | | 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 | | Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 | @@ -344,14 +323,15 @@ - 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段; - 星图详情类接口:看后搜率、画像、商业能力、传播指标; - 公司 talent-search 后端:秒思 api 指标; -- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和传播指标筛选规则。 +- 使用者输入:勾选状态、星图 ID、批次名称和字段选择。 ### 5.2 保留规则 - 有有效达人 ID 和达人名称的记录会进入页面记录集合; - 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行; - CSV 基础字段、导出状态、失败原因等固定字段会保留; -- 字段选择只影响可选数据字段,不删除固定标识字段。 +- 字段选择只影响可选数据字段,不删除固定标识字段; +- 字段弹窗左侧持续展示已选字段,右侧支持按字段名或分类检索,并实时同步勾选状态。 ### 5.3 过滤规则 @@ -359,7 +339,6 @@ - 导出选中达人数据在选择 `全部` 且没有勾选时,保留当前筛选结果的全部分页;其他范围必须有已勾选达人; - 选择 `全部` 且存在勾选达人时,画像导出和提交批次都只保留全部范围内已勾选达人; - 选择 `全部` 时,勾选达人不在当前筛选结果内则不回退为全部达人; -- 传播指标规则启用后,不满足全部已选规则的达人会被过滤; - 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。 ### 5.4 去重规则 @@ -422,7 +401,6 @@ - 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。 - 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。 - 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。 -- 传播指标规则没有持久状态,重新执行时按当前页面选择和输入值重新判断。 重复执行相对安全的操作: @@ -485,12 +463,11 @@ 1. 在星图市场中完成筛选; 2. 等待页面列表加载完成; 3. 选择 `全部` 时可不勾选;选择其他范围时勾选需要导出的达人; -4. 可选:点击 `选择字段` 调整 CSV 字段; -5. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径; -6. 点击 `导出选中达人数据`; -7. 等待状态提示从导出中消失或浏览器下载完成; -8. 在下载目录或 Chrome 下载列表中查看 CSV; -9. 检查 `导出状态` 和 `失败原因` 字段。 +4. 可选:点击 `选择字段`,通过关键词检索字段,并在已选区域确认最终字段; +5. 点击 `导出选中达人数据`; +6. 等待状态提示从导出中消失或浏览器下载完成; +7. 在下载目录或 Chrome 下载列表中查看 CSV; +8. 检查 `导出状态` 和 `失败原因` 字段。 ### 7.4 按 ID 执行导出任务 @@ -505,11 +482,10 @@ 1. 在星图市场中完成筛选; 2. 可选:勾选需要提交的达人; -3. 可选:选择完播率、互动率筛选指标,并分别填写阈值和视频口径; -4. 点击 `提交批次`; -5. 输入批次名称; -6. 等待页面提示 `批次提交成功`; -7. 到后端系统确认批次是否生成。 +3. 点击 `提交批次`; +4. 输入批次名称; +5. 等待页面提示 `批次提交成功`; +6. 到后端系统确认批次是否生成。 ### 7.6 只执行某个子流程 @@ -589,8 +565,7 @@ | 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 | | COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 | | 导出范围 | 决定收集和最终处理哪些页面达人 | 当前工具栏默认值为前 5 页;选择 `全部` 且未勾选时处理当前筛选结果全量,存在勾选时勾选优先 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 | -| 传播指标规则 | 导出或提交前二次过滤达人 | 默认不选择任何指标 | 完播率、互动率;每个已选指标填写非负阈值并选择独立视频口径 | 影响最终保留达人集合和星图接口请求口径 | 不需要重启 | 指标、口径或阈值选错会过滤掉目标达人 | -| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 | +| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 支持按字段名或分类搜索;左侧显示已选字段 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 | ## 9. 任务执行和结果确认 diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index cbce62b..12d7461 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -127,20 +127,6 @@ export function listAudienceProfileCsvHeaders( ]; } -export function buildAudienceProfileFieldValues( - row: AudienceProfileExportRow -): Record { - 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( marketListHeaders: string[] = [] ): AudienceProfileCsvFieldGroup[] { diff --git a/src/content/market/audience-profile-field-dialog.ts b/src/content/market/audience-profile-field-dialog.ts index c339b13..d6e079e 100644 --- a/src/content/market/audience-profile-field-dialog.ts +++ b/src/content/market/audience-profile-field-dialog.ts @@ -1,294 +1,729 @@ import type { AudienceProfileCsvFieldGroup } from "./audience-profile-csv"; +interface ActiveFieldDialog { + focus(): void; + promise: Promise; +} + +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(); + export function promptForAudienceProfileFields( document: Document, groups: AudienceProfileCsvFieldGroup[], selectedHeaders: string[] | undefined ): Promise { - return new Promise((resolve) => { - const selectableHeaders = groups.flatMap((group) => group.headers); - const selectedHeaderSet = new Set( - selectedHeaders === undefined - ? selectableHeaders - : selectedHeaders.filter((header) => selectableHeaders.includes(header)) - ); + const activeDialog = activeDialogs.get(document); + if (activeDialog) { + activeDialog.focus(); + return activeDialog.promise; + } - const overlay = document.createElement("div"); - overlay.dataset.audienceProfileFieldDialog = "overlay"; - applyOverlayStyles(overlay); + ensureResponsiveStyles(document); + const fields = listSelectableFields(groups); + const selectableHeaderSet = new Set(fields.map((field) => field.header)); + const selectedHeaderSet = new Set( + selectedHeaders === undefined + ? fields.map((field) => field.header) + : selectedHeaders.filter((header) => selectableHeaderSet.has(header)) + ); - const dialog = document.createElement("section"); - applyDialogStyles(dialog); - - const title = document.createElement("h2"); - applyTitleStyles(title); - - const hint = document.createElement("p"); - hint.textContent = "基础字段会固定保留。取消勾选后,本次及后续CSV将不包含对应列。"; - applyHintStyles(hint); - - const toolbar = document.createElement("div"); - applyToolbarStyles(toolbar); - - const selectAllButton = document.createElement("button"); - selectAllButton.type = "button"; - selectAllButton.textContent = "全选"; - applySecondaryButtonStyles(selectAllButton); - - const resetButton = document.createElement("button"); - resetButton.type = "button"; - resetButton.textContent = "恢复默认"; - applySecondaryButtonStyles(resetButton); - - toolbar.append(selectAllButton, resetButton); - - const groupContainer = document.createElement("div"); - applyGroupContainerStyles(groupContainer); - - const fieldInputs: HTMLInputElement[] = []; - groups.forEach((group) => { - const groupSection = document.createElement("section"); - groupSection.dataset.audienceProfileFieldDialogGroup = "section"; - applyGroupSectionStyles(groupSection); - - const groupHeader = document.createElement("label"); - applyGroupHeaderStyles(groupHeader); - - const groupInput = document.createElement("input"); - groupInput.type = "checkbox"; - - const groupTitle = document.createElement("span"); - groupTitle.textContent = group.label; - - groupHeader.append(groupInput, groupTitle); - - const fieldList = document.createElement("div"); - applyFieldListStyles(fieldList); - - const groupFieldInputs = group.headers.map((header) => { - const fieldLabel = document.createElement("label"); - applyFieldLabelStyles(fieldLabel); - - const input = document.createElement("input"); - input.type = "checkbox"; - input.value = header; - input.dataset.audienceProfileFieldDialogField = "checkbox"; - input.checked = selectedHeaderSet.has(header); - - const text = document.createElement("span"); - text.textContent = header; - - fieldLabel.append(input, text); - fieldList.append(fieldLabel); - fieldInputs.push(input); - return input; - }); - - const syncGroupInput = () => { - const checkedCount = groupFieldInputs.filter((input) => input.checked).length; - groupInput.checked = checkedCount === groupFieldInputs.length; - groupInput.indeterminate = checkedCount > 0 && checkedCount < groupFieldInputs.length; - }; - - groupInput.addEventListener("change", () => { - groupFieldInputs.forEach((input) => { - input.checked = groupInput.checked; - }); - syncTitle(); - }); - groupFieldInputs.forEach((input) => { - input.addEventListener("change", () => { - syncGroupInput(); - syncTitle(); - }); - }); - syncGroupInput(); - - groupSection.append(groupHeader, fieldList); - groupContainer.append(groupSection); - }); - - const actions = document.createElement("div"); - applyActionsStyles(actions); - - const cancelButton = document.createElement("button"); - cancelButton.type = "button"; - cancelButton.textContent = "取消"; - applySecondaryButtonStyles(cancelButton); - - const confirmButton = document.createElement("button"); - confirmButton.type = "button"; - confirmButton.dataset.audienceProfileFieldDialogSave = "button"; - confirmButton.textContent = "保存"; - applyPrimaryButtonStyles(confirmButton); - - actions.append(cancelButton, confirmButton); - dialog.append(title, hint, toolbar, groupContainer, actions); - overlay.append(dialog); - document.body.appendChild(overlay); - - function syncTitle() { - const checkedCount = fieldInputs.filter((input) => input.checked).length; - title.textContent = `可选字段(已选 ${checkedCount}/${fieldInputs.length} 个字段)`; - } - - function close(value: string[] | null) { - overlay.remove(); - resolve(value); - } - - selectAllButton.addEventListener("click", () => { - fieldInputs.forEach((input) => { - input.checked = true; - }); - syncTitle(); - syncAllGroupInputs(dialog); - }); - resetButton.addEventListener("click", () => { - fieldInputs.forEach((input) => { - input.checked = true; - }); - syncTitle(); - syncAllGroupInputs(dialog); - }); - cancelButton.addEventListener("click", () => close(null)); - confirmButton.addEventListener("click", () => { - const nextHeaders = fieldInputs - .filter((input) => input.checked) - .map((input) => input.value); - close(nextHeaders); - }); - overlay.addEventListener("click", (event) => { - if (event.target === overlay) { - close(null); - } - }); - syncTitle(); + let resolveDialog: (value: string[] | null) => void = () => undefined; + const promise = new Promise((resolve) => { + resolveDialog = resolve; }); + + const overlay = document.createElement("div"); + overlay.dataset.audienceProfileFieldDialog = "overlay"; + applyOverlayStyles(overlay); + + const dialog = document.createElement("section"); + dialog.dataset.audienceProfileFieldDialog = "dialog"; + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("role", "dialog"); + applyDialogStyles(dialog); + + const header = document.createElement("header"); + applyDialogHeaderStyles(header); + const headingGroup = document.createElement("div"); + const title = document.createElement("h2"); + title.id = "sces-audience-profile-field-dialog-title"; + title.textContent = "选择导出字段"; + applyTitleStyles(title); + dialog.setAttribute("aria-labelledby", title.id); + const hint = document.createElement("p"); + hint.textContent = "基础字段会固定保留。字段设置会用于本次及后续 CSV 导出。"; + applyHintStyles(hint); + headingGroup.append(title, hint); + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.dataset.audienceProfileFieldDialogClose = "button"; + closeButton.textContent = "×"; + closeButton.title = "关闭"; + closeButton.setAttribute("aria-label", "关闭字段选择"); + applyCloseButtonStyles(closeButton); + header.append(headingGroup, closeButton); + + const content = document.createElement("div"); + content.dataset.audienceProfileFieldDialogContent = "root"; + applyContentStyles(content); + + const selectedPane = document.createElement("section"); + selectedPane.dataset.audienceProfileFieldDialogPane = "selected"; + applyPaneStyles(selectedPane); + const selectedHeader = document.createElement("div"); + applyPaneHeaderStyles(selectedHeader); + const selectedTitle = document.createElement("h3"); + selectedTitle.textContent = "已选字段"; + applyPaneTitleStyles(selectedTitle); + const selectedCount = document.createElement("span"); + selectedCount.dataset.audienceProfileFieldDialogSelectedCount = "text"; + selectedCount.setAttribute("aria-live", "polite"); + applyCountStyles(selectedCount); + selectedHeader.append(selectedTitle, selectedCount); + + const selectionActions = document.createElement("div"); + applySelectionActionsStyles(selectionActions); + const selectAllButton = document.createElement("button"); + selectAllButton.type = "button"; + selectAllButton.textContent = "全选"; + applyTextButtonStyles(selectAllButton); + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.textContent = "恢复默认"; + applyTextButtonStyles(resetButton); + selectionActions.append(selectAllButton, resetButton); + + const selectedList = document.createElement("div"); + selectedList.dataset.audienceProfileFieldDialogSelectedList = "root"; + applySelectedListStyles(selectedList); + const selectedEmptyState = document.createElement("p"); + selectedEmptyState.dataset.audienceProfileFieldDialogSelectedEmpty = "text"; + selectedEmptyState.textContent = "暂未选择字段"; + applyEmptyStateStyles(selectedEmptyState); + selectedPane.append( + selectedHeader, + selectionActions, + selectedList, + selectedEmptyState + ); + + const resultPane = document.createElement("section"); + resultPane.dataset.audienceProfileFieldDialogPane = "results"; + applyPaneStyles(resultPane); + const resultHeader = document.createElement("div"); + applyPaneHeaderStyles(resultHeader); + const resultTitle = document.createElement("h3"); + resultTitle.textContent = "字段列表"; + applyPaneTitleStyles(resultTitle); + const resultCount = document.createElement("span"); + resultCount.dataset.audienceProfileFieldDialogResultCount = "text"; + resultCount.setAttribute("aria-live", "polite"); + applyCountStyles(resultCount); + resultHeader.append(resultTitle, resultCount); + + const searchInput = document.createElement("input"); + searchInput.type = "search"; + searchInput.placeholder = "搜索字段名称或分类"; + searchInput.dataset.audienceProfileFieldDialogSearch = "input"; + searchInput.setAttribute("aria-label", "搜索导出字段"); + searchInput.autocomplete = "off"; + applySearchInputStyles(searchInput); + + const resultList = document.createElement("div"); + resultList.dataset.audienceProfileFieldDialogResults = "root"; + applyResultListStyles(resultList); + const resultEmptyState = document.createElement("p"); + resultEmptyState.dataset.audienceProfileFieldDialogResultEmpty = "text"; + resultEmptyState.textContent = "没有匹配的字段"; + resultEmptyState.hidden = true; + applyEmptyStateStyles(resultEmptyState); + + const resultInputs = new Map(); + const resultItems = new Map(); + const groupControls: GroupControl[] = []; + groups.forEach((group) => { + const groupFields = fields.filter((field) => field.group === group.label); + if (groupFields.length === 0) return; + + const groupSection = document.createElement("section"); + groupSection.dataset.audienceProfileFieldDialogGroup = "section"; + groupSection.dataset.audienceProfileFieldDialogGroupLabel = group.label; + applyGroupSectionStyles(groupSection); + + const groupHeader = document.createElement("label"); + applyGroupHeaderStyles(groupHeader); + const groupInput = document.createElement("input"); + groupInput.type = "checkbox"; + groupInput.dataset.audienceProfileFieldDialogGroupToggle = group.label; + applyCheckboxStyles(groupInput); + const groupTitle = document.createElement("span"); + groupTitle.textContent = group.label; + groupHeader.append(groupInput, groupTitle); + + const fieldList = document.createElement("div"); + applyFieldListStyles(fieldList); + groupFields.forEach((field) => { + const fieldLabel = document.createElement("label"); + fieldLabel.dataset.audienceProfileFieldDialogResult = field.header; + fieldLabel.dataset.audienceProfileFieldDialogResultGroup = field.group; + applyFieldLabelStyles(fieldLabel); + + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = field.header; + input.dataset.audienceProfileFieldDialogField = "checkbox"; + applyCheckboxStyles(input); + const text = document.createElement("span"); + text.textContent = field.header; + fieldLabel.append(input, text); + fieldList.append(fieldLabel); + resultInputs.set(field.header, input); + resultItems.set(field.header, fieldLabel); + + input.addEventListener("change", () => { + setFieldSelected(field.header, input.checked); + }); + }); + + const headers = groupFields.map((field) => field.header); + groupInput.addEventListener("change", () => { + headers.forEach((fieldHeader) => { + if (groupInput.checked) selectedHeaderSet.add(fieldHeader); + else selectedHeaderSet.delete(fieldHeader); + }); + syncSelectionView(); + }); + groupControls.push({ headers, input: groupInput, root: groupSection }); + groupSection.append(groupHeader, fieldList); + resultList.append(groupSection); + }); + resultPane.append( + resultHeader, + searchInput, + resultList, + resultEmptyState + ); + content.append(selectedPane, resultPane); + + const actions = document.createElement("footer"); + applyActionsStyles(actions); + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "取消"; + applySecondaryButtonStyles(cancelButton); + const confirmButton = document.createElement("button"); + confirmButton.type = "button"; + confirmButton.dataset.audienceProfileFieldDialogSave = "button"; + confirmButton.textContent = "保存"; + applyPrimaryButtonStyles(confirmButton); + actions.append(cancelButton, confirmButton); + + dialog.append(header, content, actions); + overlay.append(dialog); + document.body.appendChild(overlay); + + function setFieldSelected(fieldHeader: string, isSelected: boolean): void { + if (isSelected) selectedHeaderSet.add(fieldHeader); + else selectedHeaderSet.delete(fieldHeader); + syncSelectionView(); + } + + function syncSelectionView(): void { + resultInputs.forEach((input, fieldHeader) => { + input.checked = selectedHeaderSet.has(fieldHeader); + }); + groupControls.forEach(({ headers, input }) => { + const checkedCount = headers.filter((fieldHeader) => + selectedHeaderSet.has(fieldHeader) + ).length; + input.checked = checkedCount === headers.length; + input.indeterminate = checkedCount > 0 && checkedCount < headers.length; + }); + + selectedList.replaceChildren(); + fields.forEach((field) => { + if (!selectedHeaderSet.has(field.header)) return; + const fieldLabel = document.createElement("label"); + fieldLabel.dataset.audienceProfileFieldDialogSelectedField = field.header; + applySelectedFieldStyles(fieldLabel); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = true; + input.value = field.header; + input.dataset.audienceProfileFieldDialogSelectedCheckbox = "checkbox"; + applyCheckboxStyles(input); + const textGroup = document.createElement("span"); + applySelectedFieldTextStyles(textGroup); + const fieldName = document.createElement("span"); + fieldName.textContent = field.header; + const groupName = document.createElement("small"); + groupName.textContent = field.group; + applySelectedFieldGroupStyles(groupName); + textGroup.append(fieldName, groupName); + fieldLabel.append(input, textGroup); + input.addEventListener("change", () => { + setFieldSelected(field.header, input.checked); + }); + selectedList.append(fieldLabel); + }); + selectedCount.textContent = `${selectedHeaderSet.size} 个`; + setElementVisible( + selectedEmptyState, + selectedHeaderSet.size === 0, + "block" + ); + } + + function syncSearchResults(): void { + const query = searchInput.value.trim().toLocaleLowerCase(); + let visibleCount = 0; + resultItems.forEach((item, fieldHeader) => { + const searchableText = `${item.dataset.audienceProfileFieldDialogResultGroup ?? ""} ${fieldHeader}`.toLocaleLowerCase(); + const isVisible = !query || searchableText.includes(query); + setElementVisible(item, isVisible, "flex"); + if (isVisible) visibleCount += 1; + }); + groupControls.forEach(({ root }) => { + const hasVisibleField = Array.from( + root.querySelectorAll( + "[data-audience-profile-field-dialog-result]" + ) + ).some((item) => !item.hidden); + setElementVisible(root, hasVisibleField, "block"); + }); + resultCount.textContent = query + ? `找到 ${visibleCount} 个` + : `${fields.length} 个可选`; + setElementVisible(resultEmptyState, visibleCount === 0, "block"); + } + + function close(value: string[] | null): void { + document.removeEventListener("keydown", onKeyDown); + activeDialogs.delete(document); + overlay.remove(); + resolveDialog(value); + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.key === "Escape") close(null); + } + + selectAllButton.addEventListener("click", () => { + fields.forEach((field) => selectedHeaderSet.add(field.header)); + syncSelectionView(); + }); + resetButton.addEventListener("click", () => { + selectedHeaderSet.clear(); + fields.forEach((field) => selectedHeaderSet.add(field.header)); + syncSelectionView(); + }); + searchInput.addEventListener("input", syncSearchResults); + closeButton.addEventListener("click", () => close(null)); + cancelButton.addEventListener("click", () => close(null)); + confirmButton.addEventListener("click", () => { + close( + fields + .filter((field) => selectedHeaderSet.has(field.header)) + .map((field) => field.header) + ); + }); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) close(null); + }); + document.addEventListener("keydown", onKeyDown); + + syncSelectionView(); + syncSearchResults(); + searchInput.focus(); + activeDialogs.set(document, { + focus: () => searchInput.focus(), + promise + }); + return promise; } -function syncAllGroupInputs(dialog: HTMLElement): void { - dialog - .querySelectorAll('[data-audience-profile-field-dialog-group="section"]') - .forEach((section) => { - const groupInput = section.querySelector(":scope > label > input"); - const fieldInputs = Array.from( - section.querySelectorAll(":scope > div input") - ) as HTMLInputElement[]; - if (!(groupInput instanceof HTMLInputElement) || fieldInputs.length === 0) { - return; - } +function listSelectableFields( + groups: AudienceProfileCsvFieldGroup[] +): SelectableField[] { + const seenHeaders = new Set(); + return groups.flatMap((group) => + group.headers.flatMap((header) => { + if (seenHeaders.has(header)) return []; + seenHeaders.add(header); + return [{ group: group.label, header }]; + }) + ); +} - const checkedCount = fieldInputs.filter((input) => input.checked).length; - groupInput.checked = checkedCount === fieldInputs.length; - groupInput.indeterminate = checkedCount > 0 && checkedCount < fieldInputs.length; - }); +function setElementVisible( + element: HTMLElement, + isVisible: boolean, + visibleDisplay: "block" | "flex" +): void { + element.hidden = !isVisible; + element.style.display = isVisible ? visibleDisplay : "none"; } function applyOverlayStyles(overlay: HTMLElement): void { - overlay.style.position = "fixed"; - overlay.style.inset = "0"; - overlay.style.zIndex = "2147483647"; - overlay.style.display = "flex"; - overlay.style.alignItems = "center"; - overlay.style.justifyContent = "center"; - overlay.style.background = "rgba(15, 23, 42, 0.38)"; + Object.assign(overlay.style, { + alignItems: "center", + background: "rgba(15, 23, 42, 0.42)", + display: "flex", + inset: "0", + justifyContent: "center", + padding: "24px", + position: "fixed", + zIndex: "2147483647" + }); } function applyDialogStyles(dialog: HTMLElement): void { - dialog.style.width = "680px"; - dialog.style.maxWidth = "calc(100vw - 32px)"; - dialog.style.maxHeight = "calc(100vh - 48px)"; - dialog.style.display = "flex"; - dialog.style.flexDirection = "column"; - dialog.style.background = "#ffffff"; - dialog.style.borderRadius = "8px"; - dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; - dialog.style.padding = "20px"; - dialog.style.boxSizing = "border-box"; + Object.assign(dialog.style, { + background: "#ffffff", + borderRadius: "8px", + boxShadow: "0 20px 52px rgba(15, 23, 42, 0.24)", + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + height: "min(680px, calc(100vh - 48px))", + maxWidth: "calc(100vw - 48px)", + 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 { - title.style.margin = "0 0 8px"; - title.style.fontSize = "18px"; - title.style.fontWeight = "700"; - title.style.color = "#1f2329"; + Object.assign(title.style, { + color: "#1f2329", + fontSize: "18px", + fontWeight: "700", + letterSpacing: "0", + lineHeight: "26px", + margin: "0" + }); } function applyHintStyles(hint: HTMLElement): void { - hint.style.margin = "0 0 12px"; - hint.style.fontSize = "13px"; - hint.style.lineHeight = "20px"; - hint.style.color = "#64748b"; + Object.assign(hint.style, { + color: "#64748b", + fontSize: "13px", + lineHeight: "20px", + margin: "4px 0 0" + }); } -function applyToolbarStyles(toolbar: HTMLElement): void { - toolbar.style.display = "flex"; - toolbar.style.gap = "8px"; - toolbar.style.marginBottom = "12px"; +function applyCloseButtonStyles(button: HTMLButtonElement): void { + Object.assign(button.style, { + alignItems: "center", + 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 { - container.style.display = "flex"; - container.style.flexDirection = "column"; - container.style.gap = "10px"; - container.style.overflow = "auto"; - container.style.paddingRight = "4px"; +function applyContentStyles(content: HTMLElement): void { + Object.assign(content.style, { + display: "grid", + flex: "1 1 auto", + gap: "16px", + 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 { - section.style.border = "1px solid #e5e7eb"; - section.style.borderRadius = "8px"; - section.style.padding = "10px"; + Object.assign(section.style, { + borderBottom: "1px solid #e5e7eb", + padding: "10px 0" + }); } function applyGroupHeaderStyles(label: HTMLElement): void { - label.style.display = "flex"; - label.style.alignItems = "center"; - label.style.gap = "8px"; - label.style.fontWeight = "700"; - label.style.color = "#1f2329"; - label.style.marginBottom = "8px"; + Object.assign(label.style, { + alignItems: "center", + color: "#1f2329", + cursor: "pointer", + display: "flex", + fontSize: "13px", + fontWeight: "700", + gap: "8px", + marginBottom: "7px" + }); } function applyFieldListStyles(list: HTMLElement): void { - list.style.display = "grid"; - list.style.gridTemplateColumns = "repeat(auto-fit, minmax(220px, 1fr))"; - list.style.gap = "8px"; + Object.assign(list.style, { + display: "grid", + gap: "3px", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))" + }); } function applyFieldLabelStyles(label: HTMLElement): void { - label.style.display = "flex"; - label.style.alignItems = "center"; - label.style.gap = "6px"; - label.style.fontSize = "13px"; - label.style.lineHeight = "18px"; - label.style.color = "#374151"; + Object.assign(label.style, { + alignItems: "flex-start", + borderRadius: "4px", + color: "#475569", + cursor: "pointer", + 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 { - actions.style.display = "flex"; - actions.style.justifyContent = "flex-end"; - actions.style.columnGap = "8px"; - actions.style.marginTop = "14px"; + Object.assign(actions.style, { + borderTop: "1px solid #e5e7eb", + columnGap: "8px", + display: "flex", + flex: "0 0 auto", + justifyContent: "flex-end", + padding: "14px 22px" + }); } function applyPrimaryButtonStyles(button: HTMLButtonElement): void { - button.style.height = "32px"; - button.style.padding = "0 15px"; - button.style.border = "1px solid #7f1d2d"; - button.style.borderRadius = "8px"; - button.style.background = "#7f1d2d"; - button.style.color = "#ffffff"; - button.style.fontWeight = "600"; + Object.assign(button.style, { + background: "#7f1d2d", + border: "1px solid #7f1d2d", + borderRadius: "8px", + color: "#ffffff", + fontWeight: "600", + height: "34px", + padding: "0 18px" + }); } function applySecondaryButtonStyles(button: HTMLButtonElement): void { - button.style.height = "32px"; - button.style.padding = "0 15px"; - button.style.border = "1px solid #d0d7de"; - button.style.borderRadius = "8px"; - button.style.background = "#ffffff"; - button.style.color = "#1f2329"; - button.style.fontWeight = "600"; + Object.assign(button.style, { + background: "#ffffff", + border: "1px solid #d0d7de", + borderRadius: "8px", + color: "#1f2329", + 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); } diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 4fefa97..d19e6a8 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -4,7 +4,6 @@ import { listRateCsvHeaders } from "./csv-exporter"; import { - buildAudienceProfileFieldValues, buildAudienceProfileCsv, listAudienceProfileSelectableFieldGroups, type AudienceProfileCsvOptions @@ -53,10 +52,6 @@ import { applyFilterAndSort } from "./filter-sort-controller"; import { createMarketApiClient } from "./api-client"; import { createExportRangeController } from "./export-range-controller"; import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar"; -import { - listNumericMetricFilterDefinitions, - matchesMetricFilterRule -} from "./metric-filter"; import { createSilentExportController } from "./silent-export-controller"; import { createSpreadInfoClient, @@ -66,7 +61,6 @@ import { } from "./spread-info"; import { readToolbarExportTarget, - readToolbarSpreadFilter, setToolbarBusyState, setToolbarExportStatus } from "./plugin-toolbar"; @@ -89,8 +83,7 @@ import type { MarketRecord, MarketRowSnapshot, MarketSortState, - MetricThresholdFilter, - SpreadInfoConfig, + SpreadInfoConfig } from "./types"; interface MutationObserverLike { @@ -494,12 +487,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); return; } - const spreadFilter = readToolbarSpreadFilter(toolbar); - if (spreadFilter.error) { - setToolbarExportStatus(toolbar, spreadFilter.error); - return; - } - const batchName = await promptBatchName(); if (batchName === null) { return; @@ -513,15 +500,12 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, true); try { const hasSelectedAuthors = selectedAuthorIds.size > 0; - const resolvedRecords = await applyMetricThresholdFilter( - await exportRecords( - exportTarget.target, - hasSelectedAuthors ? "提交已选达人中" : "提交中", - { - showDetailedProgress: !hasSelectedAuthors - } - ), - spreadFilter.filter + const resolvedRecords = await exportRecords( + exportTarget.target, + hasSelectedAuthors ? "提交已选达人中" : "提交中", + { + showDetailedProgress: !hasSelectedAuthors + } ); const records = exportTarget.target.mode === "all" @@ -559,11 +543,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { } } }; - toolbar = ensurePluginToolbar( - options.document, - toolbarHandlers, - readMetricFilterDefinitions() - ); + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); const ready = (async () => { await runSyncCycle(); @@ -736,11 +716,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { function applyCurrentView(): void { runWithoutMutationSync(() => { - toolbar = ensurePluginToolbar( - options.document, - toolbarHandlers, - readMetricFilterDefinitions() - ); + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); const table = syncMarketTable(options.document); if (!table) { return; @@ -1126,51 +1102,6 @@ export function createMarketController(options: CreateMarketControllerOptions) { return selectedRecords.length > 0 ? selectedRecords : records; } - async function applyMetricThresholdFilter( - records: MarketRecord[], - filter: MetricThresholdFilter | undefined - ): Promise { - 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[] { if (selectedAuthorIds.size === 0) { 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 { try { const rawValue = options.window.localStorage?.getItem( @@ -2046,11 +1970,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { } async function runSingleSyncCycle(): Promise { - toolbar = ensurePluginToolbar( - options.document, - toolbarHandlers, - readMetricFilterDefinitions() - ); + toolbar = ensurePluginToolbar(options.document, toolbarHandlers); if (isPluginToolbarMounted(toolbar.root, options.document)) { toolbarRemountScheduled = false; } diff --git a/src/content/market/metric-filter.ts b/src/content/market/metric-filter.ts deleted file mode 100644 index 1af8e11..0000000 --- a/src/content/market/metric-filter.ts +++ /dev/null @@ -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, - 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; -} diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index a7b5de3..3cfd4be 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,11 +1,4 @@ -import type { MetricFilterDefinition } from "./metric-filter"; -import type { - MarketExportScope, - MarketExportTarget, - MetricFilterOperator, - MetricFilterRule, - MetricThresholdFilter -} from "./types"; +import type { MarketExportScope, MarketExportTarget } from "./types"; export interface PluginToolbarHandlers { onExportAudienceProfile(): Promise | void; @@ -14,31 +7,6 @@ export interface PluginToolbarHandlers { onSubmitBatch(): Promise | void; } -interface MetricFilterRuleDom { - definition: MetricFilterDefinition; - operatorSelect: HTMLSelectElement; - removeButton: HTMLButtonElement; - root: HTMLElement; - thresholdInput: HTMLInputElement; -} - -interface MetricCatalogDom { - actions: Map; - addButton: HTMLButtonElement; - closeButton: HTMLButtonElement; - emptyState: HTMLElement; - items: Map; - panel: HTMLElement; - root: HTMLElement; - searchInput: HTMLInputElement; - selectedCount: HTMLElement; -} - -interface ToolbarResizeListener { - handler: EventListener; - window: Window; -} - export interface PluginToolbarDom { audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement; @@ -47,18 +15,14 @@ export interface PluginToolbarDom { exportCustomPagesInput: HTMLInputElement; exportRangeSelect: HTMLSelectElement; exportStatusText: HTMLElement; - metricCatalog: MetricCatalogDom; - metricRules: Map; root: HTMLElement; } 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"); -type ToolbarRootWithResizeListener = HTMLElement & { +type ToolbarRootWithDom = HTMLElement & { [TOOLBAR_DOM]?: PluginToolbarDom; - [TOOLBAR_RESIZE_LISTENER]?: ToolbarResizeListener; }; export function isPluginToolbarMounted( @@ -66,50 +30,28 @@ export function isPluginToolbarMounted( document: Document ): boolean { const actionRow = findNativeActionRow(document); - const isMounted = Boolean( - actionRow && root.parentElement === actionRow && !root.hidden - ); - if (!isMounted) { - cleanupToolbarResizeListener(root); - } - return isMounted; + return Boolean(actionRow && root.parentElement === actionRow && !root.hidden); } export function ensurePluginToolbar( document: Document, - handlers: PluginToolbarHandlers, - metricDefinitions: MetricFilterDefinition[] = [] + handlers: PluginToolbarHandlers ): PluginToolbarDom { ensurePluginActionButtonTheme(document); - const catalogVersion = metricDefinitions - .map(({ field, group }) => `${group}\u0001${field}`) - .join("\u0002"); const existingRoot = document.querySelector( "[data-plugin-toolbar='root']" - ) as HTMLElement | null; - const existingToolbar = (existingRoot as ToolbarRootWithResizeListener | null)?.[ - TOOLBAR_DOM - ]; - if ( - existingRoot && - existingToolbar && - existingRoot.dataset.pluginMetricCatalogVersion === catalogVersion && - existingRoot.querySelector('[data-plugin-metric-catalog-trigger="button"]') - ) { + ) as ToolbarRootWithDom | null; + const existingToolbar = existingRoot?.[TOOLBAR_DOM]; + if (existingRoot && existingToolbar) { ensureToolbarMounted(existingRoot, document); - ensureToolbarResizeListener(existingToolbar); return existingToolbar; } - if (existingRoot) { - cleanupToolbarResizeListener(existingRoot); - existingRoot.remove(); - } + existingRoot?.remove(); - const root = document.createElement("section"); + const root = document.createElement("section") as ToolbarRootWithDom; root.dataset.pluginToolbar = "root"; - root.dataset.pluginMetricCatalogVersion = catalogVersion; applyToolbarRootStyles(root); const exportRangeSelect = document.createElement("select"); @@ -132,64 +74,56 @@ export function ensurePluginToolbar( const audienceProfileExportButton = createActionButton( document, "导出选中达人数据", - "pluginExportAudienceProfile", - "button" + "pluginExportAudienceProfile" ); audienceProfileExportButton.title = "仅导出已勾选达人,包含内容数据、效果预估、画像等维度"; - const audienceProfileByIdExportButton = createActionButton( - document, - "按星图ID导出", - "pluginExportAudienceProfileById", - "button" - ); - audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"; - const audienceProfileFieldButton = createActionButton( document, "选择字段", - "pluginAudienceProfileFields", - "button" + "pluginAudienceProfileFields" ); - audienceProfileFieldButton.title = "勾选本次CSV需要导出的字段,设置会自动保存"; + audienceProfileFieldButton.title = "搜索并勾选本次CSV需要导出的字段,设置会自动保存"; + + const audienceProfileByIdExportButton = createActionButton( + document, + "按星图ID导出", + "pluginExportAudienceProfileById" + ); + audienceProfileByIdExportButton.title = "粘贴达人星图ID后批量导出达人数据,不依赖当前列表勾选"; const batchSubmitButton = createActionButton( document, "提交批次", - "pluginBatchSubmit", - "button" + "pluginBatchSubmit" ); batchSubmitButton.title = "将当前选中的达人提交到后续业务批次"; const exportStatusText = document.createElement("span"); exportStatusText.dataset.pluginExportStatus = "text"; + exportStatusText.setAttribute("aria-live", "polite"); applyStatusStyles(exportStatusText); - const rulesGroup = document.createElement("div"); - rulesGroup.dataset.pluginMetricRules = "root"; - applyRulesGroupStyles(rulesGroup); - const metricRules = new Map(); - const metricCatalog = createMetricCatalog(document, metricDefinitions); - const panel = document.createElement("div"); panel.dataset.pluginToolbarPanel = "root"; applyToolbarPanelStyles(panel); - const firstRow = document.createElement("div"); - firstRow.dataset.pluginToolbarRow = "primary"; - applyToolbarRowStyles(firstRow); - const secondRow = document.createElement("div"); - secondRow.dataset.pluginToolbarRow = "thresholds"; - applyToolbarRowStyles(secondRow); + const primaryRow = document.createElement("div"); + primaryRow.dataset.pluginToolbarRow = "primary"; + applyToolbarRowStyles(primaryRow); const dataGroup = document.createElement("div"); dataGroup.dataset.pluginToolbarGroup = "data"; applyToolbarGroupStyles(dataGroup); - const selectedAudienceExportGroup = createToolbarActionGroup(document, "selected-audience-export", [ - audienceProfileExportButton, - audienceProfileFieldButton, - exportRangeSelect, - exportCustomPagesInput - ]); + const selectedAudienceExportGroup = createToolbarActionGroup( + document, + "selected-audience-export", + [ + audienceProfileExportButton, + audienceProfileFieldButton, + exportRangeSelect, + exportCustomPagesInput + ] + ); const idExportGroup = createToolbarActionGroup(document, "id-export", [ audienceProfileByIdExportButton ]); @@ -204,11 +138,8 @@ export function ensurePluginToolbar( batchSubmitGroup ); - const filterTitle = createToolbarGroupTitle(document, "指标筛选"); - const filterNote = createFilterNote(document); - firstRow.append(dataGroup, exportStatusText); - secondRow.append(filterTitle, metricCatalog.root, rulesGroup, filterNote); - panel.append(firstRow, secondRow); + primaryRow.append(dataGroup, exportStatusText); + panel.append(primaryRow); root.append(panel); document.body.appendChild(root); @@ -220,11 +151,9 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportRangeSelect, exportStatusText, - metricCatalog, - metricRules, root } satisfies PluginToolbarDom; - (root as ToolbarRootWithResizeListener)[TOOLBAR_DOM] = toolbarDom; + root[TOOLBAR_DOM] = toolbarDom; applyNativeControlStyles(document, toolbarDom); ensureToolbarMounted(root, document); @@ -244,42 +173,65 @@ export function ensurePluginToolbar( exportRangeSelect.addEventListener("change", () => { 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); - syncMetricCatalog(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( document: Document, label: string, - datasetKey: string, - datasetValue: string + datasetKey: string ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; - button.dataset[datasetKey] = datasetValue; + button.dataset[datasetKey] = "button"; button.textContent = label; 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"); option.value = value; option.textContent = label; @@ -306,290 +258,10 @@ function createToolbarActionDivider(document: Document): HTMLElement { 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(); - const items = new Map(); - const definitionsByGroup = new Map(); - 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 { 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(); - 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("[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 { const actionRow = findNativeActionRow(document); if (!actionRow) { @@ -597,7 +269,9 @@ function ensureToolbarMounted(root: HTMLElement, document: Document): void { return; } const customizeButton = findNativeActionButton(actionRow, "自定义指标"); - const anchor = customizeButton ? findDirectChildAnchor(actionRow, customizeButton) : null; + const anchor = customizeButton + ? findDirectChildAnchor(actionRow, customizeButton) + : null; if (anchor) actionRow.insertBefore(root, anchor); else if (root.parentElement !== actionRow) actionRow.prepend(root); root.hidden = false; @@ -611,24 +285,30 @@ function findNativeActionRow(document: Document): HTMLElement | null { customizeButton && exportButton ? findSmallestSharedActionRow(customizeButton, exportButton, header) : null; - if (sharedActionRow) { - return sharedActionRow; - } + if (sharedActionRow) return sharedActionRow; + const scope = header ?? document; const candidates = Array.from( scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header") ).filter((element): element is HTMLElement => element instanceof document.defaultView!.HTMLElement ); - return candidates - .filter((candidate) => - isNativeActionRowCandidate(candidate, customizeButton, exportButton) - ) - .sort((left, right) => { - const depthDelta = - getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header); - return depthDelta || normalizeText(left.textContent).length - normalizeText(right.textContent).length; - })[0] ?? null; + return ( + candidates + .filter((candidate) => + isNativeActionRowCandidate(candidate, customizeButton, exportButton) + ) + .sort((left, right) => { + const depthDelta = + getDepthWithinAncestor(right, header) - + getDepthWithinAncestor(left, header); + return ( + depthDelta || + normalizeText(left.textContent).length - + normalizeText(right.textContent).length + ); + })[0] ?? null + ); } function findHeaderContainer( @@ -685,7 +365,10 @@ function isNativeActionRowCandidate( ...Array.from(child.querySelectorAll("button")) ]) .map((button) => normalizeText(button.textContent)); - return labels.includes("导出") && (labels.includes("自定义指标") || Boolean(customizeButton)); + return ( + labels.includes("导出") && + (labels.includes("自定义指标") || Boolean(customizeButton)) + ); } function getDepthWithinAncestor( @@ -701,18 +384,29 @@ function getDepthWithinAncestor( 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; if (!document) return null; - return Array.from(root.querySelectorAll("button")).find( - (element): element is HTMLButtonElement => - element instanceof document.defaultView!.HTMLButtonElement && normalizeText(element.textContent) === text - ) ?? null; + return ( + Array.from(root.querySelectorAll("button")).find( + (element): element is HTMLButtonElement => + element instanceof document.defaultView!.HTMLButtonElement && + 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; - while (current?.parentElement && current.parentElement !== parent) current = current.parentElement; + while (current?.parentElement && current.parentElement !== parent) { + current = current.parentElement; + } return current?.parentElement === parent ? current : null; } @@ -721,172 +415,167 @@ function normalizeText(value: string | null): string { } 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - Object.assign(catalog.style, { alignItems: "center", display: "inline-flex", flex: "0 0 auto", gap: "8px", position: "relative" }); -} - -function applyMetricCatalogTriggerStyles(button: HTMLButtonElement): void { - applySecondaryButtonStyles(button); - Object.assign(button.style, { fontSize: "12px", height: "32px", padding: "0 10px" }); -} - -function applyMetricCatalogCountStyles(count: HTMLElement): void { - Object.assign(count.style, { color: "#64748b", fontSize: "12px", fontWeight: "700", whiteSpace: "nowrap" }); -} - -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) => { +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; }); - [toolbar.audienceProfileExportButton, toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileFieldButton].forEach(applySecondaryButtonStyles); + [ + toolbar.audienceProfileExportButton, + toolbar.audienceProfileByIdExportButton, + toolbar.audienceProfileFieldButton + ].forEach(applySecondaryButtonStyles); applyPrimaryButtonStyles(toolbar.batchSubmitButton); - [toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach(applyInputStyles); + [toolbar.exportRangeSelect, toolbar.exportCustomPagesInput].forEach( + applyInputStyles + ); toolbar.exportRangeSelect.style.minWidth = "104px"; 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 { - 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 { - 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 { - Object.assign(element.style, { background: "#ffffff", border: "1px solid #d0d7de", borderRadius: "6px", boxSizing: "border-box", color: "#1f2329", height: "32px", padding: "0 8px" }); +function applyInputStyles( + 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 { - 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 { - if (document.getElementById(PLUGIN_ACTION_BUTTON_STYLE_ID)) return; - const style = document.createElement("style"); - style.id = PLUGIN_ACTION_BUTTON_STYLE_ID; + let style = document.getElementById( + PLUGIN_ACTION_BUTTON_STYLE_ID + ) as HTMLStyleElement | null; + if (!style) { + style = document.createElement("style"); + style.id = PLUGIN_ACTION_BUTTON_STYLE_ID; + document.head.appendChild(style); + } 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='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-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; } + [data-plugin-export-status='text'] { flex-basis: 100% !important; text-align: left !important; } } `; - document.head.appendChild(style); } diff --git a/src/content/market/spread-info.ts b/src/content/market/spread-info.ts index cabc3f8..ff9943e 100644 --- a/src/content/market/spread-info.ts +++ b/src/content/market/spread-info.ts @@ -1,7 +1,6 @@ import type { SpreadInfoConfig, - SpreadInfoMetrics, - SpreadMetricFilterRule + SpreadInfoMetrics } from "./types"; interface FetchResponseLike { @@ -110,12 +109,6 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) { } return metrics; - }, - async loadAuthorSpreadMetricSnapshot( - authorId: string, - config: SpreadInfoConfig - ): Promise { - 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( config: SpreadInfoConfig, metric: SpreadInfoMetricDefinition @@ -366,15 +333,6 @@ function readNumberLike(value: unknown): number | 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 { if (value === null) { return undefined; diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 420962a..aa116fb 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -21,30 +21,6 @@ export interface SpreadInfoConfig { 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 = | keyof Required | keyof Required; diff --git a/tests/audience-profile-field-dialog.test.ts b/tests/audience-profile-field-dialog.test.ts index 9b59042..24209f5 100644 --- a/tests/audience-profile-field-dialog.test.ts +++ b/tests/audience-profile-field-dialog.test.ts @@ -18,16 +18,166 @@ describe("audience-profile-field-dialog", () => { ); const fields = Array.from( - document.querySelectorAll('[data-audience-profile-field-dialog-field="checkbox"]') + document.querySelectorAll( + '[data-audience-profile-field-dialog-field="checkbox"]' + ) ) as HTMLInputElement[]; expect(fields).toHaveLength(2); expect(fields.every((field) => !field.checked)).toBe(true); + expect( + document.querySelectorAll( + "[data-audience-profile-field-dialog-selected-field]" + ) + ).toHaveLength(0); + expect( + document.querySelector( + '[data-audience-profile-field-dialog-selected-empty="text"]' + )?.hidden + ).toBe(false); - const saveButton = document.querySelector( - '[data-audience-profile-field-dialog-save="button"]' - ) as HTMLButtonElement; - saveButton.click(); - + 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( + '[data-audience-profile-field-dialog-save="button"]' + ) as HTMLButtonElement; + saveButton.click(); +} + +function readResultItem(field: string): HTMLElement | undefined { + return Array.from( + document.querySelectorAll( + "[data-audience-profile-field-dialog-result]" + ) + ).find( + (element) => element.dataset.audienceProfileFieldDialogResult === field + ); +} + +function readResultInput(field: string): HTMLInputElement { + const input = Array.from( + document.querySelectorAll( + '[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( + '[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( + "[data-audience-profile-field-dialog-selected-field]" + ) + ).map((element) => element.dataset.audienceProfileFieldDialogSelectedField ?? ""); +} diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 87be2c2..d45dec9 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -377,7 +377,7 @@ describe("market-content-entry", () => { ); expect(audienceProfileFieldButton?.textContent).toBe("选择字段"); expect(audienceProfileFieldButton?.title).toBe( - "勾选本次CSV需要导出的字段,设置会自动保存" + "搜索并勾选本次CSV需要导出的字段,设置会自动保存" ); expect(batchSubmitButton?.title).toBe("将当前选中的达人提交到后续业务批次"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); @@ -426,9 +426,6 @@ describe("market-content-entry", () => { const primaryRow = document.querySelector( '[data-plugin-toolbar-row="primary"]' ) as HTMLElement | null; - const thresholdRow = document.querySelector( - '[data-plugin-toolbar-row="thresholds"]' - ) as HTMLElement | null; const dataGroup = document.querySelector( '[data-plugin-toolbar-group="data"]' ) as HTMLElement | null; @@ -441,18 +438,9 @@ describe("market-content-entry", () => { const batchSubmitGroup = document.querySelector( '[data-plugin-toolbar-action-group="batch-submit"]' ) 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( '[data-plugin-export-status="text"]' ) as HTMLElement | null; - const titles = Array.from( - document.querySelectorAll("[data-plugin-toolbar-title]") - ) as HTMLElement[]; const exportRangeSelect = document.querySelector( '[data-plugin-export-range="select"]' ) as HTMLSelectElement | null; @@ -471,24 +459,10 @@ describe("market-content-entry", () => { const batchSubmitButton = document.querySelector( '[data-plugin-batch-submit="button"]' ) 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(panel?.style.flexDirection).toBe("column"); expect(panel?.style.alignItems).toBe("center"); expect(primaryRow?.style.flexWrap).toBe("nowrap"); - expect(thresholdRow?.style.flexWrap).toBe("nowrap"); - expect(thresholdRow?.style.alignItems).toBe("center"); - expect(panel?.style.overflowX).toBe("visible"); - expect(panel?.style.overflowY).toBe("visible"); + expect(panel?.style.overflow).toBe("visible"); expect(dataGroup?.parentElement).toBe(primaryRow); expect( document.querySelector('[data-plugin-toolbar-action-group="csv-export"]') @@ -509,31 +483,11 @@ describe("market-content-entry", () => { batchSubmitButton ]); 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(thresholdRow?.style.justifyContent).toBe("flex-start"); - expect(titles.map((element) => element.textContent)).toEqual([ - "指标筛选" - ]); - 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(document.querySelector('[data-plugin-toolbar-row="thresholds"]')).toBeNull(); + expect(document.querySelector('[data-plugin-metric-catalog]')).toBeNull(); + expect(document.querySelector('[data-plugin-metric-filter-rule]')).toBeNull(); + expect(document.body.textContent).not.toContain("指标筛选"); expect([ audienceProfileExportButton?.textContent, audienceProfileByIdExportButton?.textContent, @@ -1357,237 +1311,6 @@ describe("market-content-entry", () => { 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 () => { document.body.innerHTML = buildRealMarketFixture([ { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } @@ -2288,6 +2011,18 @@ describe("market-content-entry", () => { await controller.ready; 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( 'input[data-audience-profile-field-dialog-field="checkbox"][value="秒思api-看后搜数"]' ) as HTMLInputElement | null; @@ -2333,8 +2068,6 @@ describe("market-content-entry", () => { await controller.ready; setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -2348,76 +2081,6 @@ describe("market-content-entry", () => { 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 () => { document.body.innerHTML = buildMarketFixture(); const submitBatch = vi.fn(async () => ({ ok: true })); @@ -2439,7 +2102,6 @@ describe("market-content-entry", () => { })); await controller.ready; - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); expect(submitBatch).not.toHaveBeenCalled(); @@ -2483,7 +2145,6 @@ describe("market-content-entry", () => { })); await controller.ready; - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-confirm="button"]'); await flush(); @@ -2518,7 +2179,6 @@ describe("market-content-entry", () => { })); await controller.ready; - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); click('[data-plugin-batch-name-cancel="button"]'); await flush(); @@ -2559,8 +2219,6 @@ describe("market-content-entry", () => { clickSelectionCheckboxForAuthor("222"); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -2646,8 +2304,6 @@ describe("market-content-entry", () => { await controller.ready; clickSelectionCheckboxForAuthor("111"); - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -2766,8 +2422,6 @@ describe("market-content-entry", () => { const rowSelectionCheckbox = readSelectionCheckboxForAuthor("111"); rowSelectionCheckbox.checked = true; - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -2848,8 +2502,6 @@ describe("market-content-entry", () => { await flushWithTimers(); setSelectValue('[data-plugin-export-range="select"]', "current"); dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForMockCall(submitBatch, 40, 50); @@ -2906,7 +2558,6 @@ describe("market-content-entry", () => { clickSelectionCheckboxForAuthor("111"); setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await waitForCondition(() => @@ -2994,8 +2645,6 @@ describe("market-content-entry", () => { setSelectValue('[data-plugin-export-range="select"]', "all"); dispatchChange('[data-plugin-export-range="select"]'); - - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); for (let attempt = 0; attempt < 40; attempt += 1) { if ( @@ -3054,7 +2703,6 @@ describe("market-content-entry", () => { })); await controller.ready; - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await flush(); @@ -3087,7 +2735,6 @@ describe("market-content-entry", () => { })); await controller.ready; - removeDefaultSpreadMetricFilter(); click('[data-plugin-batch-submit="button"]'); await flush(); @@ -4903,68 +4550,6 @@ function click(selector: string) { element.click(); } -function findMetricCatalogAction(field: string): HTMLButtonElement | null { - return Array.from( - document.querySelectorAll("[data-plugin-metric-catalog-action]") - ).find((element) => element.dataset.pluginMetricCatalogAction === field) ?? null; -} - -function findMetricCatalogItem(field: string): HTMLElement | null { - return Array.from( - document.querySelectorAll("[data-plugin-metric-catalog-item]") - ).find((element) => element.dataset.pluginMetricCatalogItem === field) ?? null; -} - -function findMetricRule(field: string): HTMLElement | null { - return Array.from( - document.querySelectorAll("[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() { return { onConfigureAudienceProfileFields: vi.fn(), diff --git a/tests/metric-filter.test.ts b/tests/metric-filter.test.ts deleted file mode 100644 index 96b156e..0000000 --- a/tests/metric-filter.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/tests/spread-info.test.ts b/tests/spread-info.test.ts index 294a3ec..d6e12e2 100644 --- a/tests/spread-info.test.ts +++ b/tests/spread-info.test.ts @@ -1,14 +1,11 @@ import { describe, expect, test, vi } from "vitest"; import { - buildSpreadInfoConfigKey, buildSpreadInfoColumns, buildSpreadInfoUrl, createSpreadInfoClient, DEFAULT_SPREAD_INFO_CONFIGS, filterUnloadedSpreadInfoConfigs, - matchesSpreadMetricRule, - normalizeSpreadInfoConfig, mapSpreadInfoResponse, selectSpreadInfoConfigsForHeaders } from "../src/content/market/spread-info"; @@ -246,12 +243,14 @@ describe("spread-info", () => { }); await expect( - client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }) + client.loadAuthorSpreadMetrics("7361012802036695050", [ + { + flowType: 0, + onlyAssign: true, + range: 2, + type: 2 + } + ]) ).rejects.toThrow("HTTP 429: Too Many Requests"); }); @@ -269,12 +268,14 @@ describe("spread-info", () => { }); await expect( - client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }) + client.loadAuthorSpreadMetrics("7361012802036695050", [ + { + flowType: 0, + onlyAssign: true, + range: 2, + type: 2 + } + ]) ).rejects.toThrow( "星图传播数据接口被限流 (status_code=31157): 您访问过于频繁,请24小时后重试" ); @@ -288,101 +289,14 @@ describe("spread-info", () => { }); await expect( - client.loadAuthorSpreadMetricSnapshot("7361012802036695050", { - flowType: 0, - onlyAssign: true, - range: 2, - type: 2 - }) + client.loadAuthorSpreadMetrics("7361012802036695050", [ + { + flowType: 0, + onlyAssign: true, + range: 2, + type: 2 + } + ]) ).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); - }); });