feat: enhance knowledge dashboard analytics

This commit is contained in:
Wenruli
2026-08-21 13:30:09 +08:00
parent cb53642b6d
commit 08ec22af48
27 changed files with 1799 additions and 301 deletions
@@ -3,11 +3,11 @@
## 元信息 Metadata
- Feature ID: `055-knowledge-space-content-stat-rebuild`
- Status: `design-updated-awaiting-confirmation`
- Status: `req008-correction-implemented-verified`
- Based on: `requirements.md``REQ-001``REQ-008`
- Created: `2026-08-20`
- Updated: `2026-08-20`
- Implementation state: `REQ-001``REQ-007` 的代码实现和只读验证已完成;`REQ-008` 已形成设计和任务,尚未实现;实际索引重建仍需 T014 单独确认
- Implementation state: `REQ-001``REQ-008` 已完成实现与自动化验证;`share_of_parent` 已替换为 `share_of_total`;实际索引重建仍需 T014 单独确认
## 1. 设计目标与边界
@@ -19,7 +19,7 @@
4. 让预览、下载统计失败不影响主业务,并能从带快照的原始事件幂等补偿。
5. 组织、主组织或知识库绑定变化后准实时覆写受影响的 `file` 快照,每日全量任务只作为最终一致性校准。
6. 破坏性重建后只同步当前有效文件,不恢复旧预览、下载、收藏和 `portal_engagement_daily` 历史。
7. 通过可声明的虚拟指标策略提供上传人和文件所属组织知识贡献占比,并支持父级组织及非组织切片分母
7. 通过可声明的虚拟指标策略提供单一通用知识贡献占比,使任意维度或维度组合的文件数都除以当前筛选范围有效文件总数
### 1.2 非目标
@@ -29,7 +29,7 @@
- 不引入 `tenant_id`、组织 ID 或新的关系型数据库表。
- 不修改文件可见性、下载权限、收藏权限和组织管理权限。
- 不为知识贡献占比新增 ES 字段、记录类型、历史回填或索引重建步骤。
-强制校验知识贡献占比与组织维度的配对关系
-为不同维度分别定义知识贡献占比指标或专用计算规则
## 2. 当前实现依据与问题定位
@@ -47,17 +47,18 @@
| `src/backend/bisheng/telemetry_search/domain/init_dataset.py` | 看板数据集指标和维度定义 | 暴露重复旧部门字段;无 8 个新维度和收藏次数 |
| `src/backend/scripts/rebuild_knowledge_space_content_stat.py` | 删除并重建精确索引 | 预检缺少下载、收藏和门户参与度分类计数;当前重建会恢复旧下载历史 |
### 2.1 `REQ-008` 更新时的实现基线
### 2.1 `REQ-008` 口径纠正时的实现基线
`REQ-001``REQ-007` 已按本设计实现。新增贡献占比涉及的当前基线如下:
`REQ-001``REQ-007` 已按本设计实现。`T015``T018` 曾按错误理解实现组织父级占比,当前基线和纠正差距如下:
| 位置 | 当前能力 | `REQ-008` 差距 |
|---|---|---|
| `src/backend/bisheng/telemetry_search/domain/models/dashboard_dataset.py` | `MetricConfig` 支持普通聚合、`formula``index``sum_field` | 没有“占总体/父级”策略、组织层级字段列表和默认格式元数据 |
| `src/backend/bisheng/telemetry_search/domain/services/component.py` | `query_formula_metric` 分两次查询并按相同维度合并 | 分母无法只移除最末级组织维度;直接 divide 会得到逐行自身相除 |
| `src/backend/bisheng/telemetry_search/domain/schemas/query_builder.py` | 支持 term、terms、range、match_all、match_phrase | 缺少显式 `exists` 过滤,无法保证缺失组织字段同时退出分子和分母 |
| `src/frontend/platform/src/pages/Dashboard/components/config/DatasetSelector.tsx` | 传递虚拟指标和 divide 标记 | 未传递数据集级默认数值格式 |
| `src/frontend/platform/src/pages/Dashboard/components/config/useChartState.tsx` | divide 指标默认百分比、2 位小数 | 无法只让两个贡献占比默认 1 位小数 |
| `src/backend/bisheng/telemetry_search/domain/models/dashboard_dataset.py` | 已声明 `SHARE_OF_PARENT``share_dimension_hierarchy` 和默认格式 | 应改为不依赖层级的 `SHARE_OF_TOTAL`,层级声明不再需要 |
| `src/backend/bisheng/telemetry_search/domain/services/component.py` | `query_share_of_parent_metric` 对非组织维度直接返回 `1.0`,组织维度只移除最深层级 | 导致普通维度恒为 `100%`,且组织维度分母不是有效文件总数 |
| `src/backend/bisheng/telemetry_search/domain/schemas/query_builder.py` | 曾增加 `ExistsOp` | 新口径不追加维度 `exists` 过滤;该操作符无其他调用,已删除 |
| `src/backend/bisheng/telemetry_search/domain/init_dataset.py` | 暴露两个组织专用占比指标 | 应替换成唯一 `knowledge_contribution_ratio` |
| `src/frontend/platform/src/pages/Dashboard/components/config/DatasetSelector.tsx` | 已能透传数据集默认数值格式 | 继续复用,无需按维度区分 |
| `src/frontend/platform/src/pages/Dashboard/components/config/useChartState.tsx` | 已能采用数据集默认百分比格式 | 保留 percent + 1 位小数行为 |
## 3. 目标数据模型
@@ -307,8 +308,7 @@ Redis 保存 `REPLAY_FLOOR_KEY`。破坏性重建持有投影锁后记录 `rebui
| 预览次数 | `sum(preview_count)` | `record_type=preview_daily`、有效空间级别 |
| 下载次数 | `sum(download_count)` | `record_type=download_daily`、有效空间级别 |
| 收藏次数 | `sum(favorite_count)` | `record_type=favorite_daily`、有效空间级别 |
| 上传人知识贡献占比 | `share_of_parent(value_count(file_id))` | `record_type=file``file_type=1`、有效空间级别、当前上传人组织字段存在 |
| 文件所属知识贡献占比 | `share_of_parent(value_count(file_id))` | `record_type=file``file_type=1`、有效空间级别、当前所属组织字段存在 |
| 知识贡献占比 | `share_of_total(value_count(file_id))` | `record_type=file``file_type=1`、有效空间级别 |
收藏次数是独立指标,不读取普通 `portal_favorite` 事件数量,也不读取 `portal_engagement_daily`
@@ -318,54 +318,51 @@ Redis 保存 `REPLAY_FLOOR_KEY`。破坏性重建持有投影锁后记录 `rebui
启动时数据集 seed 会刷新系统数据集定义。已有自定义看板若引用已移除字段将失效,本次不自动重写,其迁移仍是独立事项 `OPEN-002`,不阻塞本功能实现。
### 9.3 通用 `share_of_parent` 虚拟指标策略
### 9.3 通用 `share_of_total` 虚拟指标策略
扩展 `MetricConfig`,增加三个可选且向后兼容的声明字段
`MetricConfig` 使用以下声明
```text
calculation = "share_of_parent"
share_dimension_hierarchy = [company_field, department_field, office_field, squad_field]
calculation = "share_of_total"
default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: false}
```
- `calculation` 使用新枚举 `VirtualMetricCalculationEnum.SHARE_OF_PARENT`;已有 `formula``index``sum_field` 语义不变。
- `share_dimension_hierarchy` 由数据集声明,不在查询服务中硬编码 `mid_knowledge_space_content_stat`具体指标字段名。
- 两个贡献指标分别声明上传人和文件所属组织的四级字段顺序
- `calculation` 使用 `VirtualMetricCalculationEnum.SHARE_OF_TOTAL`;已有 `formula``index``sum_field` 语义不变。
- 删除新引入但口径错误的 `SHARE_OF_PARENT` 调度和 `share_dimension_hierarchy` 配置,不在查询服务中硬编码 `mid_knowledge_space_content_stat`具体指标字段名或任何维度名
- 系统数据集只声明一个 `knowledge_contribution_ratio` 指标,基础过滤与“总文件数”一致
- `default_number_format` 只作为新指标首次加入组件时的默认值;保存后的组件格式继续以自身 `data_config.metrics[].numberFormat` 为准。
查询算法:
1. 将普通维度和堆叠维度按结果顺序视为统一维度列表
2. `share_dimension_hierarchy` 中找出当前查询已经选择的字段,并按声明层级选取最深字段作为 `target_dimension`,不依赖拖放顺序
3.查询保留全部维度和全部筛选,使用指标声明的 `value_count(file_id)`,并追加 `exists(target_dimension)`
4. 分母查询复制全部筛选和聚合,但从分组维度中只移除 `target_dimension`;父级组织、时间、业务域、空间等其他维度全部保留,同时追加相同的 `exists(target_dimension)`
5. 以“分子结果去掉目标维度后的上下文键”关联分母结果,返回原始完整维度列和 `numerator / denominator`
6. 分母缺失或为 0 时返回 `0`;分子或分母都不做百分数乘 100,展示层负责格式化
7. 若未选择任何声明的组织维度,不抛配对错误;将当前过滤和非组织分组上下文视为整体,非空上下文返回 `1`。该行为只保证查询稳定,不属于组织贡献口径的推荐用法。
`query_builder.py` 新增通用 `ExistsOp`,生成 `{ "exists": { "field": ... } }`。它属于查询 Schema 的向后兼容扩展,也可被其他数据集复用。
1. 分子查询保留全部普通维度、stack dimension、指标基础过滤和请求筛选,使用指标声明的 `value_count(file_id)`
2. 分母查询复制相同指标聚合和全部过滤,但把 `dimensions` 设为空列表、`stack_dimension` 设为 `None`;不得移除看板条件筛选、联动筛选或时间范围
3.查询不追加任何维度 `exists` 过滤,因此缺少所选维度值的有效文件仍计入总数
4. 分母结果是当前筛选范围的单一有效文件总数;每个分子结果行都除以该值,返回分子原始完整维度列和比例
5. 分母缺失或为 0 时返回 `0`;分子或分母都不乘 100,展示层负责格式化
6. 未选择任何维度时,分子和分母均为当前筛选范围总数,因此非零返回 `1`,零文件返回 `0`
### 9.4 结果形状与排序兼容
- `query_share_of_parent_metric` 始终返回与分子查询相同的完整维度列顺序,因此 `query_all_metrics` 仍可按现有 tuple key 合并多个指标。
- 分母查询内部减少一个维度,其结果不直接暴露给前端。
- 排序、Top N 和组件结果限制继续在占比计算完成后执行;Top N 只限制展示行,不缩小分母,所以只展示部分组织时可见占比之和允许小于 100%
- 看板条件筛选和联动维度筛选同时作用于分子与分母;即使筛选字段等于目标组织字段,也不移除该筛选条件
- `query_share_of_total_metric` 始终返回与分子查询相同的完整维度列顺序,因此 `query_all_metrics` 仍可按现有 tuple key 合并多个指标。
- 分母查询内部不包含任何分组维度,其结果不直接暴露给前端。
- 排序、Top N 和组件结果限制继续在占比计算完成后执行;Top N 只限制展示行,不缩小分母。
- 缺失维度值的文件不产生分子行但仍进入分母,或 Top N 隐藏部分行时,可见占比之和允许小于 `100%`
- 看板条件筛选和联动维度筛选同时作用于分子与分母;即使筛选字段也被用于分组,也不移除该筛选条件。
### 9.5 文件结构计划 File Structure Plan
| 文件 | 变更 | 目的 |
|---|---|---|
| `src/backend/bisheng/telemetry_search/domain/models/dashboard_dataset.py` | 修改 | 新增通用虚拟计算策略、层级字段列表和默认格式元数据 |
| `src/backend/bisheng/telemetry_search/domain/schemas/query_builder.py` | 修改 | 新增通用 `ExistsOp` |
| `src/backend/bisheng/telemetry_search/domain/services/component.py` | 修改 | 实现父级/总体分母查询和维度键合并;保留现有 divide 路径 |
| `src/backend/bisheng/telemetry_search/domain/init_dataset.py` | 修改 | 注册两个知识贡献占比指标及两套组织层级 |
| `src/backend/test/telemetry_search/test_knowledge_contribution_ratio.py` | 新增 | 参数化覆盖单层、多层、非组织切片、空值、零分母和回归 |
| `src/backend/test/telemetry_search/test_knowledge_space_content_dataset.py` | 修改 | 固定两个指标的数据集声明契约 |
| `src/backend/bisheng/telemetry_search/domain/models/dashboard_dataset.py` | 修改 | 将错误的 `SHARE_OF_PARENT`/层级配置替换为 `SHARE_OF_TOTAL`,保留默认格式元数据 |
| `src/backend/bisheng/telemetry_search/domain/schemas/query_builder.py` | 修改 | 删除仅由错误占比实现引入且当前无其他调用的 `ExistsOp` |
| `src/backend/bisheng/telemetry_search/domain/services/component.py` | 修改 | 实现移除全部分组的总体分母查询;保留现有 divide 路径 |
| `src/backend/bisheng/telemetry_search/domain/init_dataset.py` | 修改 | 删除两个错误指标,注册单一通用知识贡献占比指标 |
| `src/backend/test/telemetry_search/test_knowledge_contribution_ratio.py` | 修改 | 参数化覆盖任意单维、多维组合、筛选、缺失维度入分母、无维度、零分母和回归 |
| `src/backend/test/telemetry_search/test_knowledge_space_content_dataset.py` | 修改 | 固定单一指标的数据集声明契约,并断言两个旧指标不存在 |
| `src/frontend/platform/src/controllers/API/dashboard.ts` | 修改 | 补齐真实数据集 MetricConfig 类型及默认格式字段 |
| `src/frontend/platform/src/pages/Dashboard/components/config/DatasetSelector.tsx` | 修改 | 将默认格式随指标选择/拖拽传递 |
| `src/frontend/platform/src/pages/Dashboard/components/config/useChartState.tsx` | 修改 | 首次配置时优先采用数据集默认百分比格式 |
| `src/frontend/platform/src/test/knowledgeContributionMetricFormat.test.ts` | 新增 | 验证两个指标默认 percent + 1 位小数,已有 divide 默认不变 |
| `src/frontend/platform/src/test/knowledgeContributionMetricFormat.test.ts` | 修改 | 验证单一指标默认 percent + 1 位小数,已有 divide 默认不变 |
## 10. 并发、失败与兼容性
@@ -376,7 +373,7 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
- 事件原始记录由 `event_id` 作为 ES `_id` 去重。
- 日统计由维度哈希 `_id` 和绝对计数的单调 `max` 更新保证并发安全。
- 重建、当前快照全量和事件队列共享重建锁;重建期间消息可入队但不消费。
- 知识贡献占比执行两次只读聚合查询,不维护共享状态;同一查询请求内使用深拷贝隔离分子和分母的维度、过滤配置
- 知识贡献占比执行“保留全部分组的分子查询”和“移除全部分组的分母查询”两次只读聚合,不维护共享状态;同一请求内使用深拷贝隔离查询参数
### 10.2 失败语义
@@ -388,7 +385,7 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
| 日统计写失败 | 业务已成功、原始事件已存在 | 消息重试或按日期手工回放 |
| 当前文件投影失败 | 组织/文件业务已成功 | 当前队列重试;每日全量最终校准 |
| 占比分子或分母查询失败 | 当前看板组件查询失败 | 沿用查询服务错误响应和日志;不返回伪造比例,不影响数据写入 |
| 占比分母为 0 或找不到匹配上下文 | 当前看板组件查询成功 | 返回 `0` |
| 占比分母为 0 或分母结果缺失 | 当前看板组件查询成功 | 返回 `0` |
在 Redis 和 ES 同时不可用时,系统仍优先保证用户操作成功;此时只能通过告警暴露无法持久化的统计风险,不能虚假承诺绝对零丢失。
@@ -398,9 +395,10 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
- 普通门户遥测调用保持原接口;新增严格写入口只供可重试 Worker 使用。
- `portal_engagement_daily` 的字段和查询保持不变。
- 新索引与旧 Mapping 不兼容,必须通过已确认的删除重建切换,不支持原地映射回退。
- `MetricConfig` 新字段全部可选;未声明 `calculation=share_of_parent` 的数据集继续走原 `formula/index/sum_field/普通聚合` 分支。
- `MetricConfig.calculation``default_number_format` 保持可选;未声明 `calculation=share_of_total` 的数据集继续走原 `formula/index/sum_field/普通聚合` 分支。
- 已保存组件继续使用自身 `numberFormat`,新增默认格式只影响以后首次添加的贡献占比指标。
- `REQ-008` 不需要数据库迁移、ES Mapping 更新或再次重建索引;系统数据集 seed 刷新后即可暴露新指标。
- 引用两个旧占比字段的已保存测试组件不会自动迁移;刷新 seed 后需人工重新选择 `knowledge_contribution_ratio`
## 11. 验证设计
@@ -418,8 +416,8 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
| `V-FAVORITE-DAILY-001` | 收藏事件分桶测试 | 相同快照累计、不同快照拆分 |
| `V-FAVORITE-REBUILD-001` | 重建与回放起点测试 | 旧收藏事件不恢复 |
| `V-FAVORITE-REGRESSION-001` | 门户参与度及预览/下载回归 | 原有独立口径不被收藏改造改变 |
| `V-CONTRIBUTION-SCHEMA-001` | 数据集 seed 契约测试 | 两个指标声明正确,Mapping 和重建脚本无持久化变化 |
| `V-CONTRIBUTION-QUERY-001` | `DataQueryService` 参数化服务测试 | 单层、父级、多维切片、exists、零分母与两套组织层级 |
| `V-CONTRIBUTION-SCHEMA-001` | 数据集 seed 契约测试 | 只暴露一个通用指标,两个指标不存在,Mapping 和重建脚本无持久化变化 |
| `V-CONTRIBUTION-QUERY-001` | `DataQueryService` 参数化服务测试 | 单维、多维组合、普通与 stack dimension、筛选保留、缺失维度入分母、无维度和零分母 |
| `V-CONTRIBUTION-FORMAT-001` | 前端指标选择状态测试 | 新贡献指标默认 percent + 1 位小数,保存后格式可覆盖 |
| `V-CONTRIBUTION-REGRESSION-001` | 查询服务定向回归 | 既有 divide、普通虚拟指标、多个指标结果合并不变 |
@@ -427,9 +425,9 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
| Requirement | Design | Tasks | Verification |
|---|---|---|---|
| `REQ-008` 指标声明与默认格式 | 9.1、9.3、9.5 | `T016`, `T017` | `V-CONTRIBUTION-SCHEMA-001`, `V-CONTRIBUTION-FORMAT-001` |
| `REQ-008` 父级/总体分母与空值 | 9.3、9.4 | `T015` | `V-CONTRIBUTION-QUERY-001` |
| `REQ-008` 兼容性与无持久化变化 | 9.5、10.3、12 | `T015`, `T016`, `T017`, `T018` | `V-CONTRIBUTION-REGRESSION-001`, `V-CONTRIBUTION-SCHEMA-001` |
| `REQ-008` 单一指标声明与默认格式 | 9.1、9.3、9.5 | `T020`, `T021` | `V-CONTRIBUTION-SCHEMA-001`, `V-CONTRIBUTION-FORMAT-001` |
| `REQ-008` 总体分母、多维组合与空值 | 9.3、9.4 | `T019` | `V-CONTRIBUTION-QUERY-001` |
| `REQ-008` 兼容性与无持久化变化 | 9.5、10.3、12 | `T019`, `T020`, `T021`, `T022` | `V-CONTRIBUTION-REGRESSION-001`, `V-CONTRIBUTION-SCHEMA-001` |
## 12. 回退策略
@@ -442,7 +440,7 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
实际执行破坏性重建前必须再次展示 preflight 数量并取得执行确认。
`REQ-008` 本身没有数据迁移或不可逆操作,可独立回退:移除两个数据集指标声明、前端默认格式透传和 `share_of_parent` 查询分支即可;已有索引文档与已保存组件数据不需要恢复
`REQ-008` 本身没有数据迁移或不可逆操作,可独立回退查询策略和数据集 seed;已有索引文档不需要恢复。由于旧占比字段会被新单一指标替换,回退或再次前进后,引用这些字段的测试组件均可能需要人工重新选择指标
## 13. 设计决策摘要
@@ -456,6 +454,6 @@ default_number_format = {type: "percent", decimalPlaces: 1, thousandSeparator: f
- `DEC-008`: 组织变化通过 `user``department` 队列扇出,历史日统计不回写。
- `DEC-009`: 收藏次数只由实际新建收藏关系触发,不信任客户端普通收藏遥测作为计数依据。
- `DEC-010`: `portal_engagement_daily` 旧历史随重建清空,结构和后续实时写入逻辑不改。
- `DEC-011`: 知识贡献占比使用通用 `share_of_parent` 虚拟指标策略,不为两个指标写硬编码查询分支
- `DEC-012`: 分母移除最末级匹配组织维度,保留上级组织、非组织分组及全部筛选,并以 `exists(target_dimension)` 同时排除空组织文件
- `DEC-011`: 知识贡献占比使用通用 `share_of_total` 虚拟指标策略,不硬编码数据集、指标字段或维度字段
- `DEC-012`: 分母移除全部普通分组和 stack dimension,保留全部筛选且不追加维度 `exists`;缺少维度值的有效文件继续计入总数
- `DEC-013`: 数据集通过 `default_number_format` 声明 percent + 1 位小数;已有组件自身格式优先,不改变其他 divide 指标默认行为。
@@ -3,26 +3,26 @@
## 阅读摘要
- 本文档用于从零整理并重建 `mid_knowledge_space_content_stat` 数据集的业务口径、数据结构和维护规则。
- 当前状态:`REQ-001``REQ-007` 的代码实现与验证已完成;新增 `REQ-008` 知识贡献占比已完成需求澄清,等待设计确认和实现;实际索引清空重建仍等待 T014 破坏性操作确认。
- 本轮已确认:除既有四级组织与收藏口径外,新增“上传人知识贡献占比”和“文件所属知识贡献占比”两个虚拟指标
- 当前停止点:更新需求、设计、任务和验证计划;不修改生产代码,也不执行索引重建。
- 当前状态:`REQ-001``REQ-008` 已完成实现与自动化验证;实际索引清空重建仍等待 T014 破坏性操作确认。
- 本轮已确认:知识贡献占比不是组织父级占比,而是任意维度或维度组合的有效文件数占当前筛选范围有效文件总数的比例
- 当前停止点:已完成 `REQ-008` 的实现和验证;未刷新远端数据集、未迁移已有组件、未执行索引重建。
## 元信息 Metadata
- Feature ID: `055-knowledge-space-content-stat-rebuild`
- Status: `requirements-confirmed`
- Mode: `spec-update-awaiting-confirmation`
- Status: `req008-correction-implemented-verified`
- Mode: `implementation`
- Created: `2026-08-20`
- Updated: `2026-08-20`
- Source request: `重新整理并重建 mid_knowledge_space_content_stat;在既有组织四级字段、收藏次数基础上新增两套知识贡献占比虚拟指标。`
- Source request: `重新整理并重建 mid_knowledge_space_content_stat;在既有组织四级字段、收藏次数基础上新增一个适用于任意维度的知识贡献占比虚拟指标。`
## 需求入口摘要 Intake Summary
- 问题 Problem: 当前数据集同时存在上传人主部门、上传人全部所属部门和知识库绑定部门等单层或重复字段,不能按公司、部门、科室、班组统一分析,也无法准确表达文件上传人组织与文件所属组织的差异。
- 当前状态 Current state: 数据集提供 `primary_department_id/name``space_department_id/name``uploader_department_infos.department_id/name`,字段重复、层级不足且历史日统计复用当前文件快照。
- 目标结果 Target outcome: 数据集分别提供唯一一套“上传人组织”和“文件所属组织”四级名称维度;文件快照反映当前组织,预览、下载和收藏日统计保留事件发生时维度;提供累计成功收藏动作次数,以及按上传人组织和文件所属组织计算的知识贡献占比。
- 目标结果 Target outcome: 数据集分别提供唯一一套“上传人组织”和“文件所属组织”四级名称维度;文件快照反映当前组织,预览、下载和收藏日统计保留事件发生时维度;提供累计成功收藏动作次数,以及按任意维度或维度组合计算的总体知识贡献占比。
- 影响对象 Affected users/systems: 数据看板维度和指标选择、虚拟指标查询服务、百分比默认格式、`mid_knowledge_space_content_stat` 数据契约、文件快照投影、事件日统计、增量刷新和重建流程。
- 请求停止点 Requested stopping point: 本轮只同步更新 `REQ-008`规格、设计、任务和验证计划;生产实现与破坏性索引重建分别等待后续确认。
- 请求停止点 Requested stopping point: 完成 `REQ-008`生产实现和自动化验证;破坏性索引重建等待后续独立确认。
## 范围 Scope
@@ -35,18 +35,18 @@
- 定义组织变化后的文件快照刷新、历史日统计不回写、原始事件补偿和失败降级要求。
- 定义旧预览、下载日统计在本次重建中的清理口径。
- 定义累计成功收藏动作次数、收藏日统计以及旧收藏事件不回填的口径。
- 定义上传人和文件所属组织两套知识贡献占比的分子、分母、层级、空值和展示口径。
- 定义单一通用知识贡献占比的分子、总体分母、多维组合、空值和展示口径。
### 不包含 Excludes
- 本轮不新增总文件数、新增文件数、内容贡献人数、预览次数、下载次数、收藏次数和两套知识贡献占比以外的指标。
- 本轮不新增总文件数、新增文件数、内容贡献人数、预览次数、下载次数、收藏次数和单一知识贡献占比以外的指标。
- 本轮不调整 `portal_engagement_daily` 的数据结构、存储位置或统计口径。
- 本轮不确定最终日统计 `_id` 的拼接或哈希算法,设计阶段只需保证同日不同组织组合可拆分。
- 本轮不制定旧自定义看板引用已移除字段时的迁移方案。
- 本轮不修改生产代码、数据库、ES 索引或实际看板配置。
- 本轮不直接操作目标环境数据库、ES 索引或实际看板配置;生产代码修改仅限已确认的实施任务
- 本轮不沿用或合并已有同主题 SDD spec。
- 不为知识贡献占比新增 ES 字段、记录类型或历史回填数据。
-强制阻止知识贡献占比与非对应组织维度搭配;指标名称承担口径提示作用
-限制知识贡献占比的搭配维度;时间、空间、分类、业务域、上传人和两套组织维度均使用同一总体占比口径
- 本次更新后的实施范围覆盖 `REQ-001``REQ-008`;后续新增指标或记录类型必须先更新本规格。
## 需求列表 Requirements
@@ -242,55 +242,54 @@
- `AC-REQ-007-07`: WHEN 新版本首次创建或重建索引 THEN 系统 SHALL 不根据旧收藏事件生成 `favorite_daily` 历史记录。
- `AC-REQ-007-08`: WHEN 新增收藏次数指标 THEN `portal_engagement_daily`、预览次数和下载次数的现有记录与查询行为 SHALL 保持不变。
### REQ-008: 提供上传人和文件所属组织知识贡献占比
### REQ-008: 提供适用于任意维度的知识贡献占比
作为看板配置和数据分析人员,我需要分别查看上传人组织和文件所属组织的知识贡献占比,以便比较各级组织在当前统计范围内贡献的有效文件份额
作为看板配置和数据分析人员,我需要使用一个通用知识贡献占比指标,查看任意维度或维度组合聚合出的有效文件数占当前筛选范围有效文件总数的比例,以便比较不同分组对整体知识内容的贡献
#### 指标契约 Metric Contract
| 字段编码 | 展示名称 | 适用组织维度 | 返回值 |
| 字段编码 | 展示名称 | 适用维度 | 返回值 |
|---|---|---|---|
| `uploader_knowledge_contribution_ratio` | 上传人知识贡献占比 | 上传人公司、部门、科室、班组 | `01` 比例值 |
| `belonging_knowledge_contribution_ratio` | 文件所属知识贡献占比 | 所属公司、部门、科室、班组 | `01` 比例值 |
| `knowledge_contribution_ratio` | 知识贡献占比 | 数据集全部可选维度及其组合 | `01` 比例值 |
两个指标的基础有效文件口径均为
指标的基础有效文件口径与“总文件数”一致
- `record_type=file`
- `file_type=1`
- `space_level` 属于 `KNOWLEDGE_SPACE_DASHBOARD_FILE_LEVELS`
- 当前最末级组织字段存在且非空。
#### 计算规则 Business Rules
1. 分子是当前结果行及当前全部筛选条件下的有效文件数。
2. 分母保留当前全部看板筛选条件、时间或业务域等非组织分组维度,以及已经选择的上级同套组织维度;只移除当前所选最末级同套组织维度
3.只选择一个对应组织维度时,分母是当前筛选范围内具有该组织字段的全部有效文件数。
4. 当同时选择多个对应组织层级时,以固定层级顺序 `company → department → office → squad` 判断最末级维度,计算该层级在已选择直属或上级组织上下文内的占比,不依赖前端字段拖放顺序
5. 当存在时间、业务域、知识空间等非组织分组维度时,分母保留这些维度,因此每个非组织分组分别计算组织占比
6. 缺少当前最末级组织字段的文件既不进入分子,也不进入分母;不创建“未归属”占比行
2. 分母当前全部筛选条件下的有效文件总数;分母查询移除全部普通分组维度和 stack dimension,不保留时间、组织、分类、业务域、空间或其他分组上下文
3.同时选择多个维度时,分子按完整维度组合聚合,每个组合均除以同一个当前筛选范围有效文件数。
4. 看板条件筛选、联动筛选和时间范围同时作用于分子与分母;分母只移除分组,不移除任何筛选条件
5. 缺少任一所选维度值的文件不形成该维度组合的展示行,但仍计入分母,因此可见分组占比之和允许小于 `100%`
6. 未选择维度时,有效文件总数大于 0 返回 `1`,有效文件总数为 0 返回 `0`
7. 分母为零时返回 `0`,不得返回异常、`NaN` 或无穷值。
8. 前端默认以百分比显示,保留 1 位小数,例如后端返回 `0.266` 时显示 `26.6%`
9. 指标名称提示用户选择对应的上传人或文件所属组织维度,但前后端不新增强制配对校验;不正确搭配不属于本需求的口径保证范围
10. 两个占比均为查询时虚拟计算,不写入 `mid_knowledge_space_content_stat` 文档,不要求重新构建文件快照,也不改变任何既有指标
9. 指标为查询时虚拟计算,不写入 `mid_knowledge_space_content_stat` 文档,不要求重新构建文件快照,也不改变任何既有指标
10. 数据集不再暴露 `uploader_knowledge_contribution_ratio``belonging_knowledge_contribution_ratio` 两个错误口径指标;已有测试组件若引用旧字段,需要重新选择 `knowledge_contribution_ratio`,本次不自动迁移自定义组件配置
#### 计算示例 Examples
- 只选择“所属部门”:部门有效文件数 ÷ 当前筛选范围内具有所属部门的有效文件总数
- 选择“所属公司+所属部门”:部门有效文件数 ÷ 对应公司内具有所属部门的有效文件总数。
- 选择“月份+所属部门”:部门当月有效文件数 ÷ 当月具有所属部门的有效文件总数
- 选择“业务域+上传人科室”:科室在该业务域的有效文件数 ÷ 该业务域内具有上传人科室的有效文件总数
- 当前筛选范围有 100 个有效文件,部门 A 有 30 个文件:部门 A 显示 `30.0%`
- 选择“月份+所属部门”:某月份、某部门有效文件数 ÷ 当前整个筛选范围的有效文件总数。
- 当前筛选范围有 100 个有效文件,仅 50 个文件具有所属部门:所有可见部门占比之和允许为 `50.0%`
- 增加业务域筛选后:分子和分母都只统计满足该业务域筛选的有效文件。
#### 验收标准 Acceptance Criteria
- `AC-REQ-008-01`: WHEN 看板加载“知识空间内容统计”指标列表 THEN 系统 SHALL 展示“上传人知识贡献占比”和“文件所属知识贡献占比”两个虚拟指标。
- `AC-REQ-008-02`: GIVEN 只选择任一对应组织维度 WHEN 查询贡献占比 THEN 每行 SHALL 等于该组织有效文件数除以当前筛选范围内该组织字段非空的有效文件总数
- `AC-REQ-008-03`: GIVEN 同时选择同套多级组织维度 WHEN 查询贡献占比 THEN 最末级组织 SHALL 以上一级已选择组织上下文作为分母范围
- `AC-REQ-008-04`: GIVEN 查询同时包含时间、业务域或其他非组织分组维度 WHEN 查询贡献占比 THEN 系统 SHALL 保留这些分组并分别计算占比
- `AC-REQ-008-05`: GIVEN 文件缺少当前最末级组织字段 WHEN 计算分子和分母 THEN 系统 SHALL 同时排除该文件
- `AC-REQ-008-01`: WHEN 看板加载“知识空间内容统计”指标列表 THEN 系统 SHALL 展示一个 `knowledge_contribution_ratio` / “知识贡献占比”虚拟指标,不再展示两个旧组织专用占比指标。
- `AC-REQ-008-02`: GIVEN 当前筛选范围有 100 个有效文件且维度分组 A 有 30 个文件 WHEN 查询知识贡献占比 THEN 分组 A SHALL 返回 `0.3`
- `AC-REQ-008-03`: GIVEN 同时选择多个维度 WHEN 查询知识贡献占比 THEN 每个完整维度组合 SHALL 使用该组合文件数除以当前筛选范围有效文件总数
- `AC-REQ-008-04`: GIVEN 存在看板条件筛选、联动筛选或时间范围 WHEN 查询知识贡献占比 THEN 分子与分母 SHALL 应用相同筛选,且分母 SHALL 不保留任何分组维度
- `AC-REQ-008-05`: GIVEN 文件缺少任一所选维度值 WHEN 计算知识贡献占比 THEN 该文件 SHALL 不形成对应展示行但 SHALL 继续计入分母
- `AC-REQ-008-06`: GIVEN 当前统计上下文分母为零 WHEN 查询贡献占比 THEN 系统 SHALL 返回 `0` 且查询成功。
- `AC-REQ-008-07`: WHEN 用户首次把任一知识贡献占比加入组件 THEN 前端 SHALL 默认使用百分比格式并保留 1 位小数。
- `AC-REQ-008-07`: WHEN 用户首次把知识贡献占比加入组件 THEN 前端 SHALL 默认使用百分比格式并保留 1 位小数。
- `AC-REQ-008-08`: WHEN 新增知识贡献占比 THEN 现有虚拟除法指标、总文件数、预览次数、下载次数和收藏次数的查询结果 SHALL 保持不变。
- `AC-REQ-008-09`: WHEN 检查 ES Mapping、文件快照和重建脚本 THEN 系统 SHALL 不为知识贡献占比增加持久化字段、记录或重建步骤。
- `AC-REQ-008-10`: GIVEN 未选择任何维度 WHEN 查询知识贡献占比 THEN 有效文件总数大于 0 SHALL 返回 `1`,否则 SHALL 返回 `0`
## 验证方式 Verification Methods
@@ -308,8 +307,8 @@
| AC-REQ-007-05, AC-REQ-007-06 | V-FAVORITE-DAILY-001 | daily projection integration test | 相同维度快照聚合为一条记录,不同快照拆分并保留事件发生时组织字段 |
| AC-REQ-007-07 | V-FAVORITE-REBUILD-001 | rebuild dry-run + post-rebuild inspection | 重建不读取旧收藏事件生成 `favorite_daily`,上线前日期无收藏日统计 |
| AC-REQ-007-08 | V-FAVORITE-REGRESSION-001 | targeted regression test | 新增收藏投影前后,`portal_engagement_daily`、预览和下载记录及查询结果保持一致 |
| AC-REQ-008-01, AC-REQ-008-09 | V-CONTRIBUTION-SCHEMA-001 | dataset contract test + mapping regression | 数据集包含两个新虚拟指标及其层级配置、默认格式ES Mapping 与重建流程不变 |
| AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06 | V-CONTRIBUTION-QUERY-001 | parameterized service test | 参数化覆盖单、多层父级、非组织切片、字段缺失和零分母,并校验两套组织字段 |
| AC-REQ-008-01, AC-REQ-008-09 | V-CONTRIBUTION-SCHEMA-001 | dataset contract test + mapping regression | 数据集包含一个通用占比指标及其默认格式;两个旧指标已移除ES Mapping 与重建流程不变 |
| AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06, AC-REQ-008-10 | V-CONTRIBUTION-QUERY-001 | parameterized service test | 参数化覆盖单、多维组合、普通与 stack dimension、筛选保留、缺失维度仍入分母、无维度和零分母 |
| AC-REQ-008-07 | V-CONTRIBUTION-FORMAT-001 | frontend state/config test | 新指标首次加入组件时继承 percent + 1 位小数,用户后续仍可调整格式 |
| AC-REQ-008-08 | V-CONTRIBUTION-REGRESSION-001 | targeted backend regression | 既有普通指标、通用 divide 指标及结果合并行为保持不变 |
@@ -321,7 +320,7 @@
- `NFR-004`: 统计链路故障不得阻断门户预览和下载的主业务链路。
- `NFR-005`: 规格未完成且未取得单独的破坏性操作确认前,不得删除或重建实际索引。
- `NFR-006`: 新增组织解析和原始事件字段不得改变现有用户权限或文件可见性判断。
- `NFR-007`: 知识贡献占比必须由通用、数据集可声明的虚拟指标计算策略实现,不得在查询服务中硬编码数据集编码或两个具体指标字段名。
- `NFR-007`: 知识贡献占比必须由通用、数据集可声明的 `share_of_total` 虚拟指标计算策略实现,不得在查询服务中硬编码数据集编码或具体指标字段名。
## 澄清记录 Clarifications
@@ -346,13 +345,11 @@
- Q: 收藏日统计是否回填旧事件? → A: 不回填,从新版本上线后开始统计。
- Q: 是否同时调整 `portal_engagement_daily`、预览和下载口径? → A: 不调整,不属于本次收藏指标修改范围。
- Q: 重建原物理索引时是否允许清空既有 `portal_engagement_daily`? → A: 允许清空,不保留也不自动恢复其历史记录。
- Q: 知识贡献占比按哪套组织归属? → A: 分别提供上传人组织和文件所属组织两个虚拟指标
- Q: 两个指标支持哪些层级? → A: 分别支持对应组织的公司、部门、科室、班组四级维度
- Q: 分母是否受看板筛选影响? → A: 保留全部筛选条件,只从分母分组中移除当前最末级组织维度
- Q: 同时选择多级组织维度时如何计算? → A: 最末级组织占已选择上级组织上下文的比例,例如部门占公司
- Q: 时间、业务域等非组织维度如何处理? → A: 分母保留这些维度,在各自切片内分别计算占比
- Q: 缺少当前组织字段的文件如何处理? → A: 同时排除出分子和分母,不展示“未归属”分组。
- Q: 是否强制指标与对应组织维度配对? → A: 不强制;使用指标名称提示正确选择。
- Q: 知识贡献占比是否按上传人组织和文件所属组织拆成两个指标? → A: 否,只保留一个适用于任意维度的通用“知识贡献占比”
- Q: 分母是什么? → A: 当前全部筛选条件下的有效文件总数,移除所有普通分组维度和 stack dimension
- Q: 同时选择多个维度时如何计算? → A: 每个完整维度组合的有效文件数除以当前筛选范围有效文件总数
- Q: 缺少所选维度值的文件如何处理? → A: 不形成展示行,但仍计入分母,可见占比之和允许小于 `100%`
- Q: 时间、业务域等维度是否适用? → A: 适用;所有数据集维度使用同一总体占比口径
- Q: 百分比如何展示? → A: 后端返回 `01` 比例值,前端默认显示百分比并保留 1 位小数,零分母返回 `0%`
## 待确认事项 Open Questions
@@ -381,9 +378,9 @@
- 清空旧预览、下载历史属于不可逆数据操作,实际执行前必须展示影响范围、备份/回退能力并再次确认。
- 当前物理索引还承载 `portal_engagement_daily`;直接删除索引会同时清空门户累计阅读和下载历史,这是用户已经确认接受的重建影响。
- 旧收藏事件不生成 `favorite_daily`,因此新版本上线前的收藏动作不会出现在收藏次数趋势中,这是已确认的数据边界。
- 现有通用 `FormulaEnum.DIVIDE` 的分子和分母使用相同分组,无法直接计算父级/总体占比;实现必须扩展虚拟指标计算策略,并保护既有除法指标兼容性。
- 指标与组织维度不做强制配对,因此配置人员若误用非对应维度,结果不在本需求的口径保证范围内
- 贡献占比按名称字段分组,继续继承同名组织合并的既有数据口径
- 现有通用 `FormulaEnum.DIVIDE` 的分子和分母使用相同分组,无法直接计算总体占比;实现必须扩展 `share_of_total` 虚拟指标计算策略,并保护既有除法指标兼容性。
- 两个旧占比字段从系统数据集移除后,引用旧字段的测试组件会失效,需要人工重新选择新指标;本次不自动迁移自定义组件配置
- 缺失维度值的文件仍进入分母,因此可见分组占比之和可能小于 `100%`,这是已确认口径而非数据异常
## 需求质量门 Requirements Quality Gate
@@ -395,6 +392,6 @@
- [x] Acceptance criteria sharing one behavior reuse an evidence target instead of duplicating commands.
- [x] No orphan `AC-*` or `V-*` entries exist.
- [x] Scope includes and excludes are explicit.
- [x] 本轮组织维度、收藏次数和两套知识贡献占比不存在关键歧义。
- [x] 本轮组织维度、收藏次数和单一通用知识贡献占比不存在关键歧义。
- [x] 本次实施范围已更新为 `REQ-001``REQ-008`;后续新增需求必须先更新 spec。
- [x] Requirements avoid implementation details unless required to define the confirmed data contract and time semantics.
@@ -4,7 +4,7 @@
- Feature ID: `055-knowledge-space-content-stat-rebuild`
- Created: `2026-08-20`
- Update type: `scope-change`
- Update type: `scope-change-and-requirement-correction`
## 范围变化
@@ -32,4 +32,25 @@
## 后续
更新后的设计与任务经用户确认后使用 `sdd-task T015-T018` 实现和验证T014 继续保持独立暂停状态。
用户确认后使用 `sdd-task T015-T018` 完成实现和验证T014 继续保持独立暂停状态,知识贡献占比实施未修改索引 Mapping、重建脚本或已有数据
## 口径纠正:`share_of_parent` → `share_of_total`
用户在真实看板验证时发现所有非组织维度的知识贡献占比均为 `100%`。代码与旧规格复核确认:`share_of_parent` 仅识别预先声明的组织层级,未匹配组织维度时会直接返回 `1.0`;即使匹配组织维度,分母也是父级组织上下文,而不是用户期望的有效文件总数。
重新澄清后的真实口径为:
- 数据集只保留一个 `knowledge_contribution_ratio` / “知识贡献占比”;
- 适用于任意单维度和多维组合;
- 分子为当前完整维度组合的有效文件数;
- 分母为当前全部筛选条件下的有效文件总数,移除所有普通和 stack 分组;
- 缺少所选维度值的文件不形成展示行,但仍进入分母;
- 默认 percent + 1 位小数,不新增 ES 字段或重建步骤。
因此,`T015``T018` 虽已按旧规格执行,但其贡献占比证据全部失效。新增 `T019``T022` 纠正实现、数据集契约、前端回归和验证;T014 继续独立暂停。
## 纠正实施结果
`T019``T022` 已完成:查询服务使用相同指标聚合和相同筛选分别查询完整分组分子与无分组分母;系统数据集只暴露 `knowledge_contribution_ratio`;前端默认使用百分比并保留 1 位小数。自动化验证覆盖任意单维、多维与 stack 组合、筛选继承、维度缺失、零分母、无维度和既有 divide 回归。
本次纠正未修改文件投影、ES Mapping、重建脚本或实际索引数据,也未刷新远端 `dashboard_dataset`。目标环境需在部署重启后由幂等 seed 覆盖数据集定义;引用两个旧指标的已保存组件仍需人工重新选择新指标。
@@ -3,11 +3,11 @@
## 元信息 Metadata
- Feature ID: `055-knowledge-space-content-stat-rebuild`
- Status: `scope-updated-awaiting-confirmation`
- Status: `req008-correction-implemented-verified`
- Inputs: `requirements.md`, `design.md`
- Created: `2026-08-20`
- Updated: `2026-08-20`
- Execution gate: `REQ-001``REQ-007` 的代码实施确认已满足;新增 `T015``T018` 需确认更新后的设计与任务后实施;T014 删除并重建索引仍需单独确认
- Execution gate: `REQ-001``REQ-008` 已完成;T014 删除并重建索引仍需单独确认
## 执行原则
@@ -341,7 +341,9 @@ PYTHONPATH=./ python scripts/rebuild_knowledge_space_content_stat.py \
## Phase 6:知识贡献占比虚拟指标(范围更新)
### [ ] T015 实现通用 `share_of_parent` 查询策略
> 历史说明:`T015`~`T018` 已按当时确认但后来被纠正的“组织父级占比”规格完成。其代码与验证证据不再满足当前 `REQ-008`,由 Phase 7 的 `T019``T022` 替换;保留任务记录仅用于追溯,不得作为当前验收依据。
### [x] T015 实现通用 `share_of_parent` 查询策略
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06, AC-REQ-008-08_
@@ -372,7 +374,7 @@ uv run pytest \
-q
```
### [ ] T016 注册两套知识贡献占比数据集指标
### [x] T016 注册两套知识贡献占比数据集指标
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-02, AC-REQ-008-07, AC-REQ-008-09_
@@ -397,7 +399,7 @@ uv run pytest \
-q
```
### [ ] T017 透传贡献占比默认百分比格式
### [x] T017 透传贡献占比默认百分比格式
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-07, AC-REQ-008-08_
@@ -416,7 +418,7 @@ cd src/frontend/platform
npm test -- src/test/knowledgeContributionMetricFormat.test.ts src/test/pieChartTooltip.test.ts
```
### [ ] T018 执行贡献占比跨层定向验证并更新证据
### [x] T018 执行贡献占比跨层定向验证并更新证据
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06, AC-REQ-008-07, AC-REQ-008-08, AC-REQ-008-09_
@@ -431,6 +433,76 @@ _Boundary: 只验证 `REQ-008` 及受影响的既有查询行为;不得执行
- 更新 `verification.md`,将 `REQ-008` 的实际命令、通过数、失败数、未验证项与手工验证建议单独记录。
- 明确确认没有修改知识内容索引 Mapping、重建脚本和已有数据。
## Phase 7:纠正为通用总体知识贡献占比
### [x] T019 将 `share_of_parent` 替换为通用 `share_of_total`
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06, AC-REQ-008-08, AC-REQ-008-10_
_Verification: V-CONTRIBUTION-QUERY-001, V-CONTRIBUTION-REGRESSION-001_
_Depends: T018_
_Boundary: 只纠正通用虚拟指标查询策略;不硬编码数据集、指标或维度字段,不修改 ES 文档、Mapping、文件投影或重建流程。_
- 先重写 `test/telemetry_search/test_knowledge_contribution_ratio.py`,使旧 `share_of_parent` 断言产生红灯,并参数化覆盖:
- 分类、业务域、知识空间、上传人和组织等任意单维;
- 普通维度与 stack dimension 组成的多维组合;
- 分子保留完整分组、分母移除全部普通和 stack 分组;
- 看板条件、联动条件和时间范围在分子与分母中保持一致;
- 缺少所选维度值的文件仍进入分母;
- 无维度时非零返回 `1`、零文件和零分母返回 `0`
-`VirtualMetricCalculationEnum.SHARE_OF_PARENT` 替换为 `SHARE_OF_TOTAL`,移除只服务错误口径的 `share_dimension_hierarchy`
-`query_share_of_parent_metric` 替换为 `query_share_of_total_metric`:分子保留原查询分组,分母使用相同聚合与过滤但清空全部分组;每个分子行除以同一分母值。
- 占比查询不追加 `ExistsOp`;当前代码检索确认它仅由错误占比实现和对应测试使用,因此删除该操作符及其测试。
- 保持 `query_formula_metric``query_sum_metric``query_index_metric`、普通聚合和多指标结果合并行为不变。
### [x] T020 将数据集指标收敛为单一 `knowledge_contribution_ratio`
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-02, AC-REQ-008-07, AC-REQ-008-09_
_Verification: V-CONTRIBUTION-SCHEMA-001, V-CONTRIBUTION-FORMAT-001_
_Depends: T019_
_Boundary: 只更新系统数据集 seed 与契约测试;不自动修改已保存组件,不运行索引重建。_
- 从“知识空间内容统计”移除:
- `uploader_knowledge_contribution_ratio`
- `belonging_knowledge_contribution_ratio`
- 注册唯一指标 `knowledge_contribution_ratio` / “知识贡献占比”,使用:
- `record_type=file`
- `file_type=1`
- 有效 `space_level`
- `value_count(file_id)`
- `calculation=share_of_total`
- 默认格式 `{type: percent, decimalPlaces: 1, thousandSeparator: false}`
- 更新数据集契约测试,断言只存在一个新指标、两个旧指标不存在,并确认记录模型、Mapping 和重建脚本没有占比持久化字段或步骤。
- 验证启动 seed 的幂等刷新路径会覆盖目标 `dashboard_dataset.schema_config`;真实环境刷新随部署重启执行,不在本任务中直接写远端数据库。
### [x] T021 调整前端单一指标契约与默认格式回归
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-07, AC-REQ-008-08_
_Verification: V-CONTRIBUTION-FORMAT-001, V-CONTRIBUTION-REGRESSION-001_
_Depends: T020_
_Boundary: 只调整数据集字段类型和前端测试;不得迁移、覆盖或重写已保存组件配置。_
- 将前端 `MetricConfig.calculation` 类型从 `share_of_parent` 改为 `share_of_total`
- 保留现有数据集默认格式在点击、拖拽和组件状态中的透传,确保新指标首次加入时使用 percent + 1 位小数。
- 更新 `knowledgeContributionMetricFormat.test.ts`,只覆盖 `knowledge_contribution_ratio`,并继续验证已有 divide 默认行为和已保存组件格式优先。
- 明确不为两个旧指标增加前端别名;已保存测试组件需要人工重新选择新指标。
### [x] T022 执行纠正后的跨层验证并更新证据
_Requirements: REQ-008_
_Acceptance: AC-REQ-008-01, AC-REQ-008-02, AC-REQ-008-03, AC-REQ-008-04, AC-REQ-008-05, AC-REQ-008-06, AC-REQ-008-07, AC-REQ-008-08, AC-REQ-008-09, AC-REQ-008-10_
_Verification: V-CONTRIBUTION-SCHEMA-001, V-CONTRIBUTION-QUERY-001, V-CONTRIBUTION-FORMAT-001, V-CONTRIBUTION-REGRESSION-001_
_Depends: T019, T020, T021_
_Boundary: 只验证纠正后的 `REQ-008` 和受影响的既有查询行为;不得刷新远端数据集、执行 T014 或运行任何索引写入/删除/重建。_
- 运行纠正后的后端数据集契约、占比查询和实时看板回归测试。
- 运行前端默认格式、饼图百分比回归和最小生产构建。
- 执行目标 Python 静态检查、`py_compile``bash scripts/arch-guard.sh``git diff --check`
- 更新 `verification.md`:将四个贡献占比验证 ID 从 `NOT_RUN` 更新为实际结果,并记录旧证据失效原因、测试数量、失败、未验证项和真实环境手工验证建议。
- 明确检查 `knowledge_space_content.py`、Mapping、重建脚本和实际 ES 数据无变化。
## 需求追踪矩阵
| Requirement | Tasks |
@@ -442,13 +514,15 @@ _Boundary: 只验证 `REQ-008` 及受影响的既有查询行为;不得执行
| `REQ-005` | `T003`, `T007`, `T008`, `T009`, `T012` |
| `REQ-006` | `T004`, `T005`, `T009`, `T011`, `T012`, `T013`, `T014` |
| `REQ-007` | `T001`, `T004`, `T006`, `T010`, `T011`, `T012`, `T014` |
| `REQ-008` | `T015`, `T016`, `T017`, `T018` |
| `REQ-008` | `T019`, `T020`, `T021`, `T022``T015``T018` 为已失效历史实现) |
## 实际偏差记录
- `T008` 的组织刷新入口测试落在 `test/telemetry/test_knowledge_space_content_refresh_triggers.py`,并复用组织标签、主组织、绑定和移动 Service 的现有定向测试;计划中的 `test/knowledge/test_knowledge_space_content_projection_events.py` 在项目中不存在,未创建同义重复文件。
- `T012` 对新增核心文件执行 `ruff check``ruff format --check` 均通过;对所有修改文件执行全文件 `ruff check` 时发现 239 个既有历史规则问题,主要位于本功能未改动的旧代码行。为避免扩大范围,本次使用“新增核心文件 lint + 全部生产改动 `py_compile` + 定向测试 + arch-guard”作为静态验证证据,详见 `verification.md`
- `T013` 首次 dry-run 发现总记录数比五类已知记录多 5 条,因此补充了未知类型聚合预检。复验确认这 5 条均为旧 `record_type=preview`,并非缺失 `record_type` 的文档。根据暂停条件,`T014` 保持未执行,等待用户明确确认该旧类型随索引一起不可恢复清空。
- `T018` 按计划执行了目标文件 `ruff format --check`,5 个既有 Python 文件因全文件格式基线未满足而返回非零;为避免对共享旧文件做大范围机械格式化,本批使用新测试文件格式通过、关键错误与导入规则通过、`py_compile`、30 条后端回归、5 条前端测试、生产构建和架构守卫作为验证证据,详见 `verification.md`
- `REQ-008` 上线前验证发现所有非组织维度占比均为 `100%`。根因是 `share_of_parent` 在未匹配组织层级时按设计直接返回 `1.0`,且整个父级分母模型与用户实际要求的“分组文件数 ÷ 当前筛选范围文件总数”不一致。`T015``T018` 的贡献占比证据因此失效,由 `T019``T022` 完整替换。
## 风险与暂停条件
@@ -457,6 +531,7 @@ _Boundary: 只验证 `REQ-008` 及受影响的既有查询行为;不得执行
- 如果 Redis 无法满足事件 payload/lease 持久化要求,暂停 `T004`,不得退化为只记录日志后丢事件。
- 如果目标环境 dry-run 数量与门户/看板口径不一致,暂停 `T014`,先定位有效文件查询差异。
- 如果实际索引存在未识别的其他 `record_type`,暂停重建并请用户确认,不默认删除未知业务数据。
- 如果 `share_of_parent` 需要硬编码数据集指标字段才能完成,暂停 T015,返回设计层修正通用配置契约。
- 如果分母移除目标维度后无法稳定映射回完整分子维度,暂停 T015,不得以逐行自身相除或前端二次汇总替代。
- 如果默认格式透传会覆盖已保存组件 `numberFormat`,暂停 T017,优先保护现有组件兼容性
- 如果 `share_of_total` 需要硬编码数据集指标或维度字段才能完成,暂停 T019,返回设计层修正通用配置契约。
- 如果分母查询仍携带任何普通或 stack 分组维度,暂停 T019,不得以父级占比、切片内占比或前端二次汇总替代。
- 如果分母为实现“占比和等于 100%”而追加维度 `exists`,暂停 T019;缺失维度值文件必须按确认口径继续计入总数
- 如果默认格式透传会覆盖已保存组件 `numberFormat`,暂停 T021,优先保护现有组件兼容性。
@@ -3,27 +3,120 @@
## 元信息
- Feature ID: `055-knowledge-space-content-stat-rebuild`
- Status: `scope-updated-req008-not-implemented`
- Status: `req008-correction-passed`
- Verified at: `2026-08-20`
- Covered tasks: `T001``T013`
- Pending tasks: `T014``T018`(其中 `T014` 为独立破坏性操作,`T015``T018` 为新增知识贡献占比
- Covered tasks: `T001``T013``T019``T022``T015``T018` 已执行但其旧 `REQ-008` 证据已失效
- Pending tasks: `T014`(独立破坏性索引重建
## 结论
`REQ-001``REQ-007`代码实现、定向回归、静态语法检查、架构守卫和目标环境只读 dry-run 已完成。新增 `REQ-008` 两套知识贡献占比仅完成规格更新,尚未修改代码或产生验证证据。实际删除并重建 `mid_knowledge_space_content_stat` 也尚未执行;旧预览、下载、收藏、门户参与度及额外的 5 条 `record_type=preview` 数据仍在现有索引中
`REQ-001``REQ-007`既有实现和验证保持有效。`REQ-008` 已完成口径纠正:数据集只暴露唯一 `knowledge_contribution_ratio`,按“完整维度组合有效文件数 ÷ 当前全部筛选条件下有效文件总数”计算。分母移除全部普通维度和 stack dimension,但保留数据集、看板联动和时间筛选;缺少所选维度值的文件仍进入分母。后端、前端和构建验证均通过。实际索引重建仍未执行,远端 `dashboard_dataset` 也未由本次任务直接刷新
## `REQ-008` 范围更新与验证计划
## `REQ-008` 验证结果
本节记录计划,不是通过证据。
| Verification ID | 当前状态 | 计划证据 |
| Verification ID | 当前状态 | 实际证据 |
|---|---|---|
| `V-CONTRIBUTION-SCHEMA-001` | `NOT_RUN` | 两个数据集指标的策略、层级、过滤、聚合和默认格式契约;Mapping/重建无变化 |
| `V-CONTRIBUTION-QUERY-001` | `NOT_RUN` | 单层、多级父级、非组织切片、空字段、零分母和 stack dimension 参数化服务测试 |
| `V-CONTRIBUTION-FORMAT-001` | `NOT_RUN` | 新指标首次加入组件默认 percent + 1 位小数,保存格式优先 |
| `V-CONTRIBUTION-REGRESSION-001` | `NOT_RUN` | 既有 divide、普通虚拟指标、已有指标与结果合并行为不变 |
| `V-CONTRIBUTION-SCHEMA-001` | `PASS` | 数据集契约测试确认只存在 `knowledge_contribution_ratio`,两个旧字段、层级配置和持久化模型字段均不存在;目标投影、Mapping 和重建脚本差异检查退出码为 `0` |
| `V-CONTRIBUTION-QUERY-001` | `PASS` | 参数化测试覆盖分类、业务域、知识空间、上传人、所属组织、多维与 stack;确认分母清空全部分组、筛选保持一致、缺失维度文件仍入分母、无维度和零分母稳定 |
| `V-CONTRIBUTION-FORMAT-001` | `PASS` | 前端测试确认唯一新指标首次添加使用 `percent + 1` 位小数,已有组件格式优先 |
| `V-CONTRIBUTION-REGRESSION-001` | `PASS` | 后端相关组合 51 项通过,前端相关组合 8 项通过,既有 divide、数据集 seed 刷新路径、实时看板和饼图行为保持通过 |
`REQ-008` 不依赖 T014,不需要删除或重建索引。后续应单独执行 `T015``T018`,不得把贡献占比实现授权解释为 T014 的破坏性操作授权
纠正后的 `REQ-008` 不依赖 T014,不需要删除或重建索引,也不需要回填文件数据。本次实现修改了查询策略、系统数据集 seed 和前端类型/测试,但未直接修改数据库、ES Mapping、文件投影、重建脚本或 ES 数据
### 失效原因与复现证据
- 当前 `query_share_of_parent_metric` 仅在所选字段命中组织层级时计算父级占比;未命中时直接把非零结果改为 `1.0`,因此分类、业务域、知识空间等维度显示 `100%`
- 只读索引检查确认目标索引有 `1516` 条文件快照,八个组织字段也存在非空数据,故问题不是 ES 无文件或组织字段全空。
- 旧测试与旧规格一致,但旧规格本身不符合用户实际口径,因此测试通过不能继续作为验收证据。
### 纠正后实际证据
- 测试先行红灯:新契约在旧实现上得到 `12 failed, 3 passed`,失败集中在缺少 `SHARE_OF_TOTAL` 和唯一新指标,证明用例能捕获原错误路径。
- 后端回归:`51 passed, 0 failed`,包含占比查询、数据集契约、幂等 seed 刷新路径和实时看板回归。
- 前端回归:3 个测试文件、`8 passed, 0 failed`,覆盖新指标格式、饼图 tooltip 和饼图数据行为。
- Vite 生产构建通过;仅有项目既有的外部脚本、浏览器数据、依赖 `eval` 和大 chunk 警告。
- `ruff format --check`2 个本次测试文件通过;`ruff check --select E9,F63,F7,F82,I001`:目标 Python 文件通过;`py_compile`:目标 Python 文件通过。
- `bash scripts/arch-guard.sh``git diff --check` 均退出码 `0`
- `knowledge_space_content.py``knowledge_space_content_dimensions.py``rebuild_knowledge_space_content_stat.py` 的目标差异检查退出码为 `0`,确认本次纠正未增加持久化字段、Mapping 或重建步骤。
### 实际命令
```bash
cd src/backend
uv run pytest \
test/telemetry_search/test_knowledge_contribution_ratio.py \
test/telemetry_search/test_knowledge_space_content_dataset.py \
test/telemetry_search/test_dashboard_enum_labels.py \
test/test_realtime_dashboard.py \
-q
uv run ruff format --check \
test/telemetry_search/test_knowledge_contribution_ratio.py \
test/telemetry_search/test_knowledge_space_content_dataset.py
uv run ruff check --select E9,F63,F7,F82,I001 <目标 Python 文件>
uv run python -m py_compile <目标 Python 文件>
cd src/frontend/platform
npx vitest run \
src/test/knowledgeContributionMetricFormat.test.ts \
src/test/pieChartTooltip.test.ts \
src/test/pieChartData.test.ts
npm run build
cd ../../..
bash scripts/arch-guard.sh
git diff --check
```
### 未执行项与真实环境建议
- 未直接刷新远端 `dashboard_dataset`;部署重启时 `_upgrade_dashboard_datasets` 会幂等覆盖目标数据集 `schema_config`
- 未迁移已有组件;引用两个旧指标的组件需人工重新选择 `knowledge_contribution_ratio`
- 未向 ES 发起任何写入、删除或重建操作,T014 仍独立暂停。
- 部署后建议分别用“业务域”“月份+所属部门”“知识分类+上传人”验证,并用相同筛选范围的总文件数手工复算至少一个分组;对于存在维度缺失值的场景,可见占比之和小于 `100%` 属于预期。
以下内容保留为 `T015``T018` 的历史执行记录,不代表当前 `REQ-008` 通过。
### 后端定向回归
```bash
cd src/backend
uv run pytest \
test/telemetry_search/test_knowledge_contribution_ratio.py \
test/telemetry_search/test_knowledge_space_content_dataset.py \
test/test_realtime_dashboard.py \
-q
```
结果:`30 passed, 0 failed`
### 前端定向回归与构建
```bash
cd src/frontend/platform
npm test -- \
src/test/knowledgeContributionMetricFormat.test.ts \
src/test/pieChartTooltip.test.ts
npm run build
```
结果:`2` 个测试文件、`5` 个测试全部通过;Vite 生产构建通过。构建仅输出项目既有的依赖与大分块警告。
### 静态与架构证据
- `ruff check --select E9,F63,F7,F82,I`:目标 Python 文件全部通过。
- `python -m py_compile`:目标 Python 文件全部通过。
- `ruff format --check`:2 个新测试文件已符合格式;5 个共享旧文件的全文件格式基线未满足,因此该命令整体退出码为 `1`。为避免扩大范围,本批未机械格式化整份旧文件。
- `bash scripts/arch-guard.sh`:退出码 `0`,无 VIOLATION。
- `git diff --check`:通过。
-`knowledge_space_content.py``rebuild_knowledge_space_content_stat.py` 的目标差异检查为空,确认没有新增占比持久化字段或重建逻辑。
### 历史未执行项与纠正后手工建议
- 旧实现未在真实看板中验证任意非组织维度,用户实际验证发现均为 `100%`,该缺口已使旧贡献占比证据失效。
- 纠正实现后应在看板分别选择“业务域”“月份+所属部门”“知识分类+上传人”进行手工查询,并用同一筛选范围总文件数复算至少一个结果。
- 应额外验证某个维度存在缺失值时,可见占比之和小于 `100%`,并确认这是预期口径。
- 上述验证不授权 T014 或任何索引写入、删除、重建。
## 可执行证据
@@ -4,16 +4,29 @@ from bisheng.common.constants.telemetry import (
)
from bisheng.core.database import get_async_db_session, get_database_connection
from bisheng.database.models.group import DefaultGroup
from bisheng.database.models.group_resource import GroupResourceDao, GroupResource, ResourceTypeEnum
from bisheng.database.models.group_resource import GroupResource, GroupResourceDao, ResourceTypeEnum
from bisheng.telemetry_search.domain.models.dashboard import DashboardType
from bisheng.telemetry_search.domain.models.dashboard_dao import DashboardDao
from bisheng.telemetry_search.domain.models.dashboard_dataset import DashboardDataset, SchemaConfig, MetricConfig, \
DimensionConfig, FormulaEnum
from bisheng.telemetry_search.domain.repositories.implementations.dataset_repository_impl import \
DashboardDatasetRepositoryImpl
from bisheng.telemetry_search.domain.schemas.query_builder import AggregationExpression, AggsTypeEnum, PipelineTypeEnum, \
FilterExpression, TermOp, \
MatchAllOp, TermsOp
from bisheng.telemetry_search.domain.models.dashboard_dataset import (
DashboardDataset,
DimensionConfig,
FormulaEnum,
MetricConfig,
SchemaConfig,
VirtualMetricCalculationEnum,
)
from bisheng.telemetry_search.domain.repositories.implementations.dataset_repository_impl import (
DashboardDatasetRepositoryImpl,
)
from bisheng.telemetry_search.domain.schemas.query_builder import (
AggregationExpression,
AggsTypeEnum,
FilterExpression,
MatchAllOp,
PipelineTypeEnum,
TermOp,
TermsOp,
)
DASHBOARD_DATASET = [
DashboardDataset(
@@ -853,6 +866,32 @@ DASHBOARD_DATASET = [
)
]
),
MetricConfig(
field="knowledge_contribution_ratio",
name="知识贡献占比",
is_virtual=True,
calculation=VirtualMetricCalculationEnum.SHARE_OF_TOTAL,
default_number_format={
"type": "percent",
"decimalPlaces": 1,
"thousandSeparator": False,
},
filter=FilterExpression(bool_operator="must", filters=[
TermOp(field="record_type", value="file"),
TermOp(field="file_type", value=1),
TermsOp(
field="space_level",
value=list(KNOWLEDGE_SPACE_DASHBOARD_FILE_LEVELS),
),
]),
aggregations=[
AggregationExpression(
name="knowledge_contribution_ratio",
type=AggsTypeEnum.VALUE_COUNT,
field="file_id",
)
],
),
],
dimensions=[
DimensionConfig(
@@ -1,16 +1,16 @@
from enum import Enum
from typing import Dict, Literal, List, Optional
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel
from sqlalchemy import Column, VARCHAR, BOOLEAN
from bisheng.core.database.dialect_helpers import JsonType
from sqlalchemy import BOOLEAN, VARCHAR, Column
from sqlmodel import Field
from bisheng.common.models.base import SQLModelSerializable
from bisheng.core.database.dialect_helpers import JsonType
from bisheng.telemetry_search.domain.schemas.query_builder import (
AggregationExpression,
AggsTypeEnum,
FilterExpression,
AggregationExpression,
)
@@ -29,6 +29,12 @@ class FormulaEnum(str, Enum):
DIVIDE = 'divide'
class VirtualMetricCalculationEnum(str, Enum):
"""Virtual metric calculation strategies."""
SHARE_OF_TOTAL = "share_of_total"
class MetricConfig(BaseModel):
"""
Metric Configuration Model
@@ -43,6 +49,8 @@ class MetricConfig(BaseModel):
aggregations: Optional[List[AggregationExpression]] = None
formula: Optional[FormulaEnum] = None
calculation: Optional[VirtualMetricCalculationEnum] = None
default_number_format: Optional[Dict[str, Any]] = None
index: Optional[int] = None
sum_field: Optional[str] = None
@@ -158,6 +158,12 @@ class ComponentDataConfig(BaseModel):
dimensions: List[DimensionField] = Field(default_factory=list, description="list of dimension fields")
stack_dimension: Optional[DimensionField] = Field(default=None, alias="stackDimension",
description="list of stack dimension fields")
stack_dimensions: List[DimensionField] = Field(
default_factory=list,
alias="stackDimensions",
max_length=2,
description="ordered pivot table column dimensions",
)
metrics: List[MetricField] = Field(default_factory=list, description="list of metric fields")
field_order: List[FieldOrder] = Field(default_factory=list, alias="fieldOrder", description="list of field order")
filters: List[FilterCondition] = Field(default_factory=list, description="list of filter conditions")
@@ -169,6 +175,12 @@ class ComponentDataConfig(BaseModel):
alias="pivotColumnAliases",
)
def get_stack_dimensions(self) -> List[DimensionField]:
"""Return the new ordered column dimensions or the legacy single dimension."""
if self.stack_dimensions:
return self.stack_dimensions
return [self.stack_dimension] if self.stack_dimension else []
class DataQueryResult(BaseModel):
value: List[List] = Field(default_factory=list, description="metrics value list", examples=[[1], [2], [3]])
@@ -1,7 +1,7 @@
from enum import Enum
from typing import Optional, Any, Literal, Union, List
from typing import Any, List, Literal, Optional, Union
from pydantic import BaseModel, field_validator, Field, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
TIME_INTERVALS_MAP = {
'year': '1y',
@@ -14,7 +14,13 @@ from bisheng.common.errcode.telemetry import (
)
from bisheng.core.database import get_async_db_session
from ..models.dashboard_dataset import DimensionConfig, FormulaEnum, MetricConfig, SchemaConfig
from ..models.dashboard_dataset import (
DimensionConfig,
FormulaEnum,
MetricConfig,
SchemaConfig,
VirtualMetricCalculationEnum,
)
from ..repositories.implementations.dataset_repository_impl import DashboardDatasetRepositoryImpl
from ..schemas.component import (
AggregationType,
@@ -72,8 +78,16 @@ class DataQueryService(BaseModel):
dimension_map = {one.field: one for one in schema_config.dimensions}
query_dimensions = await self.convert_dimensions(self.data_config.dimensions, dimension_map)
configured_stack_dimensions = self.data_config.get_stack_dimensions()
if self.data_config.stack_dimension:
if self.data_config.stack_dimensions:
query_stack_dimensions = await self.convert_dimensions(
configured_stack_dimensions,
dimension_map,
)
query_dimensions.extend(query_stack_dimensions)
stack_dimension = None
elif self.data_config.stack_dimension:
stack_dimensions = await self.convert_dimensions([self.data_config.stack_dimension], dimension_map)
stack_dimension = stack_dimensions[0] if stack_dimensions else None
else:
@@ -111,7 +125,7 @@ class DataQueryService(BaseModel):
return res
async def query_all_metrics(self, metric_map: Dict[str, MetricConfig], dimension_index: int, index_name: str,
dimensions: List[AggregationExpression], stack_dimension: AggregationExpression,
dimensions: List[AggregationExpression], stack_dimension: AggregationExpression | None,
filters: List[FilterExpression]) -> List[List]:
all_dimensions = {}
res = []
@@ -159,7 +173,13 @@ class DataQueryService(BaseModel):
dimension_index: int, **search_kwargs) -> List[List]:
if metric_config.is_virtual:
# need query twice from telemetry mid table
if metric_config.formula is not None:
if metric_config.calculation == VirtualMetricCalculationEnum.SHARE_OF_TOTAL:
return await self.query_share_of_total_metric(
metric_config,
dimension_index,
**search_kwargs,
)
elif metric_config.formula is not None:
return await self.query_formula_metric(metric_config, dimension_index, **search_kwargs)
elif metric_config.sum_field is not None:
return await self.query_sum_metric(metric_config, dimension_index, **search_kwargs)
@@ -188,6 +208,47 @@ class DataQueryService(BaseModel):
# eg. [[dim1, dim2, metric1, metric2], [...]]
return await SearchEngineService(search_params).search()
async def query_share_of_total_metric(
self,
metric_config: MetricConfig,
dimension_index: int,
**search_kwargs,
) -> List[List]:
"""Calculate each complete dimension bucket's share of the filtered total."""
filters = search_kwargs.pop('filters', None)
filters = self.merge_filters(filters, metric_config.filter)
numerator_params = SearchParameters(
metrics=metric_config.aggregations,
filters=copy.deepcopy(filters),
**copy.deepcopy(search_kwargs),
)
numerator_result = await SearchEngineService(numerator_params).search()
denominator_kwargs = copy.deepcopy(search_kwargs)
denominator_kwargs["dimensions"] = []
denominator_kwargs["stack_dimension"] = None
denominator_params = SearchParameters(
metrics=metric_config.aggregations,
filters=copy.deepcopy(filters),
**denominator_kwargs,
)
denominator_result = await SearchEngineService(denominator_params).search()
denominator = (
denominator_result[0][0]
if denominator_result and denominator_result[0]
else 0
)
dimension_count = dimension_index + 1
result: List[List] = []
for row in numerator_result:
numerator = row[dimension_count]
ratio = numerator / denominator if denominator else 0
result.append([*row[:dimension_count], ratio])
return result
async def query_formula_metric(self, metric_config: MetricConfig, dimension_index: int, **search_kwargs) \
-> List[List]:
filters = search_kwargs.pop('filters', None)
@@ -340,8 +401,7 @@ class DataQueryService(BaseModel):
one.sort = "asc"
sort_field[one.field_id] = (sort_index, one.sort)
sort_index += 1
if self.data_config.stack_dimension:
dimension = self.data_config.stack_dimension
for dimension in self.data_config.get_stack_dimensions():
if dimension.field_id == TIMESTAMP_FIELD and dimension.sort is None:
dimension.sort = "asc"
sort_field[dimension.field_id] = (sort_index, dimension.sort)
@@ -0,0 +1,248 @@
from unittest.mock import AsyncMock
import pytest
from bisheng.telemetry_search.domain.models.dashboard_dataset import (
FormulaEnum,
MetricConfig,
VirtualMetricCalculationEnum,
)
from bisheng.telemetry_search.domain.schemas.component import ComponentDataConfig
from bisheng.telemetry_search.domain.schemas.query_builder import (
AggregationExpression,
AggsTypeEnum,
FilterExpression,
RangeOp,
RangeValue,
TermOp,
TermsOp,
)
from bisheng.telemetry_search.domain.services import component as component_module
from bisheng.telemetry_search.domain.services.component import DataQueryService
def _dimension(field: str) -> AggregationExpression:
return AggregationExpression(name=field, type=AggsTypeEnum.TERMS, field=field)
def _share_metric() -> MetricConfig:
return MetricConfig(
field="knowledge_contribution_ratio",
name="知识贡献占比",
is_virtual=True,
calculation=VirtualMetricCalculationEnum.SHARE_OF_TOTAL,
filter=FilterExpression(
bool_operator="must",
filters=[TermOp(field="record_type", value="file")],
),
aggregations=[
AggregationExpression(
name="knowledge_contribution_count",
type=AggsTypeEnum.VALUE_COUNT,
field="file_id",
)
],
)
def _service() -> DataQueryService:
return DataQueryService(dataset_code="test", data_config=ComponentDataConfig())
def _install_search_results(monkeypatch, *results):
calls = []
pending_results = iter(results)
class FakeSearchEngineService:
def __init__(self, parameters):
calls.append(parameters)
async def search(self):
return next(pending_results)
monkeypatch.setattr(component_module, "SearchEngineService", FakeSearchEngineService)
return calls
@pytest.mark.parametrize(
"dimension_field",
[
"category_name",
"business_domain_name",
"space_name",
"uploader_name",
"belonging_department_name",
],
)
@pytest.mark.asyncio
async def test_share_of_total_uses_current_filtered_file_total_for_any_single_dimension(
monkeypatch,
dimension_field,
):
calls = _install_search_results(
monkeypatch,
[["A", 30], ["B", 20]],
[[100]],
)
result = await _service().query_one_metric(
_share_metric(),
aggregation="sum",
dimension_index=0,
index_name="test-index",
dimensions=[_dimension(dimension_field)],
stack_dimension=None,
filters=None,
)
assert result == [["A", 0.3], ["B", 0.2]]
assert [dimension.field for dimension in calls[0].dimensions] == [dimension_field]
assert calls[1].dimensions == []
assert calls[1].stack_dimension is None
assert sum(row[-1] for row in result) == 0.5
@pytest.mark.asyncio
async def test_share_of_total_removes_all_dimensions_and_stack_only_from_denominator(monkeypatch):
calls = _install_search_results(
monkeypatch,
[
["2026-08", "炼钢", "一部", 15],
["2026-08", "炼钢", "二部", 5],
["2026-08", "轧钢", "一部", 10],
],
[[100]],
)
result = await _service().query_one_metric(
_share_metric(),
aggregation="sum",
dimension_index=2,
index_name="test-index",
dimensions=[_dimension("timestamp"), _dimension("business_domain_name")],
stack_dimension=_dimension("belonging_department_name"),
filters=None,
)
assert result == [
["2026-08", "炼钢", "一部", 0.15],
["2026-08", "炼钢", "二部", 0.05],
["2026-08", "轧钢", "一部", 0.1],
]
assert [dimension.field for dimension in calls[0].dimensions] == [
"timestamp",
"business_domain_name",
]
assert calls[0].stack_dimension.field == "belonging_department_name"
assert calls[1].dimensions == []
assert calls[1].stack_dimension is None
@pytest.mark.asyncio
async def test_share_of_total_keeps_dataset_dashboard_link_and_time_filters(monkeypatch):
calls = _install_search_results(monkeypatch, [["炼钢", 25]], [[100]])
runtime_filters = [
FilterExpression(
bool_operator="must",
filters=[TermsOp(field="space_id", value=["space-1", "space-2"])],
),
FilterExpression(
bool_operator="must",
filters=[
RangeOp(
field="timestamp",
value=RangeValue(gte=1_785_513_600_000, lte=1_788_105_599_000),
)
],
),
]
result = await _service().query_one_metric(
_share_metric(),
aggregation="sum",
dimension_index=0,
index_name="test-index",
dimensions=[_dimension("business_domain_name")],
stack_dimension=None,
filters=runtime_filters,
)
assert result == [["炼钢", 0.25]]
numerator_filters = [item.model_dump() for item in calls[0].filters]
denominator_filters = [item.model_dump() for item in calls[1].filters]
assert numerator_filters == denominator_filters
assert len(numerator_filters) == 3
assert numerator_filters[-1]["filters"][0]["field"] == "record_type"
@pytest.mark.asyncio
@pytest.mark.parametrize("denominator_result", [[], [[0]]])
async def test_share_of_total_returns_zero_when_denominator_is_missing_or_zero(
monkeypatch,
denominator_result,
):
_install_search_results(monkeypatch, [["一部", 2], ["二部", 1]], denominator_result)
result = await _service().query_one_metric(
_share_metric(),
aggregation="sum",
dimension_index=0,
index_name="test-index",
dimensions=[_dimension("belonging_department_name")],
stack_dimension=None,
filters=None,
)
assert result == [["一部", 0], ["二部", 0]]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("numerator_result", "denominator_result", "expected"),
[
([[100]], [[100]], [[1.0]]),
([[0]], [[0]], [[0]]),
],
)
async def test_share_of_total_without_dimensions_returns_overall_ratio(
monkeypatch,
numerator_result,
denominator_result,
expected,
):
calls = _install_search_results(monkeypatch, numerator_result, denominator_result)
result = await _service().query_one_metric(
_share_metric(),
aggregation="sum",
dimension_index=-1,
index_name="test-index",
dimensions=[],
stack_dimension=None,
filters=None,
)
assert result == expected
assert calls[0].dimensions == calls[1].dimensions == []
@pytest.mark.asyncio
async def test_existing_divide_metric_still_uses_formula_query_path(monkeypatch):
service = _service()
query_formula_metric = AsyncMock(return_value=[[0.5]])
monkeypatch.setattr(DataQueryService, "query_formula_metric", query_formula_metric)
metric = MetricConfig(
field="existing_ratio",
name="既有占比",
is_virtual=True,
formula=FormulaEnum.DIVIDE,
aggregations=[
AggregationExpression(name="first", type=AggsTypeEnum.VALUE_COUNT, field="file_id"),
AggregationExpression(name="second", type=AggsTypeEnum.VALUE_COUNT, field="file_id"),
],
)
result = await service.query_one_metric(metric, aggregation="sum", dimension_index=-1)
assert result == [[0.5]]
query_formula_metric.assert_awaited_once()
@@ -1,4 +1,5 @@
from bisheng.common.constants.telemetry import KNOWLEDGE_SPACE_CONTENT_STAT_INDEX
from bisheng.telemetry.domain.mid_table.knowledge_space_content import KnowledgeSpaceContentRecord
from bisheng.telemetry_search.domain.init_dataset import DASHBOARD_DATASET
@@ -50,3 +51,43 @@ def test_knowledge_space_content_dataset_favorite_metric_uses_daily_projection()
"aggs": None,
}
]
def test_knowledge_space_content_dataset_exposes_single_query_time_contribution_ratio():
metrics = {item["field"]: item for item in _dataset_schema()["metrics"]}
field = "knowledge_contribution_ratio"
metric = metrics[field]
assert metric["name"] == "知识贡献占比"
assert metric["is_virtual"] is True
assert metric["calculation"] == "share_of_total"
assert "share_dimension_hierarchy" not in metric
assert metric["default_number_format"] == {
"type": "percent",
"decimalPlaces": 1,
"thousandSeparator": False,
}
assert metric["filter"]["filters"] == [
{"operator": "term", "field": "record_type", "value": "file"},
{"operator": "term", "field": "file_type", "value": 1},
{
"operator": "terms",
"field": "space_level",
"value": ["public", "department", "team", "team_ks", "personal"],
},
]
assert metric["aggregations"] == [
{
"name": field,
"type": "value_count",
"field": "file_id",
"custom_params": None,
"time_interval": None,
"aggs": None,
}
]
assert not {
"uploader_knowledge_contribution_ratio",
"belonging_knowledge_contribution_ratio",
}.intersection(metrics)
assert field not in KnowledgeSpaceContentRecord.model_fields
@@ -0,0 +1,153 @@
from datetime import datetime
from types import SimpleNamespace
import pytest
from bisheng.telemetry_search.domain.schemas.component import ComponentDataConfig
from bisheng.telemetry_search.domain.services import component as component_module
from bisheng.telemetry_search.domain.services.component import DataQueryService
def _dimension(field_id: str, display_name: str, time_granularity: str | None = None) -> dict:
return {
"fieldId": field_id,
"fieldName": display_name,
"fieldCode": field_id,
"displayName": display_name,
"sort": None,
"timeGranularity": time_granularity,
}
def _dataset_config() -> SimpleNamespace:
return SimpleNamespace(
es_index_name="pivot-index",
schema_config={
"dimensions": [
{"name": "上传人", "field": "uploader_name", "field_type": "string"},
{
"name": "日期",
"field": "timestamp",
"field_type": "date",
"time_granularitys": ["day"],
},
{"name": "分类", "field": "category_name", "field_type": "string"},
],
"metrics": [
{
"name": "新增文件数",
"field": "new_file_count",
"field_type": "number",
}
],
},
)
def _install_repository(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeSessionContext:
async def __aenter__(self):
return object()
async def __aexit__(self, exc_type, exc_value, traceback):
return False
class FakeRepository:
def __init__(self, session):
self.session = session
async def find_one(self, **kwargs):
return _dataset_config()
monkeypatch.setattr(component_module, "get_async_db_session", FakeSessionContext)
monkeypatch.setattr(
component_module,
"DashboardDatasetRepositoryImpl",
FakeRepository,
)
@pytest.mark.asyncio
async def test_two_pivot_column_dimensions_follow_row_dimensions(monkeypatch):
_install_repository(monkeypatch)
captured = {}
day_timestamp = int(datetime(2026, 8, 20).timestamp() * 1000)
async def fake_query_all_metrics(self, metric_map, dimension_index, **kwargs):
captured["dimension_index"] = dimension_index
captured["dimensions"] = [item.field for item in kwargs["dimensions"]]
captured["stack_dimension"] = kwargs["stack_dimension"]
return [["张三", day_timestamp, "政策制度", 3]]
monkeypatch.setattr(DataQueryService, "query_all_metrics", fake_query_all_metrics)
config = ComponentDataConfig.model_validate(
{
"dimensions": [_dimension("uploader_name", "上传人")],
"stackDimensions": [
_dimension("timestamp", "时间(日)", "day"),
_dimension("category_name", "知识分类"),
],
"metrics": [
{
"fieldId": "new_file_count",
"fieldName": "新增文件数",
"aggregation": "sum",
}
],
}
)
result = await DataQueryService(
dataset_code="pivot-test",
data_config=config,
).query_telemetry_data()
assert captured == {
"dimension_index": 2,
"dimensions": ["uploader_name", "timestamp", "category_name"],
"stack_dimension": None,
}
assert result.dimensions == [["张三", "2026-08-20", "政策制度"]]
assert result.value == [[3]]
@pytest.mark.asyncio
async def test_legacy_single_stack_dimension_keeps_existing_query_path(monkeypatch):
_install_repository(monkeypatch)
captured = {}
async def fake_query_all_metrics(self, metric_map, dimension_index, **kwargs):
captured["dimension_index"] = dimension_index
captured["dimensions"] = [item.field for item in kwargs["dimensions"]]
captured["stack_dimension"] = kwargs["stack_dimension"].field
return [["张三", "政策制度", 2]]
monkeypatch.setattr(DataQueryService, "query_all_metrics", fake_query_all_metrics)
config = ComponentDataConfig.model_validate(
{
"dimensions": [_dimension("uploader_name", "上传人")],
"stackDimension": _dimension("category_name", "知识分类"),
"metrics": [
{
"fieldId": "new_file_count",
"fieldName": "新增文件数",
"aggregation": "sum",
}
],
}
)
result = await DataQueryService(
dataset_code="pivot-test",
data_config=config,
).query_telemetry_data()
assert captured == {
"dimension_index": 1,
"dimensions": ["uploader_name"],
"stack_dimension": "category_name",
}
assert result.dimensions == [["张三", "政策制度"]]
assert result.value == [[2]]
+2 -2
View File
@@ -111,8 +111,8 @@ def test_realtime_dashboard_seed_contains_three_target_datasets():
dimension["field"]: dimension["name"]
for dimension in knowledge_dataset.schema_config["dimensions"]
}
assert knowledge_dimensions["space_department_name"] == "所属部门"
assert knowledge_dimensions["primary_department_name"] == "上传人所在部门"
assert knowledge_dimensions["belonging_department_name"] == "所属部门"
assert knowledge_dimensions["uploader_department_name"] == "上传人部门"
def test_component_data_config_preserves_pivot_column_aliases():
@@ -108,21 +108,31 @@ export interface TimeGranularity {
// 维度配置
export interface DimensionConfig {
name: string
type: 'integer' | 'keyword' | 'date'
field: string
time_granularity?: TimeGranularity[]
aggregation?: Record<string, any>
aggregation_name: string
bucket_path: string
field_type: 'string' | 'number' | 'date'
time_granularitys?: string[]
}
// 指标配置
export interface MetricConfig {
field: string
field_type: 'string' | 'number' | 'date'
name: string
filter?: Record<string, any>
aggregation: Record<string, any>
aggregation_name: string
bucket_path?: string
aggregations?: Record<string, any>[]
formula?: 'add' | 'subtract' | 'multiply' | 'divide'
calculation?: 'share_of_total'
default_number_format?: {
type: 'number' | 'percent' | 'duration' | 'storage'
decimalPlaces: number
unit?: string
suffix?: string
thousandSeparator: boolean
}
index?: number
sum_field?: string
sum_type?: string
is_virtual?: boolean
}
// Schema 配置
@@ -196,10 +206,16 @@ function transformNormalData(resData: any, component: DashboardComponent) {
const MAX_PIVOT_ROWS = 500;
const MAX_PIVOT_COLUMNS = 100;
function transformPivotData(resData: any, component: DashboardComponent) {
export function transformPivotData(resData: any, component: DashboardComponent) {
const config = component.data_config;
const rowDimensionCount = config.dimensions?.length || 0;
const columns: string[] = [];
const stackDimensions = config.stackDimensions?.length
? config.stackDimensions.slice(0, 2)
: config.stackDimension
? [config.stackDimension]
: [];
const columnDimensionCount = stackDimensions.length;
const columnPaths: string[][] = [];
const columnIndex = new Map<string, number>();
const rows = new Map<string, { key: string[], values: Map<string, number> }>();
let truncated = false;
@@ -208,16 +224,20 @@ function transformPivotData(resData: any, component: DashboardComponent) {
const rowValues = dimensionValues
.slice(0, rowDimensionCount)
.map(value => String(value ?? '未分类'));
const columnValue = String(dimensionValues[rowDimensionCount] ?? '未分类');
const columnPath = dimensionValues
.slice(rowDimensionCount, rowDimensionCount + columnDimensionCount)
.map(value => String(value ?? '未分类'));
const normalizedColumnPath = columnPath.length > 0 ? columnPath : ['未分类'];
const rowKey = JSON.stringify(rowValues);
const columnKey = JSON.stringify(normalizedColumnPath);
if (!columnIndex.has(columnValue)) {
if (columns.length >= MAX_PIVOT_COLUMNS) {
if (!columnIndex.has(columnKey)) {
if (columnPaths.length >= MAX_PIVOT_COLUMNS) {
truncated = true;
return;
}
columnIndex.set(columnValue, columns.length);
columns.push(columnValue);
columnIndex.set(columnKey, columnPaths.length);
columnPaths.push(normalizedColumnPath);
}
if (!rows.has(rowKey)) {
if (rows.size >= MAX_PIVOT_ROWS) {
@@ -227,13 +247,15 @@ function transformPivotData(resData: any, component: DashboardComponent) {
rows.set(rowKey, { key: rowValues, values: new Map() });
}
const metricValue = Number(resData.value[index]?.[0] ?? 0);
rows.get(rowKey)!.values.set(columnValue, metricValue);
const row = rows.get(rowKey)!;
row.values.set(columnKey, (row.values.get(columnKey) ?? 0) + metricValue);
});
const columnTotals = columns.map(() => 0);
const columnKeys = columnPaths.map(path => JSON.stringify(path));
const columnTotals = columnPaths.map(() => 0);
const pivotRows = Array.from(rows.values()).map(row => {
const values = columns.map((column, index) => {
const value = row.values.get(column) ?? 0;
const values = columnKeys.map((columnKey, index) => {
const value = row.values.get(columnKey) ?? 0;
columnTotals[index] += value;
return value;
});
@@ -244,23 +266,31 @@ function transformPivotData(resData: any, component: DashboardComponent) {
};
});
const displayColumnPaths = columnPaths.map(path => path.map((value, index) => {
const dimension = stackDimensions[index];
return resolvePivotColumnLabels({
columns: [value],
stackDimension: dimension,
aliasConfig: config.pivotColumnAliases,
})[0];
}));
const columnHeaders = stackDimensions.map(
dimension => dimension.displayName || dimension.fieldName || dimension.fieldId
);
return {
rowHeaders: (config.dimensions || []).map(
dimension => dimension.displayName || dimension.fieldName || dimension.fieldId
),
columnHeader: config.stackDimension?.displayName
|| config.stackDimension?.fieldName
|| config.stackDimension?.fieldId
|| '',
columnHeader: columnHeaders[0] || '',
columnHeaders,
metricName: config.metrics?.[0]?.displayName
|| config.metrics?.[0]?.fieldName
|| '',
columns: resolvePivotColumnLabels({
columns,
stackDimension: config.stackDimension,
aliasConfig: config.pivotColumnAliases,
}),
originalColumns: columns,
columns: displayColumnPaths.map(path => path[path.length - 1]),
originalColumns: columnPaths.map(path => path[path.length - 1]),
columnPaths: displayColumnPaths,
originalColumnPaths: columnPaths,
rows: pivotRows,
columnTotals,
grandTotal: columnTotals.reduce((sum, value) => sum + value, 0),
@@ -12,6 +12,40 @@ interface PivotTableProps {
isDark: boolean
}
interface PivotHeaderCell {
label: string
path: string[]
colSpan: number
}
const buildHeaderRows = (columnPaths: string[][]): PivotHeaderCell[][] => {
const depth = Math.max(1, ...columnPaths.map(path => path.length))
return Array.from({ length: depth }, (_, level) => {
const cells: PivotHeaderCell[] = []
let columnIndex = 0
while (columnIndex < columnPaths.length) {
const currentPath = columnPaths[columnIndex]
const prefix = currentPath.slice(0, level + 1)
let colSpan = 1
while (
columnIndex + colSpan < columnPaths.length
&& JSON.stringify(columnPaths[columnIndex + colSpan].slice(0, level + 1)) === JSON.stringify(prefix)
) {
colSpan += 1
}
cells.push({
label: currentPath[level] || "未分类",
path: currentPath,
colSpan,
})
columnIndex += colSpan
}
return cells
})
}
const formatValue = (value: number, dataConfig: DataConfig) => {
const metric = dataConfig.metrics?.[0]
if (!metric?.numberFormat) {
@@ -30,6 +64,20 @@ export const PivotTable = memo(function PivotTable({
() => Math.max(0, ...data.rows.flatMap(row => row.values)),
[data.rows]
)
const columnPaths = useMemo(
() => data.columnPaths?.length === data.columns.length
? data.columnPaths
: data.columns.map(column => [column]),
[data.columnPaths, data.columns]
)
const originalColumnPaths = useMemo(
() => data.originalColumnPaths?.length === data.columns.length
? data.originalColumnPaths
: (data.originalColumns || data.columns).map(column => [column]),
[data.columns, data.originalColumnPaths, data.originalColumns]
)
const headerRows = useMemo(() => buildHeaderRows(columnPaths), [columnPaths])
const headerDepth = headerRows.length
const cellBackground = (value: number) => {
if (!value || !maxValue) return isDark ? "rgba(71, 85, 105, 0.18)" : "#f8fafc"
@@ -53,46 +101,67 @@ export const PivotTable = memo(function PivotTable({
aria-label={`${data.metricName}交叉表`}
>
<thead className="sticky top-0 z-20">
<tr>
{data.rowHeaders.map((header, index) => (
<th
key={`${header}-${index}`}
className="sticky left-0 z-30 w-32 min-w-32 max-w-32 border-b border-r border-border bg-sky-600 px-3 py-2 text-left font-semibold text-white"
style={{ left: `${index * 128}px` }}
>
{header}
</th>
))}
{data.columns.map((column, columnIndex) => {
const originalColumn = data.originalColumns?.[columnIndex] || column
return (
{headerRows.map((headerRow, level) => (
<tr key={`header-${level}`}>
{level === 0 && (
<th
key={`${originalColumn}-${columnIndex}`}
className="min-w-24 border-b border-r border-border bg-emerald-600 px-3 py-2 text-right font-semibold text-white"
title={
originalColumn === column
? `${data.columnHeader}${column}`
: `${data.columnHeader}${originalColumn}${column}`
}
scope="col"
rowSpan={headerDepth}
className="sticky left-0 z-40 w-16 min-w-16 max-w-16 border-b border-r border-border bg-sky-700 px-2 py-2 text-center font-semibold text-white"
>
{column}
</th>
)
})}
<th className="min-w-24 border-b border-border bg-amber-500 px-3 py-2 text-right font-semibold text-white">
</th>
</tr>
)}
{level === 0 && data.rowHeaders.map((header, index) => (
<th
key={`${header}-${index}`}
scope="col"
rowSpan={headerDepth}
className="sticky left-0 z-30 w-32 min-w-32 max-w-32 border-b border-r border-border bg-sky-600 px-3 py-2 text-left font-semibold text-white"
style={{ left: `${64 + index * 128}px` }}
>
{header}
</th>
))}
{headerRow.map((cell, cellIndex) => (
<th
key={`${cell.path.join("-")}-${level}-${cellIndex}`}
scope="col"
colSpan={cell.colSpan}
className={`${level === 0 ? "bg-emerald-700" : "bg-emerald-600"} min-w-24 border-b border-r border-border px-3 py-2 text-center font-semibold text-white`}
title={`${data.columnHeaders?.[level] || data.columnHeader}${cell.label}`}
>
{cell.label}
</th>
))}
{level === 0 && (
<th
scope="col"
rowSpan={headerDepth}
className="min-w-24 border-b border-border bg-amber-500 px-3 py-2 text-right font-semibold text-white"
>
</th>
)}
</tr>
))}
</thead>
<tbody>
{data.rows.map((row, rowIndex) => (
<tr key={JSON.stringify(row.key)} className="hover:brightness-[0.98]">
<th
scope="row"
className="sticky left-0 z-20 w-16 min-w-16 max-w-16 border-b border-r border-border bg-background px-2 py-2 text-center font-medium tabular-nums text-foreground"
>
{rowIndex + 1}
</th>
{row.key.map((label, dimensionIndex) => (
<th
key={`${label}-${dimensionIndex}`}
scope="row"
className="sticky z-10 w-32 min-w-32 max-w-32 border-b border-r border-border bg-background px-3 py-2 text-left font-medium text-foreground"
style={{
left: `${dimensionIndex * 128}px`,
left: `${64 + dimensionIndex * 128}px`,
paddingLeft: `${12 + dimensionIndex * 12}px`,
}}
title={label}
@@ -110,10 +179,10 @@ export const PivotTable = memo(function PivotTable({
))}
{row.values.map((value, columnIndex) => (
<td
key={`${rowIndex}-${data.originalColumns?.[columnIndex] || data.columns[columnIndex]}`}
key={`${rowIndex}-${JSON.stringify(originalColumnPaths[columnIndex])}`}
className="border-b border-r border-border px-3 py-2 text-right tabular-nums text-foreground"
style={{ backgroundColor: cellBackground(value) }}
title={`${row.key.join(" / ")} · ${data.columns[columnIndex]}${formatValue(value, dataConfig)}`}
title={`${row.key.join(" / ")} · ${columnPaths[columnIndex].join(" / ")}${formatValue(value, dataConfig)}`}
>
{value ? formatValue(value, dataConfig) : "—"}
</td>
@@ -127,14 +196,14 @@ export const PivotTable = memo(function PivotTable({
<tfoot className="sticky bottom-0 z-10">
<tr>
<th
colSpan={Math.max(1, data.rowHeaders.length)}
colSpan={Math.max(1, data.rowHeaders.length + 1)}
className="sticky left-0 z-30 border-r border-t border-border bg-slate-100 px-3 py-2 text-left font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
</th>
{data.columnTotals.map((value, index) => (
<td
key={`total-${data.originalColumns?.[index] || data.columns[index]}`}
key={`total-${JSON.stringify(originalColumnPaths[index])}`}
className="border-r border-t border-border bg-slate-100 px-3 py-2 text-right font-semibold tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-100"
>
{formatValue(value, dataConfig)}
@@ -24,7 +24,7 @@ import { DimensionBlock } from "./DimensionBlock"
import { FilterConditionDialog } from "./FilterConditionDialog"
import { StyleConfigPanel } from "./StyleConfigPanel"
import { resolveAppliedComponentTitle } from "./componentConfigDraft"
import { useChartState } from "./useChartState"
import { getMaxStackDimensionCount, useChartState } from "./useChartState"
import { generateUUID } from "@/components/bs-ui/utils"
type ResultDisplayMode = "all" | "limit" | "limitWithOther"
@@ -173,6 +173,7 @@ export function ComponentConfigDrawer() {
return STACKED_CHART_TYPES.has(chartType) ? 3 : 1
}
const isPivotTable = chartType === ChartType.PivotTable
const maxStackDimensions = getMaxStackDimensionCount(chartType)
const isCircularChart = chartType === ChartType.Pie || chartType === ChartType.Donut
const isVirtualMetric = (field: DatasetField) => {
return field.isVirtual === true
@@ -282,7 +283,7 @@ export function ComponentConfigDrawer() {
sort: null
}
chartState.setCategoryDimensions(prev => [...prev, newDimension])
} else if (currentChartHasStack && stackDimensions.length === 0 && isMetricCard) {
} else if (currentChartHasStack && stackDimensions.length < maxStackDimensions && isMetricCard) {
if (isFieldInCategoryOrStack(safeFieldId)) {
toast({
description: t("useChartState.warn.fieldExists"),
@@ -379,6 +380,7 @@ export function ComponentConfigDrawer() {
const newMetric = {
id: `${safeFieldId}-${Date.now()}`,
fieldId: safeFieldId,
name: field.fieldCode,
displayName: field.displayName || field.fieldName,
originalName: field.displayName || field.fieldName,
fieldType: field.role,
@@ -386,12 +388,13 @@ export function ComponentConfigDrawer() {
aggregation: 'sum' as const,
isVirtual: currentIsVirtual,
isDivide: field.isDivide,
numberFormat: field.numberFormat,
}
chartState.setValueDimensions(prev => [...prev, newMetric])
}
}, [editingComponent, categoryDimensions, stackDimensions, valueDimensions, currentChartHasStack, chartState, toast, t])
}, [editingComponent, categoryDimensions, stackDimensions, valueDimensions, currentChartHasStack, chartState, maxStackDimensions, toast, t])
const invalidFieldIds = useMemo(() => {
const validSet = new Set(
@@ -1070,6 +1073,7 @@ export function ComponentConfigDrawer() {
isDimension={true}
isStack={'stack'}
dimensions={stackDimensions}
maxDimensions={maxStackDimensions}
isDragOver={dragOverSection === 'stack'}
onDragOver={(e) => handleDragOver(e, 'stack')}
onDragLeave={handleDragLeave}
@@ -10,12 +10,16 @@ import { useQuery } from "react-query"
import { useTranslation } from "react-i18next"
export interface DatasetField {
fieldId?: string
fieldCode: string // 真正用来过滤 / SQL
fieldName?: string
displayName: string // UI 显示
fieldType: "string" | "number" | "date"
role: "dimension" | "metric"
enumValues?: string[]
isVirtual?: boolean
isDivide?: string
numberFormat?: MetricConfig["default_number_format"]
timeGranularity?: string
}
@@ -50,7 +54,20 @@ const getFieldTypeIcon = (type: 'string' | 'number' | 'date') => {
// 判断是否为虚拟指标
const isVirtualMetric = (metric: MetricConfig): boolean => {
return metric.is_virtual
return Boolean(metric.is_virtual)
}
export function createMetricDatasetField(metric: MetricConfig, displayName: string): DatasetField {
return {
fieldCode: metric.field,
fieldId: metric.field,
displayName,
fieldType: metric.field_type,
isVirtual: metric.is_virtual,
isDivide: metric.formula,
numberFormat: metric.default_number_format,
role: "metric",
}
}
export function DatasetSelector({ selectedDatasetCode, isMetricCard, onDatasetChange, onDragStart, onFieldsLoaded, onFieldClick }: DatasetSelectorProps) {
@@ -96,6 +113,7 @@ export function DatasetSelector({ selectedDatasetCode, isMetricCard, onDatasetCh
fieldCode: data.fieldCode,
fieldType,
isDivide: data.isDivide,
numberFormat: data.numberFormat,
timeGranularity: data.timeGranularity,
}
@@ -120,15 +138,10 @@ export function DatasetSelector({ selectedDatasetCode, isMetricCard, onDatasetCh
role: "dimension" as const
}))
const metrics = selectedDataset.schema_config.metrics.map(m => ({
fieldCode: m.field,
fieldId: m.field,
displayName: t(m.field, { defaultValue: m.name }),
fieldType: m.field_type,
isVirtual: m.is_virtual,
isDivide: m.formula,
role: "metric" as const
}))
const metrics = selectedDataset.schema_config.metrics.map(m => createMetricDatasetField(
m,
t(m.field, { defaultValue: m.name }),
))
return [...dimensions, ...metrics]
}, [selectedDataset, t])
@@ -245,7 +258,7 @@ export function DatasetSelector({ selectedDatasetCode, isMetricCard, onDatasetCh
displayName: t(dimension.field, {
defaultValue: dimension.name
}),
fieldType: dimension.type === "integer" ? "number" : "string",
fieldType: dimension.field_type,
role: "dimension",
}
@@ -285,16 +298,10 @@ export function DatasetSelector({ selectedDatasetCode, isMetricCard, onDatasetCh
<div className="flex-1 overflow-y-auto px-4 pb-3 space-y-2 min-h-0">
{selectedDataset.schema_config.metrics.map((metric, index) => {
const isVirtual = isVirtualMetric(metric)
const field: DatasetField = {
fieldCode: metric.field,
displayName: t(metric.field, {
defaultValue: metric.name
}),
fieldType: "number",
role: "metric" as const,
isVirtual: metric.is_virtual,
isDivide: metric.formula,
}
const field = createMetricDatasetField(
metric,
t(metric.field, { defaultValue: metric.name }),
)
return (
<div
key={index}
@@ -114,6 +114,14 @@ export function DimensionFilterConfigurator({
)
}
const targetChartIds = targetCharts.map(target => target.id)
const allTargetsSelected = targetChartIds.length > 0
&& targetChartIds.every(id => linkedComponentIds.includes(id))
const toggleAllTargets = () => {
setLinkedComponentIds(allTargetsSelected ? [] : targetChartIds)
}
return (
<div className="flex h-full w-[360px] flex-col border-l border-border bg-background">
<div className="border-b border-border px-4 py-3">
@@ -181,6 +189,15 @@ export function DimensionFilterConfigurator({
<section className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-md border border-border p-2">
{targetCharts.length > 0 && (
<label className="flex cursor-pointer items-center gap-2 rounded border-b border-border px-2 py-1.5 pb-2 text-sm font-medium hover:bg-accent/50">
<Checkbox
checked={allTargetsSelected}
onCheckedChange={toggleAllTargets}
/>
<span></span>
</label>
)}
{targetCharts.map(target => (
<label
key={target.id}
@@ -8,6 +8,49 @@ import { useToast } from "@/components/bs-ui/toast/use-toast"
import { useComponentEditorStore, useEditorDashboardStore } from "@/store/dashboardStore"
import { useTranslation } from "react-i18next"
type MetricNumberFormat = DataConfig["metrics"][number]["numberFormat"]
interface MetricFormatSource {
isDivide?: string
numberFormat?: MetricNumberFormat
}
export function getMaxStackDimensionCount(chartType: ChartType): number {
return chartType === ChartType.PivotTable ? 2 : 1
}
export function resolveMetricNumberFormat(metric: MetricFormatSource): MetricNumberFormat {
if (metric.isDivide === "divide") {
if (metric.numberFormat?.type === "percent") {
return metric.numberFormat
}
if (metric.numberFormat) {
return {
type: "percent",
decimalPlaces: metric.numberFormat.decimalPlaces || 2,
unit: undefined,
suffix: metric.numberFormat.suffix || "",
thousandSeparator: false,
}
}
return {
type: "percent",
decimalPlaces: 2,
unit: undefined,
suffix: "",
thousandSeparator: false,
}
}
return metric.numberFormat || {
type: "number",
decimalPlaces: 0,
unit: undefined,
suffix: undefined,
thousandSeparator: true,
}
}
export function useChartState(
initialComponent: DashboardComponent,
@@ -105,23 +148,33 @@ export function useChartState(
}
// 2. 初始化堆叠维度
if (dc.stackDimension) {
const componentId = `stack_0_${Date.now()}`
fieldIdToComponentId.set(dc.stackDimension.fieldId, componentId)
const formattedStackDim = {
id: componentId,
fieldId: dc.stackDimension.fieldId,
name: dc.stackDimension.fieldCode,
displayName: dc.stackDimension.displayName || dc.stackDimension.fieldName,
originalName: dc.stackDimension.fieldName,
sort: dc.stackDimension.sort || null,
timeGranularity: dc.stackDimension.timeGranularity || null,
sortPriority: 0,
fieldType: 'dimension'
}
newStackDimensions.push(formattedStackDim)
setStackDimensions([formattedStackDim])
console.log('设置堆叠维度:', formattedStackDim)
const savedStackDimensions = dc.stackDimensions?.length
? dc.stackDimensions
: dc.stackDimension
? [dc.stackDimension]
: []
const maxStackDimensions = getMaxStackDimensionCount(newChartType)
if (savedStackDimensions.length > 0) {
const formattedStackDims = savedStackDimensions
.slice(0, maxStackDimensions)
.map((dimension, index) => {
const componentId = `stack_${index}_${Date.now()}`
fieldIdToComponentId.set(dimension.fieldId, componentId)
return {
id: componentId,
fieldId: dimension.fieldId,
name: dimension.fieldCode,
displayName: dimension.displayName || dimension.fieldName,
originalName: dimension.fieldName,
sort: dimension.sort || null,
timeGranularity: dimension.timeGranularity || null,
sortPriority: 0,
fieldType: 'dimension'
}
})
newStackDimensions.push(...formattedStackDims)
setStackDimensions(formattedStackDims)
console.log('设置堆叠维度:', formattedStackDims)
}
// 3. 初始化指标(value
@@ -276,6 +329,11 @@ export function useChartState(
setChartType(value)
const maxStackDimensions = getMaxStackDimensionCount(value)
if (isNewStackChart && stackDimensions.length > maxStackDimensions) {
setStackDimensions(stackDimensions.slice(0, maxStackDimensions))
}
// 从堆叠 → 非堆叠:清理 stack 维度
if (isCurrentStackChart && !isNewStackChart && stackDimensions.length > 0) {
const stackDim = stackDimensions[0]
@@ -329,7 +387,8 @@ export function useChartState(
// 特殊处理:从类别维度拖到堆叠维度
if (sourceSection === 'category' && section === 'stack') {
// 检查堆叠维度是否已满
if (stackDimensions.length >= 1) {
const maxStackDimensions = getMaxStackDimensionCount(chartType)
if (stackDimensions.length >= maxStackDimensions) {
toast({
description: t('useChartState.warn.maxStackDimension'),
variant: "warning",
@@ -357,7 +416,7 @@ export function useChartState(
};
setCategoryDimensions(updatedCategoryDimensions);
setStackDimensions([movedDimension]);
setStackDimensions(prev => [...prev, movedDimension]);
setDragOverSection(null);
return;
@@ -388,8 +447,7 @@ export function useChartState(
id: `category_${Date.now()}_${fieldId}`, // 生成新的ID
};
// 清空堆叠维度
setStackDimensions([]);
setStackDimensions(prev => prev.filter(dimension => dimension.id !== dimensionToMove.id));
setCategoryDimensions(prev => [...prev, movedDimension]);
setDragOverSection(null);
@@ -424,9 +482,9 @@ export function useChartState(
return
}
// 堆叠维度只能有一个
if (section === 'stack' && stackDimensions.length >= 1) {
console.warn('堆叠维度只能有一个,请先删除现有的堆叠维度')
const maxStackDimensions = getMaxStackDimensionCount(chartType)
if (section === 'stack' && stackDimensions.length >= maxStackDimensions) {
console.warn(`堆叠维度最多只能有 ${maxStackDimensions}`)
toast({
description: t('useChartState.warn.maxStackDimension'),
variant: "warning",
@@ -520,6 +578,7 @@ export function useChartState(
timeGranularity: data.timeGranularity,
fieldType,
isDivide: data.isDivide,
numberFormat: data.numberFormat,
}
if (section === 'category') {
@@ -534,8 +593,7 @@ export function useChartState(
}
setCategoryDimensions(prev => [...prev, newDimension])
} else if (section === 'stack') {
// 堆叠维度 - 特殊处理
setStackDimensions([newDimension])
setStackDimensions(prev => [...prev, newDimension])
} else if (section === 'value') {
setValueDimensions(prev => [...prev, newDimension])
}
@@ -616,50 +674,20 @@ export function useChartState(
timeGranularity: dim.timeGranularity || null
}))
const stackDimension = stackDimensions.length > 0 ? {
fieldId: stackDimensions[0].fieldId,
fieldName: stackDimensions[0].originalName,
fieldCode: stackDimensions[0].name,
displayName: stackDimensions[0].displayName,
sort: stackDimensions[0].sort,
timeGranularity: stackDimensions[0].timeGranularity || null
} : undefined
const serializedStackDimensions = stackDimensions
.slice(0, getMaxStackDimensionCount(chartType))
.map(dimension => ({
fieldId: dimension.fieldId,
fieldName: dimension.originalName,
fieldCode: dimension.name,
displayName: dimension.displayName,
sort: dimension.sort,
timeGranularity: dimension.timeGranularity || null
}))
const stackDimension = serializedStackDimensions[0]
const metrics = valueDimensions.map(metric => {
let numberFormat;
if (metric.isDivide === "divide") {
console.log('检测到除法指标:', metric.displayName);
if (metric.numberFormat) {
if (metric.numberFormat.type === 'percent') {
numberFormat = metric.numberFormat;
console.log('已使用百分比格式:', numberFormat);
} else {
numberFormat = {
type: 'percent' as const,
decimalPlaces: metric.numberFormat.decimalPlaces || 2,
unit: undefined,
suffix: metric.numberFormat.suffix || '',
thousandSeparator: false
};
}
} else {
numberFormat = {
type: 'percent' as const,
decimalPlaces: 2,
unit: undefined,
suffix: '',
thousandSeparator: false
};
}
} else {
numberFormat = metric.numberFormat || {
type: 'number' as const,
decimalPlaces: 0,
unit: undefined,
suffix: undefined,
thousandSeparator: true
};
}
const numberFormat = resolveMetricNumberFormat(metric)
// console.log('最终 numberFormat:', numberFormat);
@@ -742,6 +770,9 @@ export function useChartState(
return {
dimensions,
stackDimension,
stackDimensions: chartType === ChartType.PivotTable
? serializedStackDimensions
: undefined,
metrics,
fieldOrder,
filters,
@@ -25,9 +25,12 @@ export interface PivotTableRow {
export interface PivotTableDataResponse {
rowHeaders: string[]
columnHeader: string
columnHeaders?: string[]
metricName: string
columns: string[]
originalColumns?: string[]
columnPaths?: string[][]
originalColumnPaths?: string[][]
rows: PivotTableRow[]
columnTotals: number[]
grandTotal: number
@@ -243,6 +243,7 @@ export interface TimeFilter {
export interface DataConfig {
dimensions: DimensionField[] // 维度字段列表(对应 echarts xAxis
stackDimension?: DimensionField // 堆叠维度字段(某些图表类型才有,如堆叠柱状图)
stackDimensions?: DimensionField[] // Ordered pivot column dimensions; legacy configs fall back to stackDimension
metrics: MetricField[] // 指标字段列表(对应 echarts yAxis
fieldOrder: {
fieldId: string // 字段ID
@@ -0,0 +1,106 @@
import { DimensionFilterConfigurator } from "@/pages/Dashboard/components/config/DimensionFilterConfigurator"
import {
ChartType,
DashboardComponent,
DimensionFilterConfig,
} from "@/pages/Dashboard/types/dataConfig"
import { fireEvent, render, screen } from "@/test/test-utils"
import { describe, expect, it, vi } from "vitest"
const targetCharts = [
{
id: "chart-1",
title: "知识统计",
type: ChartType.Bar,
dataset_code: "mid_knowledge_space_content_stat",
},
{
id: "chart-2",
title: "知识组织化总量",
type: ChartType.Metric,
dataset_code: "mid_knowledge_space_content_stat",
},
]
vi.mock("react-query", () => ({
useQuery: () => ({
data: [
{
dataset_code: "mid_knowledge_space_content_stat",
dataset_name: "知识空间内容统计",
schema_config: {
dimensions: [
{ field: "space_level", name: "知识库大类编码" },
],
},
},
],
}),
}))
vi.mock("@/store/dashboardStore", () => ({
useEditorDashboardStore: (selector: (state: any) => unknown) => selector({
currentDashboard: { components: targetCharts },
}),
}))
const component = {
id: "dimension-filter-1",
dashboard_id: "dashboard-1",
title: "维度筛选",
type: ChartType.DimensionFilter,
dataset_code: "mid_knowledge_space_content_stat",
data_config: {
fields: [
{
id: "space-level",
fieldId: "space_level",
fieldName: "知识库大类编码",
displayName: "知识库大类编码",
defaultValues: [],
},
],
linkedComponentIds: [],
} satisfies DimensionFilterConfig,
style_config: {},
create_time: "",
update_time: "",
} as DashboardComponent
describe("dimension filter target chart select all", () => {
it("selects and clears every target chart and saves the selected ids", () => {
const onSave = vi.fn()
render(
<DimensionFilterConfigurator
component={component}
onSave={onSave}
onCancel={vi.fn()}
/>
)
const selectAll = screen.getByRole("checkbox", { name: "全选" })
const firstChart = screen.getByRole("checkbox", { name: "知识统计" })
const secondChart = screen.getByRole("checkbox", { name: "知识组织化总量" })
fireEvent.click(selectAll)
expect(selectAll).toBeChecked()
expect(firstChart).toBeChecked()
expect(secondChart).toBeChecked()
fireEvent.click(firstChart)
expect(selectAll).not.toBeChecked()
expect(firstChart).not.toBeChecked()
expect(secondChart).toBeChecked()
fireEvent.click(selectAll)
fireEvent.click(screen.getByRole("button", { name: "更新筛选预览" }))
expect(onSave).toHaveBeenCalledWith(
"mid_knowledge_space_content_stat",
expect.objectContaining({ linkedComponentIds: ["chart-1", "chart-2"] })
)
fireEvent.click(selectAll)
expect(firstChart).not.toBeChecked()
expect(secondChart).not.toBeChecked()
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest"
import { createMetricDatasetField } from "../pages/Dashboard/components/config/DatasetSelector"
import { resolveMetricNumberFormat } from "../pages/Dashboard/components/config/useChartState"
describe("knowledge contribution metric format", () => {
it("uses the dataset percent default", () => {
const field = createMetricDatasetField(
{
field: "knowledge_contribution_ratio",
field_type: "number",
name: "知识贡献占比",
is_virtual: true,
calculation: "share_of_total",
default_number_format: {
type: "percent",
decimalPlaces: 1,
thousandSeparator: false,
},
},
"知识贡献占比",
)
expect(
resolveMetricNumberFormat({
fieldId: field.fieldId,
numberFormat: field.numberFormat,
}),
).toEqual({
type: "percent",
decimalPlaces: 1,
thousandSeparator: false,
})
})
it("keeps the existing divide metric default", () => {
expect(resolveMetricNumberFormat({ isDivide: "divide" })).toEqual({
type: "percent",
decimalPlaces: 2,
unit: undefined,
suffix: "",
thousandSeparator: false,
})
})
it("preserves a saved component-level format", () => {
const savedFormat = {
type: "number" as const,
decimalPlaces: 3,
unit: "K",
suffix: "份",
thousandSeparator: true,
}
expect(
resolveMetricNumberFormat({
isDivide: undefined,
numberFormat: savedFormat,
}),
).toEqual(savedFormat)
})
})
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ComponentConfigDrawer } from "@/pages/Dashboard/components/config/ComponentConfigDrawer"
import { ChartType } from "@/pages/Dashboard/types/dataConfig"
const { editorStoreState, toastMock } = vi.hoisted(() => ({
editorStoreState: {
editingComponent: null as any,
updateEditingComponent: vi.fn(),
applyEditingComponent: vi.fn(),
cancelEditingComponent: vi.fn(),
draftVersion: 0,
},
toastMock: vi.fn(),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock("@/components/bs-ui/toast/use-toast", () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock("@/store/dashboardStore", () => ({
useComponentEditorStore: () => editorStoreState,
useEditorDashboardStore: () => ({ refreshChart: vi.fn() }),
}))
vi.mock("@/pages/Dashboard/components/config/DatasetSelector", () => ({
DatasetSelector: ({ onFieldClick }: { onFieldClick?: (field: any) => void }) => (
<button
type="button"
onClick={() => onFieldClick?.({
fieldId: "category_name",
fieldCode: "category_name",
fieldName: "知识分类",
displayName: "知识分类",
role: "dimension",
})}
>
</button>
),
}))
vi.mock("@/pages/Dashboard/components/config/DimensionBlock", () => ({
DimensionBlock: ({ dimensions, isStack }: { dimensions: any[]; isStack?: string }) => (
isStack === "stack"
? <div data-testid="stack-dimensions">{dimensions.map(item => item.fieldId).join(",")}</div>
: null
),
}))
vi.mock("@/pages/Dashboard/components/editor/ComponentPicker", () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ChartGroupItems: [],
}))
vi.mock("@/pages/Dashboard/components/config/FilterConditionDialog", () => ({
FilterConditionDialog: () => null,
}))
const pivotWithOneColumnDimension = {
id: "pivot-field-click",
title: "交叉表",
type: ChartType.PivotTable,
dataset_code: "mid_knowledge_space_content_stat",
data_config: {
dimensions: [
{
fieldId: "uploader_name",
fieldName: "上传人名称",
fieldCode: "uploader_name",
displayName: "上传人名称",
},
{
fieldId: "department_name",
fieldName: "上传人部门名称",
fieldCode: "department_name",
displayName: "上传人部门名称",
},
],
stackDimension: {
fieldId: "timestamp",
fieldName: "时间(日)",
fieldCode: "timestamp",
displayName: "时间(日)",
timeGranularity: "day",
},
metrics: [
{
fieldId: "new_file_count",
fieldName: "新增文件数",
fieldCode: "new_file_count",
displayName: "新增文件数",
aggregation: "sum",
},
],
filters: [],
fieldOrder: [],
isConfigured: true,
},
style_config: {},
}
describe("pivot column dimension field click", () => {
beforeEach(() => {
vi.clearAllMocks()
editorStoreState.editingComponent = pivotWithOneColumnDimension
editorStoreState.draftVersion = 0
})
it("adds a second pivot column dimension from the dataset field list", async () => {
render(<ComponentConfigDrawer />)
await waitFor(() => {
expect(screen.getByTestId("stack-dimensions")).toHaveTextContent("timestamp")
})
fireEvent.click(screen.getByRole("button", { name: "添加第二个列维度" }))
await waitFor(() => {
expect(screen.getByTestId("stack-dimensions")).toHaveTextContent(
"timestamp,category_name",
)
})
})
it("keeps non-pivot stacked charts limited to one column dimension", async () => {
editorStoreState.editingComponent = {
...pivotWithOneColumnDimension,
id: "stacked-bar-field-click",
type: ChartType.StackedBar,
}
render(<ComponentConfigDrawer />)
await waitFor(() => {
expect(screen.getByTestId("stack-dimensions")).toHaveTextContent("timestamp")
})
fireEvent.click(screen.getByRole("button", { name: "添加第二个列维度" }))
expect(screen.getByTestId("stack-dimensions")).toHaveTextContent("timestamp")
expect(screen.getByTestId("stack-dimensions")).not.toHaveTextContent("category_name")
expect(toastMock).toHaveBeenCalled()
})
})
@@ -0,0 +1,41 @@
import { render, screen, within } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { PivotTable } from "@/pages/Dashboard/components/charts/PivotTable"
import { PivotTableDataResponse } from "@/pages/Dashboard/types/chartData"
import { DataConfig } from "@/pages/Dashboard/types/dataConfig"
describe("pivot table sequence column", () => {
it("renders a one-based sequence before row dimensions", () => {
const data: PivotTableDataResponse = {
rowHeaders: ["上传人名称", "上传人部门名称"],
columnHeader: "时间(日)",
metricName: "新增文件数",
columns: ["2026-08-06"],
originalColumns: ["2026-08-06"],
rows: [
{ key: ["俞宇成", "信息部"], values: [1], total: 1 },
{ key: ["张杰", "采购部"], values: [3], total: 3 },
],
columnTotals: [4],
grandTotal: 4,
}
const dataConfig = {
metrics: [],
} as DataConfig
render(<PivotTable data={data} dataConfig={dataConfig} isDark={false} />)
const rows = within(screen.getByRole("table")).getAllByRole("row")
expect(within(rows[0]).getAllByRole("columnheader").map(cell => cell.textContent)).toEqual([
"序号",
"上传人名称",
"上传人部门名称",
"2026-08-06",
"合计",
])
expect(within(rows[1]).getAllByRole("rowheader")[0]).toHaveTextContent("1")
expect(within(rows[2]).getAllByRole("rowheader")[0]).toHaveTextContent("2")
expect(within(rows[3]).getByText("合计")).toHaveAttribute("colspan", "3")
})
})
@@ -0,0 +1,233 @@
import { act, render, renderHook, screen, waitFor, within } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { transformPivotData } from "@/controllers/API/dashboard"
import { PivotTable } from "@/pages/Dashboard/components/charts/PivotTable"
import { useChartState } from "@/pages/Dashboard/components/config/useChartState"
import { ChartType, DashboardComponent, DataConfig } from "@/pages/Dashboard/types/dataConfig"
const { toastMock } = vi.hoisted(() => ({
toastMock: vi.fn(),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock("@/components/bs-ui/toast/use-toast", () => ({
useToast: () => ({ toast: toastMock }),
}))
vi.mock("@/store/dashboardStore", () => ({
useComponentEditorStore: () => ({ updateEditingComponent: vi.fn() }),
useEditorDashboardStore: () => ({ refreshChart: vi.fn() }),
}))
const stackDimensions = [
{
fieldId: "timestamp",
fieldName: "时间(日)",
fieldCode: "timestamp",
displayName: "时间(日)",
sort: null,
timeGranularity: "day",
},
{
fieldId: "category_name",
fieldName: "知识分类",
fieldCode: "category_name",
displayName: "知识分类",
sort: null,
timeGranularity: null,
},
]
const component = {
id: "pivot-two-columns",
title: "交叉表",
type: ChartType.PivotTable,
dataset_code: "mid_knowledge_space_content_stat",
data_config: {
dimensions: [
{
fieldId: "uploader_name",
fieldName: "上传人名称",
fieldCode: "uploader_name",
displayName: "上传人名称",
sort: null,
timeGranularity: null,
},
{
fieldId: "department_name",
fieldName: "上传人部门名称",
fieldCode: "department_name",
displayName: "上传人部门名称",
sort: null,
timeGranularity: null,
},
],
stackDimension: stackDimensions[0],
stackDimensions,
metrics: [
{
fieldId: "new_file_count",
fieldName: "新增文件数",
fieldCode: "new_file_count",
displayName: "新增文件数",
aggregation: "sum",
isVirtual: false,
sort: null,
numberFormat: {
type: "number",
decimalPlaces: 0,
thousandSeparator: true,
},
},
],
fieldOrder: [],
filters: [],
filtersLogic: "and",
resultLimit: { limitType: "all" },
isConfigured: true,
},
style_config: {},
} as unknown as DashboardComponent
describe("pivot table two column dimensions", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("restores, saves, and limits pivot column dimensions to two", async () => {
const { result } = renderHook(() => useChartState(component))
await waitFor(() => {
expect(result.current.stackDimensions).toHaveLength(2)
})
expect(result.current.stackDimensions.map(item => item.fieldId)).toEqual([
"timestamp",
"category_name",
])
expect(result.current.stackDimensions[0].timeGranularity).toBe("day")
const savedConfig = result.current.getDataConfig("all", "")
expect(savedConfig.stackDimensions?.map(item => item.fieldId)).toEqual([
"timestamp",
"category_name",
])
expect(savedConfig.stackDimension?.fieldId).toBe("timestamp")
const dropEvent = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
getData: () => JSON.stringify({
id: "business_domain_name",
name: "business_domain_name",
displayName: "业务域",
fieldType: "dimension",
}),
},
} as unknown as React.DragEvent
act(() => {
result.current.handleDrop(dropEvent, "stack", true)
})
expect(result.current.stackDimensions).toHaveLength(2)
expect(toastMock).toHaveBeenCalled()
})
it("pivots complete column paths, accumulates duplicates, and renders grouped headers", () => {
const data = transformPivotData(
{
dimensions: [
["张三", "信息部", "2026-08-20", "政策制度"],
["张三", "信息部", "2026-08-20", "政策制度"],
["张三", "信息部", "2026-08-20", "标准规范"],
["李四", "采购部", "2026-08-21", "政策制度"],
],
value: [[1], [2], [4], [5]],
},
component,
)
expect(data.columnHeaders).toEqual(["时间(日)", "知识分类"])
expect(data.columnPaths).toEqual([
["2026-08-20", "政策制度"],
["2026-08-20", "标准规范"],
["2026-08-21", "政策制度"],
])
expect(data.rows[0]).toEqual({
key: ["张三", "信息部"],
values: [3, 4, 0],
total: 7,
})
expect(data.columnTotals).toEqual([3, 4, 5])
expect(data.grandTotal).toBe(12)
render(
<PivotTable
data={data}
dataConfig={component.data_config as DataConfig}
isDark={false}
/>,
)
const headerRows = within(screen.getByRole("table")).getAllByRole("row").slice(0, 2)
const firstHeaderCells = within(headerRows[0]).getAllByRole("columnheader")
expect(firstHeaderCells.map(cell => cell.textContent)).toEqual([
"序号",
"上传人名称",
"上传人部门名称",
"2026-08-20",
"2026-08-21",
"合计",
])
expect(firstHeaderCells[3]).toHaveAttribute("colspan", "2")
expect(firstHeaderCells[4]).toHaveAttribute("colspan", "1")
expect(within(headerRows[1]).getAllByRole("columnheader").map(cell => cell.textContent)).toEqual([
"政策制度",
"标准规范",
"政策制度",
])
})
it("keeps a legacy single column dimension as a single header row", () => {
const legacyComponent = {
...component,
data_config: {
...component.data_config,
stackDimensions: undefined,
stackDimension: stackDimensions[1],
},
} as DashboardComponent
const data = transformPivotData(
{
dimensions: [["张三", "信息部", "政策制度"]],
value: [[2]],
},
legacyComponent,
)
expect(data.columnHeaders).toEqual(["知识分类"])
expect(data.columnPaths).toEqual([["政策制度"]])
render(
<PivotTable
data={data}
dataConfig={legacyComponent.data_config as DataConfig}
isDark={false}
/>,
)
const rows = within(screen.getByRole("table")).getAllByRole("row")
expect(within(rows[0]).getAllByRole("columnheader").map(cell => cell.textContent)).toEqual([
"序号",
"上传人名称",
"上传人部门名称",
"政策制度",
"合计",
])
})
})