@finebi/finebi-cli 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/skills/finebi-cli-agent/SKILL.md +43 -123
  4. package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
  5. package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
  6. package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
  7. package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
  8. package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
  9. package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
  10. package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
  11. package/skills/finebi-cli-agent/references/web-app.md +4 -2
  12. package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
  13. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  14. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +18 -6
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +1 -1
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +8 -5
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +14 -1
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -0
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +8 -1
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +1 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +2 -0
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +15 -1
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +159 -1
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +8 -6
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +1 -1
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
  46. package/src/app.js +18 -6
  47. package/src/browser-auth.js +3 -4
  48. package/src/config.js +1 -1
  49. package/src/error-catalog.js +2 -2
  50. package/src/help.js +8 -5
  51. package/templates/web-app/AGENTS.md +18 -5
  52. package/templates/web-app/README.md +12 -5
  53. package/templates/web-app/docs/acceptance.md +14 -1
  54. package/templates/web-app/docs/auth-and-deploy.md +17 -2
  55. package/templates/web-app/docs/data-contract.md +66 -38
  56. package/templates/web-app/docs/filters.md +2 -2
  57. package/templates/web-app/gitignore.template +6 -0
  58. package/templates/web-app/scripts/browser-session.mjs +59 -0
  59. package/templates/web-app/scripts/dev-proxy.mjs +25 -24
  60. package/templates/web-app/scripts/dev-session.mjs +57 -0
  61. package/templates/web-app/scripts/session-file.mjs +45 -0
  62. package/templates/web-app/src/App.tsx +8 -1
  63. package/templates/web-app/src/components/DataTable.tsx +1 -1
  64. package/templates/web-app/src/components/Widget.tsx +1 -0
  65. package/templates/web-app/src/finebi/client.ts +20 -4
  66. package/templates/web-app/src/finebi/config.d.ts +2 -0
  67. package/templates/web-app/src/finebi/config.js +15 -1
  68. package/templates/web-app/src/finebi/normalize.ts +159 -1
  69. package/templates/web-app/src/finebi/types.ts +8 -6
  70. package/templates/web-app/src/retail.css +1 -1
  71. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  72. package/templates/web-app/tests/proxy.test.mjs +20 -5
  73. package/templates/web-app/tests/runtime.test.mjs +7 -0
  74. package/templates/web-app/tests/session.test.mjs +91 -0
  75. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  76. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  77. package/templates/web-app/vite.config.ts +9 -4
@@ -1,38 +1,66 @@
1
- # 数据契约与适配范围
2
-
3
- ## 输入与版本
4
-
5
- 浏览器调用 `/v5/api/dashboard/report/{dashboardId}/widgets/query`,传板内实例 widgetIds、includeWidgetBean=true、pageLimit、refer。原生结果仅在 src/finebi/normalize.ts 解析。输出 schemaVersion=1,与 CLI 标准化结果采用相同的字段、值状态与范围语义;v1 浏览器只覆盖 DETAIL 和 CHART,不等于 Java 适配器的全部覆盖。
6
-
7
- 配置 directoryEntryId 时请求增加 reportEntryId,开发代理校验来源与配置严格相等后透传。目标平台 HTTP 查询服务需同时支持目录权限预检与 MOUNT 模式;仅升级 CLI Provider 不代表该 HTTP 入口已支持。权限/版本错误直接呈现,不移除入口后重试。各展示组件独立请求,批量加载限制三路并发,单个组件失败不遮蔽其他结果;同一原生实例可有不同展示和筛选绑定。
8
-
9
- ### 字段选择
10
-
11
- 当前模板没有字段选择配置,浏览器请求不传 `dimensionSelections`,因此沿用组件保存的维度和指标配置。页面展示字段的绑定不等于服务端字段选择;用 CLI 验证自定义选择得到的结果,也不代表模板会自动使用同一选择。
12
-
13
- 支持完整白名单语义的平台接口通过 `dimensionSelections` 按组件指定维度和指标的 `dId` 列表;CLI 对应参数为 `dashboard data --dimension-ids`。ID 从同一来源的 `dashboard info` 中目标实例的 `queryFields` 取得,只选 `selectable=true` 的字段,包括已关闭但仍可选择的字段;不能用字段名或底层数据集字段 ID 代替。
14
-
15
- - 不传选择项时沿用保存配置。
16
- - 显式列表是维度和指标的完整白名单,未列出的可选字段全部关闭;需要维度和指标同时输出时必须一起列出。
17
- - 只传维度会关闭未选指标,只传指标会关闭全部可选维度;`[]` 关闭全部可选维度和指标,不保证各组件都有有效输出,明细表不返回列及数据行。
18
- - 选择只影响本次查询,不保存配置、不清除过滤、不按列表顺序重排字段,也不能添加组件未配置的字段。
19
-
20
- 这套语义要求目标平台 Provider/HTTP 查询接口支持维度和指标完整白名单;只升级 CLI 不会为模板增加字段选择能力。
21
-
22
- ## 消费顺序
23
-
24
- 检查 normalization(COMPLETE/PARTIAL/UNSUPPORTED)→ warnings → 选唯一 dataset → 按 columns 解释 rows[].cells → 按 range 说明范围。COMPLETE 只表示本次结构转换完整,不表示全集。多模型需指定 datasetId;UNKNOWN 行不得自动求和。
25
-
26
- columns 含 key、fieldId、name、type、encoding。key 仅在本次dataset内唯一;fieldId 是结果字段ID,不能当底层数据集字段ID。绑定使用fieldId,不使用可能重复的name。
27
-
28
- cells 含 value、display、state、reason:AVAILABLE 可用,NULL 确认空值,UNAVAILABLE 无法可靠解释,RESTRICTED 权限受限。表格优先显示 display,受限单元格统一显示“受限”。计算不能从 display 反解析数字;DECIMAL_STRING 保留上游值,JS Number 仅用于图形坐标且不能用于高精度金额计算。DATE 的 EPOCH_MILLIS_STRING 保留毫秒字符串,不擅自改变时区。日期元数据支持原生类型3、13、15、20;未知类型保守返回不可用。
29
-
30
- 浏览器适配器不能获知服务端 cellCharLimit,字符串不能据此承诺未经截断的数据库原文。图表可见数值不代表底层明细;数值型字符串不会被猜成图表指标。交叉表/分组表及缺少内联模型的图形明确为不支持;多模型、computedState 和缺列都会保留警告。
31
-
32
- ## 范围
33
-
34
- 明细表 range.total 采用有效原生row;PAGE是本次首页,pageLimit不提供续页。hasNext=true 原样保留并产生 NEXT_PAGE_UNSUPPORTED;即使返回数等于总数,也不覆盖这一状态。当前仅提供已加载结果的搜索、排序和分页,页面显示该范围。图表UNPAGED也不等于底层明细全量,可能存在Top N或聚合。
35
-
36
- 服务端返回空数组与缺失组件不同:前者可显示空数据,后者是 INVALID_RESPONSE。受限/失败/不支持结果不回退静态样本。
37
-
38
- 表格排序使用 AVAILABLE 的 value,DECIMAL_STRING 按十进制字符串比较,支持合法指数而不转换为浮点金额;缺值排在可用值之后。搜索、展示筛选和 CSV 使用 displayCell 的统一值状态呈现;CSV 导出当前匹配集合而非仅当前页,含 UNKNOWN 行标识并转义公式前缀。图表坐标允许 Number 近似,展开数据表保留原始显示值。经营观察仅消费当前结果的状态、行数和单值 KPI,不自动累加 UNKNOWN 行或推断业务比率。
1
+ # 数据契约与适配范围
2
+
3
+ ## 输入与版本
4
+
5
+ 浏览器调用 `/v5/api/dashboard/report/{dashboardId}/widgets/query`,传板内实例 widgetIds、includeWidgetBean=true、pageLimit、refer。原生结果仅在 src/finebi/normalize.ts 解析。输出 schemaVersion=1,与 CLI 标准化结果采用相同的字段、值状态与范围语义;浏览器覆盖 DETAIL、BIGROUP、BICROSS 和 CHART。交叉表采用下述浏览器矩阵契约;CLI Java 标准化器的交叉表支持范围独立,不由浏览器能力推断。
6
+
7
+ 配置 directoryEntryId 时请求增加 reportEntryId,开发代理校验来源与配置严格相等后透传。目标平台 HTTP 查询服务需同时支持目录权限预检与 MOUNT 模式;仅升级 CLI Provider 不代表该 HTTP 入口已支持。权限/版本错误直接呈现,不移除入口后重试。各展示组件独立请求,批量加载限制三路并发,单个组件失败不遮蔽其他结果;同一原生实例可有不同展示和筛选绑定。
8
+
9
+ ### 字段选择
10
+
11
+ `widgets[].dimensionIds?: string[]` 是可选的服务端字段选择配置。常规绑定省略该字段,浏览器不为该实例发送选择项,沿用组件保存的维度和指标启用状态;不会强制启用保存态隐藏字段。显式配置时,请求发送 `dimensionSelections: { [entryId]: dimensionIds }`。页面展示字段的绑定不等于服务端字段选择;CLI 查询使用的列表需要显式写入对应展示绑定才会在模板中生效。
12
+
13
+ 支持完整白名单语义的平台接口通过 `dimensionSelections` 按组件指定维度和指标的 `dId` 列表;CLI 对应参数为 `dashboard data --dimension-ids`。ID 从同一来源的 `dashboard info` 中目标实例的 `queryFields` 取得,只选 `selectable=true` 的字段,包括已关闭但仍可选择的字段;不能用字段名或底层数据集字段 ID 代替。
14
+
15
+ - 不传选择项时沿用保存配置。
16
+ - 显式列表是维度和指标的完整白名单,未列出的可选字段全部关闭;需要维度和指标同时输出时必须一起列出。
17
+ - 只传维度会关闭未选指标,只传指标会关闭全部可选维度;`[]` 关闭全部可选维度和指标,不保证各组件都有有效输出,明细表不返回列及数据行。
18
+ - 选择只影响本次查询,不保存配置、不清除过滤、不按列表顺序重排字段,也不能添加组件未配置的字段。
19
+
20
+ 配置只接受无重复、无空白项的字符串数组,`null` 非法;不能用 `[]` 代替省略。需要同时输出城市和销售额时,可在目标 `widgets[]` 条目添加以下属性(占位 ID 需替换):
21
+
22
+ ```json
23
+ "dimensionIds": ["<城市dId>", "<销售额dId>"]
24
+ ```
25
+
26
+ 各展示绑定独立查询;同一 `entryId` 可以配置不同的字段选择。直接调用批量 `queryWidgets` 时,同一实例必须使用相同选择集合,否则拒绝请求;列表顺序不影响集合比较,省略与 `[]` 不等价。开发代理校验选择属于请求实例,且匹配该实例至少一个配置绑定,再原样转发;不会丢弃选择或补回指标。
27
+
28
+ 这套语义要求目标平台 HTTP 查询接口支持维度和指标完整白名单,CLI 验证还需对应 Provider 支持。未选字段上的过滤继续生效;展示绑定必须与所选输出相符,缺列不能补零。模板不提供运行时字段选择器。
29
+
30
+ ## 消费顺序
31
+
32
+ 检查 normalization(COMPLETE/PARTIAL/UNSUPPORTED)→ warnings → 选唯一 dataset → 按 columns 解释 rows[].cells → 按 range 说明范围。COMPLETE 只表示本次结构转换完整,不表示全集。多模型需指定 datasetId;UNKNOWN 行不得自动求和。
33
+
34
+ columns 含 key、fieldId、name、type、encoding。key 仅在本次dataset内唯一;fieldId 是结果字段ID,不能当底层数据集字段ID。绑定使用fieldId,不使用可能重复的name。
35
+
36
+ cells 含 value、display、state、reason:AVAILABLE 可用,NULL 确认空值,UNAVAILABLE 无法可靠解释,RESTRICTED 权限受限。表格优先显示 display,受限单元格统一显示“受限”。计算不能从 display 反解析数字;DECIMAL_STRING 保留上游值,JS Number 仅用于图形坐标且不能用于高精度金额计算。DATE 的 EPOCH_MILLIS_STRING 保留毫秒字符串,不擅自改变时区。日期元数据支持原生类型3、13、15、20;未知类型保守返回不可用。
37
+
38
+ 浏览器适配器不能获知服务端 cellCharLimit,字符串不能据此承诺未经截断的数据库原文。图表可见数值不代表底层明细;数值型字符串不会被猜成图表指标。缺少内联模型的图形明确为不支持;多模型、computedState 和缺列都会保留警告。
39
+
40
+ ### 分组表
41
+
42
+ 原生 BIGROUP 转为 GROUP_TABLE,提供 main 数据集。按 header 的 dId 对齐列,递归保留父级分组键;节点有指标或为叶子时输出行,不丢弃父级汇总。指标采用原生 value,缺失时只接受 text 中的规范十进制数值,不反解析 formatText、单位或百分号。分组键只有明确的字符串类型可作为 value,数值/日期分组保留展示文本并标记 GROUP_KEY_SEMANTICS_UNKNOWN,不冒充原始字段值。
43
+
44
+ 行类型依据指标的 rowSumInfo:DATA、SUBTOTAL、GRAND_TOTAL;标记缺失、冲突或同一行指标分类不一致时为 UNKNOWN,不根据“合计”等标题猜测。表格显示数据/小计/总计,CSV 保留行类型。父级键、指标权限及字段歧义分别保留;重复指标不取第一个充数,不把缺值补零。含小计、总计或 UNKNOWN 的数据集不能直接累加所有行。
45
+
46
+ ### 交叉表
47
+
48
+ 原生 BICROSS 转为 CROSS_TABLE/main,以当前返回视图的矩阵展示。按 top 树叶子及其 values 的顺序建立列槽位,再按同一位置读取 left 叶子 values;保留多级分组、指标横排/竖排及原生合计位置,不按重复的指标 dId 合并交叉列。top/left 都必须存在。没有列维度时,平台返回空 top 轴,使用 left 有序指标槽位;空视图与缺失轴结构分开处理。
49
+
50
+ 首列 fieldId 为 `cross:row`,展示行分组完整路径;后续列为 `cross:0`、`cross:1` 等本次查询的槽位 ID,标题拼接列分组路径。这些 ID 不是 FineBI 的 dId,不能用于 dimensionIds 或跨查询固定字段绑定。通常只配置 `type: "table"`,省略 tableFieldIds/localFilterFieldIds 以展示全部返回列;按原生指标 dId 配置图形或 KPI 不适用于这个矩阵。模板展示路径标题,不复刻原生合并单元格、树展开和布局样式。
51
+
52
+ `columns[].path` 和 `rows[].path` 保留有序 `{fieldId,label,state}`,值单元格的 `sourceFieldId` 保留原生指标 dId。展示标题中,追加的指标名称与末级列分组名称相同时只显示一次,指标放在行维度时,仅当末级路径的 fieldId 与该行非空指标 dId 一致,且名称与前一级相同时,隐藏末级重复标题。两种去重均不折叠真实分组层级的同名标签,不修改 path 或单元格映射。DATA 行展示时省略内部 `__summary_dim_id__` 承载节点的“合计”,保留真实业务分组名称及完整 path。
53
+
54
+ 行 kind 来自 rowSumInfo;列 kind 及单元格 columnKind 来自 colSumInfo,未知或冲突保持 UNKNOWN。列小计/总计明确标注,CSV 保留展示路径标题与行类型;不能混加各级汇总。受限轴标签显示“受限”,关联单元格也保持受限。具有非空指标 dId、authority=true 且 value/text/formatText 均显式为 null 的单元格为 NULL,表格和 CSV 显示“—”;无指标的结构占位、缺失值载荷或权限未知仍为 UNAVAILABLE,不补零。
55
+
56
+ 返回值少于列槽位时标记 PARTIAL,缺格保留 MISSING_CELL;原生列指标 ID 与对应数值 ID 冲突时标记 PARTIAL/AMBIGUOUS_FIELD。数值多于列槽位、轴缺失或节点同时携带子树与非空 values 的未覆盖结构标记 UNSUPPORTED,不能截掉多余值或按猜测顺序对齐。
57
+
58
+ ## 范围
59
+
60
+ 明细表 range.total 采用有效原生row;PAGE是本次首页,pageLimit不提供续页。hasNext=true 原样保留并产生 NEXT_PAGE_UNSUPPORTED;即使返回数等于总数,也不覆盖这一状态。普通分组表的分页单位为 GROUP,总组数未知;展开后的 returnedRowCount 可以超过首页组数。开启 showRowHeaderIcon 的表头展开模式标记 INITIAL_VIEW,不给出普通页码或续页状态;原生 hasNext 仍产生续页警告。当前仅提供已加载结果的搜索、排序和分页,页面显示该范围。图表UNPAGED也不等于底层明细全量,可能存在Top N或聚合。
61
+
62
+ 交叉表 range.mode 为 INITIAL_VIEW,page/pageSize/pageUnit/total 均不声明普通表格分页或总数。`range.axes.horizontal/vertical` 分别保留原生 X/Y 的 hasNext/hasPrevious;任一方向还有数据即给出 NEXT_PAGE_UNSUPPORTED。合并 hasNext/hasPrevious 只有两轴均明确为 false 才为 false,否则保留 true 或未知。页面分别显示横向、纵向还有数据的状态;本地分页、搜索、排序和 CSV 只覆盖当前返回矩阵,不请求服务端续页或展开节点。
63
+
64
+ 服务端返回空数组与缺失组件不同:前者可显示空数据,后者是 INVALID_RESPONSE。受限/失败/不支持结果不回退静态样本。
65
+
66
+ 表格排序使用 AVAILABLE 的 value,DECIMAL_STRING 按十进制字符串比较,支持合法指数而不转换为浮点金额;缺值排在可用值之后。搜索、展示筛选和 CSV 使用 displayCell 的统一值状态呈现;CSV 导出当前匹配集合而非仅当前页,保留数据、小计、总计及 UNKNOWN 行标识并转义公式前缀。图表坐标允许 Number 近似,展开数据表保留原始显示值。经营观察仅消费当前结果的状态、行数和单值 KPI,不自动累加 UNKNOWN 行或推断业务比率。
@@ -8,7 +8,7 @@
8
8
 
9
9
  开发时从同一看板的 `dashboard info` 获取真实控件 `filterName` 和实例信息,必要时读取 `dashboard bean` 确认控件类型、绑定字段、保存默认值和作用范围。`name` 必须使用解码后的真实控件名;`label` 仅用于显示。重名控件需先在源看板消除歧义,不能用 label 或组件 ID 替代请求键。没有控件时 `filters` 保持空数组,不把任意字段伪造成服务端筛选。
10
10
 
11
- `app inspect` 和浏览器只检查本地配置,不访问远端,也不能证明控件类型或作用范围正确。交付前必须用真实接口证明条件生效。配置不复制源看板默认值,恢复默认后由服务端读取当前保存条件。
11
+ `app inspect` 默认只展示配置摘要,`--strict` 才按 CLI 内置模板规则检查。浏览器加载配置时使用项目内的校验规则;本地配置校验不能证明远端控件类型或作用范围正确。交付前必须用真实接口证明条件生效。配置不复制源看板默认值,恢复默认后由服务端读取当前保存条件。
12
12
 
13
13
  ### 1.2 条件组合与刷新
14
14
 
@@ -87,7 +87,7 @@
87
87
  更换同语义 UI 组件时,沿用表中的状态类型和统一编码器。新增能力按以下链路扩展:
88
88
 
89
89
  1. 确认源控件的类型、名称、字段、默认值、作用范围及真实请求编码,区分浏览器内部控件状态与 HTTP refer 协议;检查目标部署版本确实支持覆盖。
90
- 2. 扩展 `src/finebi/types.ts` 和 `filters.js` 中的配置、状态校验、编码和反向请求校验。CLI 的 `app inspect`、客户端和开发代理共用该模块,不另建一套允许列表。
90
+ 2. 扩展 `src/finebi/types.ts` 和 `filters.js` 中的配置、状态校验、编码和反向请求校验。客户端和开发代理共用项目内该模块,不另建一套允许列表。CLI 的 `app inspect --strict` 使用随 CLI 分发的模板模块,不能用它约束定制项目的扩展。
91
91
  3. 在 `FilterBar` 或业务页面接入控件。动态候选项必须有经过验证的接口、访问者权限、搜索/分页语义、加载失败状态、依赖条件和请求取消策略;级联还须定义上游变化后下游选择的保留或清除方式。不从样本推断完整候选项。
92
92
  4. 测试合法值、非法值、保留字、名称冲突、空值、恢复默认、开放区间和组合提交;再用真实看板核对请求、数据范围与组件展示。源码或单测通过不能代替部署与浏览器验收。
93
93
  5. 同步本契约、模板支持摘要并重新生成分发物。图表库和 UI 组件库选型见[页面骨架](design.md)。
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ temp/
4
+ .env
5
+ .env.local
6
+ .env.*.local
@@ -0,0 +1,59 @@
1
+ import {spawn} from 'node:child_process';
2
+
3
+ function openBrowser(url) {
4
+ const [command,args]=process.platform==='win32' ? ['rundll32.exe',['url.dll,FileProtocolHandler',url]]
5
+ : process.platform==='darwin' ? ['open',[url]] : ['xdg-open',[url]];
6
+ const child=spawn(command,args,{stdio:'ignore',windowsHide:true});child.on('error',()=>{});child.unref();
7
+ }
8
+
9
+ export function sleep(ms,signal) {
10
+ return new Promise((resolve,reject)=>{
11
+ if(signal?.aborted){reject(Error('AUTH_REQUIRED'));return;}
12
+ const abort=()=>{clearTimeout(timer);reject(Error('AUTH_REQUIRED'));};
13
+ const timer=setTimeout(()=>{signal?.removeEventListener('abort',abort);resolve();},ms);
14
+ signal?.addEventListener('abort',abort,{once:true});
15
+ });
16
+ }
17
+
18
+ export async function authRequest(url,body,{fetchImpl=fetch,signal}={}) {
19
+ const controller=new AbortController();
20
+ const abort=()=>controller.abort();
21
+ if(signal?.aborted)abort();else signal?.addEventListener('abort',abort,{once:true});
22
+ const timer=setTimeout(abort,30000);
23
+ try {
24
+ const response=await fetchImpl(url,{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:controller.signal});
25
+ const payload=await response.json().catch(()=>null);
26
+ return {response,payload};
27
+ } catch {throw Error(signal?.aborted?'AUTH_REQUIRED':'UPSTREAM_UNAVAILABLE');}
28
+ finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);}
29
+ }
30
+
31
+ export async function browserSession(baseUrl,options={}) {
32
+ const {signal,notify=console.info,openBrowser:launch=openBrowser,sleep:wait=sleep}=options;
33
+ const target=new URL(baseUrl);
34
+ if(target.protocol!=='https:' && !(target.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname)))throw Error('AUTH_HTTPS_REQUIRED');
35
+ const root=baseUrl.replace(/\/$/,'')+'/api/bi-cli/dev-session-auth';
36
+ const started=await authRequest(root+'/start',{description:'FineBI Web 本地开发'},options);
37
+ if(started.response.status===404)throw Error('AUTH_PLUGIN_REQUIRED');
38
+ if(!started.response.ok)throw Error('AUTH_UNAVAILABLE');
39
+ const data=started.payload?.data;
40
+ if(!data || !/^[A-Za-z0-9_-]{43}$/.test(data.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(data.userCode)
41
+ || data.verificationPath!==`/api/bi-cli/dev-session-auth/authorize?code=${data.userCode}` || data.expiresIn!==600 || data.interval!==5)throw Error('AUTH_PROTOCOL_ERROR');
42
+ const verification=baseUrl.replace(/\/$/,'')+data.verificationPath;
43
+ notify(`请先通过原有入口登录 FineBI,再打开授权页:${verification}\n核对确认码:${data.userCode}。授权会将当前账号会话保存到本地 .env.local。`);
44
+ try{await launch(verification);}catch{notify('未能打开浏览器,请手动打开上述授权页。');}
45
+ const deadline=Date.now()+data.expiresIn*1000;
46
+ while(Date.now()<deadline && !signal?.aborted) {
47
+ await wait(data.interval*1000,signal);
48
+ const polled=await authRequest(root+'/poll',{deviceCode:data.deviceCode},options);
49
+ if(polled.response.status===429)continue;
50
+ if(polled.response.status===404)throw Error('AUTH_REQUIRED');
51
+ if(!polled.response.ok)throw Error('AUTH_UNAVAILABLE');
52
+ const result=polled.payload?.data;
53
+ if(result?.status==='pending')continue;
54
+ if(result?.status==='approved' && typeof result.token==='string' && /^[\x21-\x7e]+$/.test(result.token) && !/[;,]/.test(result.token) && typeof result.username==='string')return result;
55
+ if(['denied','expired','consumed'].includes(result?.status))throw Error('AUTH_REQUIRED');
56
+ throw Error('AUTH_PROTOCOL_ERROR');
57
+ }
58
+ throw Error('AUTH_REQUIRED');
59
+ }
@@ -1,24 +1,13 @@
1
1
  // Development only: the browser never receives the developer's session token.
2
2
  import {validateRefer} from '../src/finebi/filters.js';
3
- export function createDevMiddleware(config, env = process.env, fetchImpl = fetch) {
3
+ import {validateDimensionSelections} from '../src/finebi/config.js';
4
+ import {createDevSession} from './dev-session.mjs';
5
+ export function createDevMiddleware(config, env = process.env, fetchImpl = fetch, sessionOptions = {}) {
4
6
  const prefix = config.basePath.replace(/\/$/, '');
5
7
  const queryPath = `${prefix}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`;
6
- let token = env.FINEBI_DEV_TOKEN || '';
7
- let login;
8
+ const session = createDevSession(env,{...sessionOptions,fetchImpl});
8
9
  const reply = (res, status, errorCode) => {res.writeHead(status, {'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify({success:false,errorCode}));};
9
- async function session() {
10
- if (token) return token;
11
- if (!env.FINEBI_DEV_USERNAME || !env.FINEBI_DEV_PASSWORD) throw new Error('AUTH_REQUIRED');
12
- if (!login) login = (async()=>{
13
- const response = await fetchImpl(`${env.FINEBI_DEV_BASE_URL.replace(/\/$/, '')}/login`, {method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''}),signal:AbortSignal.timeout(30000)});
14
- const body = await response.json();
15
- if (!response.ok || typeof body?.data?.accessToken !== 'string' || !body.data.accessToken) throw new Error('AUTH_REQUIRED');
16
- token=body.data.accessToken;
17
- return token;
18
- })().finally(()=>{login=undefined;});
19
- return login;
20
- }
21
- return async (req, res, next) => {
10
+ const middleware = async (req, res, next) => {
22
11
  const pathname = new URL(req.url, 'http://localhost').pathname;
23
12
  if (!pathname.startsWith(prefix+'/') && pathname !== prefix) return next();
24
13
  const host = req.headers.host || '';
@@ -33,25 +22,37 @@ export function createDevMiddleware(config, env = process.env, fetchImpl = fetch
33
22
  if (!Array.isArray(body.widgetIds) || !body.widgetIds.length || body.widgetIds.some(id=>!config.widgets.some(w=>w.entryId===id)) || !Number.isSafeInteger(body.pageLimit) || body.pageLimit<1 || body.pageLimit>config.maxRows) throw new Error('INVALID_REQUEST');
34
23
  if(body.reportEntryId!==config.directoryEntryId)throw new Error('INVALID_SOURCE');
35
24
  validateRefer(config.filters || [],body.refer);
25
+ validateDimensionSelections(config.widgets,body.widgetIds,body.dimensionSelections);
36
26
  } catch {return reply(res,400,'INVALID_REQUEST');}
37
27
  if (!env.FINEBI_DEV_BASE_URL) return reply(res,401,'AUTH_REQUIRED');
38
28
  try {
39
29
  const target = new URL(env.FINEBI_DEV_BASE_URL);
40
30
  if (!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash) throw new Error('INVALID_TARGET');
41
- const auth=await session();
31
+ for(let attempt=0;attempt<2;attempt++) {
32
+ const auth=await session.get();
42
33
  const response=await fetchImpl(`${target.href.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
43
34
  method:'POST',redirect:'manual',signal:AbortSignal.timeout(30000),
44
35
  headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest',Cookie:`fine_auth_token=${auth}`},
45
- body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {},...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{})}),
36
+ body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {},...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{}),...(body.dimensionSelections!==undefined?{dimensionSelections:body.dimensionSelections}:{})}),
46
37
  });
47
- if (response.status===401 || response.status>=300 && response.status<400 || response.headers.get('content-type')?.includes('text/html')) {token='';return reply(res,401,'AUTH_REQUIRED');}
48
- if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
49
- const json=await response.json();
50
- if (response.status===401 || ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json.errorCode)) {token='';return reply(res,401,'AUTH_REQUIRED');}
51
38
  if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
39
+ const loginResponse=response.status===401 || response.status>=300 && response.status<400 || response.ok && response.headers.get('content-type')?.includes('text/html');
40
+ const json=loginResponse ? null : await response.json();
41
+ if (loginResponse || response.ok && ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json?.errorCode ?? json?.code)) {
42
+ await session.invalidate(auth);
43
+ if(attempt===0)continue;
44
+ return reply(res,401,'AUTH_REQUIRED');
45
+ }
52
46
  // Forward only the query envelope, never upstream headers or login payloads.
53
47
  res.writeHead(response.status,{'Content-Type':'application/json','Cache-Control':'no-store'});
54
- res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || 'QUERY_FAILED'}));
55
- } catch (error) {reply(res,error.message==='AUTH_REQUIRED'?401:502,error.message==='AUTH_REQUIRED'?'AUTH_REQUIRED':'UPSTREAM_UNAVAILABLE');}
48
+ return res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || json.code || 'QUERY_FAILED'}));
49
+ }
50
+ } catch (error) {
51
+ const code=['AUTH_REQUIRED','AUTH_PLUGIN_REQUIRED','AUTH_HTTPS_REQUIRED','AUTH_PROTOCOL_ERROR','AUTH_UNAVAILABLE','AUTH_STORAGE_FAILED'].includes(error.message)?error.message:'UPSTREAM_UNAVAILABLE';
52
+ if(code!=='UPSTREAM_UNAVAILABLE') (sessionOptions.notify || console.info)(`FineBI 开发认证:${code}。请检查终端提示与 .env.local,修正后重试。`);
53
+ reply(res,code==='AUTH_REQUIRED'?401:502,code);
54
+ }
56
55
  };
56
+ middleware.dispose=()=>session.dispose();
57
+ return middleware;
57
58
  }
@@ -0,0 +1,57 @@
1
+ import {resolve} from 'node:path';
2
+ import {saveSession} from './session-file.mjs';
3
+ import {authRequest,browserSession} from './browser-session.mjs';
4
+
5
+ export function createDevSession(env,options={}) {
6
+ const {fetchImpl=fetch,browser=browserSession,notify=console.info}=options;
7
+ const file=options.file || resolve('.env.local');
8
+ const save=options.save || ((...args)=>saveSession(file,...args));
9
+ const baseUrl=(env.FINEBI_DEV_BASE_URL || '').replace(/\/$/,'');
10
+ let token=env.FINEBI_DEV_TOKEN || '';
11
+ if(env.FINEBI_DEV_TOKEN_BASE_URL && env.FINEBI_DEV_TOKEN_BASE_URL.replace(/\/$/,'')!==baseUrl)token='';
12
+ let username=env.FINEBI_DEV_TOKEN_USERNAME || '';
13
+ let pending,clearing,passwordRejected=false;
14
+ const controller=new AbortController();
15
+ const valid=value=>typeof value==='string' && /^[\x21-\x7e]+$/.test(value) && !/[;,]/.test(value);
16
+ if(token && !valid(token))token='';
17
+
18
+ async function acquire() {
19
+ if(!baseUrl || controller.signal.aborted)throw Error('AUTH_REQUIRED');
20
+ const target=new URL(baseUrl);
21
+ if(!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash)throw Error('AUTH_PROTOCOL_ERROR');
22
+ let result;
23
+ if(!passwordRejected && env.FINEBI_DEV_USERNAME && env.FINEBI_DEV_PASSWORD) {
24
+ const {response,payload}=await authRequest(baseUrl+'/login',{username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''},{fetchImpl,signal:controller.signal});
25
+ if(response.status>=500 || response.status===429)throw Error('UPSTREAM_UNAVAILABLE');
26
+ if(response.ok && valid(payload?.data?.accessToken))result={token:payload.data.accessToken,username:env.FINEBI_DEV_USERNAME};
27
+ else if([401,403].includes(response.status) || response.status>=300 && response.status<400 || payload?.errorCode || payload?.code || response.headers.get('content-type')?.includes('text/html')) {
28
+ passwordRejected=true;notify('密码登录被拒绝或需要交互登录,转为浏览器授权;本次运行不再重复尝试该密码。');
29
+ } else throw Error('AUTH_PROTOCOL_ERROR');
30
+ }
31
+ if(!result)result=await browser(baseUrl,{fetchImpl,signal:controller.signal,notify});
32
+ if(controller.signal.aborted)throw Error('AUTH_REQUIRED');
33
+ if(!valid(result?.token) || typeof result.username!=='string')throw Error('AUTH_PROTOCOL_ERROR');
34
+ if(username && username!==result.username)notify(`开发身份已切换:${username} → ${result.username}`);
35
+ try{await save(baseUrl,result.token,result.username);}catch{throw Error('AUTH_STORAGE_FAILED');}
36
+ token=result.token;username=result.username;
37
+ notify(`FineBI 开发会话已保存,当前账号:${username}`);
38
+ return token;
39
+ }
40
+ return {
41
+ async get() {
42
+ if(clearing)await clearing;
43
+ if(controller.signal.aborted)throw Error('AUTH_REQUIRED');
44
+ if(token)return token;
45
+ if(!pending)pending=acquire().finally(()=>{pending=undefined;});
46
+ return pending;
47
+ },
48
+ async invalidate(rejected) {
49
+ if(token!==rejected)return;
50
+ token='';
51
+ // Serialize invalidation with acquisition so an old response cannot overwrite a new session on disk.
52
+ clearing=save(baseUrl,'',username).catch(()=>{throw Error('AUTH_STORAGE_FAILED');}).finally(()=>{clearing=undefined;});
53
+ await clearing;
54
+ },
55
+ dispose(){controller.abort();},
56
+ };
57
+ }
@@ -0,0 +1,45 @@
1
+ import {lstat, readFile, open, mkdir, rename, chmod, unlink} from 'node:fs/promises';
2
+ import {dirname, resolve} from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {spawnSync} from 'node:child_process';
5
+
6
+ async function regularFile(path) {
7
+ try {const info=await lstat(path);if(!info.isFile() || info.isSymbolicLink())throw Error('SESSION_FILE_UNSAFE');}
8
+ catch(error){if(error.code!=='ENOENT')throw error;}
9
+ }
10
+
11
+ async function restrict(path) {
12
+ if(process.platform!=='win32'){await chmod(path,0o600);return;}
13
+ const options={encoding:'utf8',windowsHide:true};
14
+ const identity=spawnSync('whoami.exe',['/user','/fo','csv','/nh'],options);
15
+ const sid=identity.status===0 && identity.stdout.match(/"[^"]*","(S-[^"]+)"/)?.[1];
16
+ if(!sid)throw Error('SESSION_FILE_PERMISSIONS');
17
+ const result=spawnSync('icacls.exe',[path,'/inheritance:r','/grant:r',`*${sid}:(F)`,'*S-1-5-18:(F)','*S-1-5-32-544:(F)'],options);
18
+ if(result.status!==0)throw Error('SESSION_FILE_PERMISSIONS');
19
+ }
20
+
21
+ export async function saveSession(path,baseUrl,token,username='') {
22
+ path=resolve(path);
23
+ await regularFile(path);
24
+ const original=await readFile(path,'utf8').catch(error=>{if(error.code==='ENOENT')return '';throw error;});
25
+ const updates={FINEBI_DEV_TOKEN:token,FINEBI_DEV_TOKEN_BASE_URL:baseUrl,FINEBI_DEV_TOKEN_USERNAME:username};
26
+ if(!/^\s*(?:export\s+)?FINEBI_DEV_BASE_URL\s*=/m.test(original))updates.FINEBI_DEV_BASE_URL=baseUrl;
27
+ const retained=original.split(/\r?\n/).filter(line=>{
28
+ const key=line.match(/^\s*(?:export\s+)?([\w]+)\s*=/)?.[1];return !Object.hasOwn(updates,key);
29
+ }).join('\n').replace(/\n*$/,'');
30
+ const content=(retained?retained+'\n':'')+Object.entries(updates).map(([key,value])=>`${key}=${JSON.stringify(value)}`).join('\n')+'\n';
31
+ const temp=resolve(dirname(path),'temp');
32
+ await mkdir(temp,{recursive:true});
33
+ if((await lstat(temp)).isSymbolicLink())throw Error('SESSION_FILE_UNSAFE');
34
+ const stage=resolve(temp,`.finebi-session-${randomUUID()}`);
35
+ const handle=await open(stage,'wx',0o600);
36
+ try {
37
+ await restrict(stage);
38
+ await handle.writeFile(content,'utf8');await handle.sync();await handle.close();
39
+ await regularFile(path);
40
+ // A concurrent configuration edit wins; do not overwrite it with the earlier snapshot.
41
+ const current=await readFile(path,'utf8').catch(error=>{if(error.code==='ENOENT')return '';throw error;});
42
+ if(current!==original)throw Error('SESSION_FILE_CHANGED');
43
+ await rename(stage,path);
44
+ } finally {await handle.close().catch(()=>{});await unlink(stage).catch(error=>{if(error.code!=='ENOENT')throw error;});}
45
+ }
@@ -10,6 +10,13 @@ const FilterBar=lazy(()=>import('./components/FilterBar').then(module=>({default
10
10
  function Dashboard({config}: {config: AppConfig}) {
11
11
  const [filters,setFilters]=useState<FilterState>({}),[refresh,setRefresh]=useState(0);
12
12
  const [states,setStates]=useState<Record<string,WidgetState>>({});
13
+ const [activeHash,setActiveHash]=useState(()=>window.location.hash||'#dashboard');
14
+ useEffect(()=>{
15
+ const syncHash=()=>setActiveHash(window.location.hash||'#dashboard');
16
+ window.addEventListener('hashchange',syncHash);
17
+ syncHash();
18
+ return ()=>window.removeEventListener('hashchange',syncHash);
19
+ },[]);
13
20
  const generation=useRef(0),retries=useRef(new Map<string,AbortController>());
14
21
  useEffect(()=>{
15
22
  const controller=new AbortController();generation.current++;setStates({});
@@ -34,7 +41,7 @@ function Dashboard({config}: {config: AppConfig}) {
34
41
  const cockpit=config.layout==='cockpit';
35
42
  const separateKpis=cockpit||retail;
36
43
  return <div className={`app ${config.layout}`}>
37
- {retail&&<nav className="retail-nav" aria-label="页面导航"><a className="retail-brand" href="#dashboard"><b>F.</b><span>FineBI <small>经营简报</small></span></a><div><a href="#dashboard">经营概览</a><a href="#components">销售分析</a><a href="#data-notes">口径说明</a></div><span className="retail-live">动态数据</span></nav>}
44
+ {retail&&<nav className="retail-nav" aria-label="页面导航"><a className="retail-brand" href="#dashboard"><b>F.</b><span>FineBI <small>经营简报</small></span></a><div><a href="#dashboard" aria-current={activeHash==='#dashboard'?'location':undefined}>经营概览</a><a href="#components" aria-current={activeHash==='#components'?'location':undefined}>销售分析</a><a href="#data-notes" aria-current={activeHash==='#data-notes'?'location':undefined}>口径说明</a></div><span className="retail-live">动态数据</span></nav>}
38
45
  {config.layout!=='overview'&&!retail&&<aside><strong>FineBI<span>数据应用</span></strong><nav><a href="#dashboard">数据概览</a><a href="#components">分析组件</a>{config.insights&&<a href="#observations">经营观察</a>}<a href="#data-notes">数据口径</a></nav><p className="source-label">{config.directoryEntryId?'目录发布来源':'分析主题来源'}<br/>访问者权限由 FineBI 控制</p></aside>}
39
46
  <main id="dashboard"><header><div><span className="eyebrow">FINEBI · LIVE DATA</span><h1>{config.title}</h1><p className="muted">{config.description??'基于源看板的实时数据,按当前访问权限呈现'}</p></div><div className="header-actions"><a href={retail?'#data-notes':config.basePath} target={retail?undefined:'_blank'} rel="noreferrer">{retail?'口径说明':'打开 FineBI'}</a><button onClick={()=>setRefresh(n=>n+1)}>刷新数据</button></div></header>
40
47
  {config.filters.length>0&&<Boundary><Suspense fallback={<p role="status">正在加载筛选控件…</p>}><FilterBar bindings={config.filters} onApply={setFilters} cockpit={cockpit} scope={scope} compact={retail}/></Suspense></Boundary>}
@@ -15,7 +15,7 @@ export function DataTable({dataset, pageSize,binding}: {dataset: Dataset; pageSi
15
15
  const download=()=>{const url=URL.createObjectURL(new Blob([tableCsv(visible,shown)],{type:'text/csv;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download='loaded-results.csv';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
16
16
  return <><div className="table-tools"><label>搜索已加载数据 <input placeholder="搜索已加载数据" value={search} onChange={e => {setSearch(e.target.value);setPage(0);}}/></label><span>已加载 {dataset.returnedRowCount} 行 · 匹配 {rows.length} 行</span><button onClick={download}>导出匹配结果 CSV</button></div>
17
17
  {(binding.localFilterFieldIds?.length??0)>0&&<div className="local-filters">{binding.localFilterFieldIds!.map(id=>{const index=fieldIndex(dataset,id);return <label key={id}>{dataset.columns[index].name}(已加载值)<select value={filters[id]??''} onChange={e=>{setFilters(old=>({...old,[id]:e.target.value}));setPage(0);}}><option value="">全部已加载值</option>{[...new Set(dataset.rows.map(row=>displayCell(row.cells[index])))].filter(Boolean).map(value=><option key={value}>{value}</option>)}</select></label>;})}</div>}
18
- <div className="table-scroll"><table><thead><tr><th>序号</th>{visible.columns.map(c => <th key={c.key} aria-sort={sort?.fieldId===c.fieldId?(sort.direction==='asc'?'ascending':'descending'):'none'}><button className="sort-column" onClick={()=>{setSort({fieldId:c.fieldId,direction:sort?.fieldId===c.fieldId&&sort.direction==='asc'?'desc':'asc'});setPage(0);}}>{c.name} {sort?.fieldId===c.fieldId?(sort.direction==='asc'?'↑':'↓'):'↕'}</button></th>)}<th>行类型</th></tr></thead><tbody>{shown.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={visible.columns[j].key}>{displayCell(c)}</td>)}<td>{row.kind==='DATA'?'数据':'UNKNOWN'}</td></tr>)}</tbody></table></div>
18
+ <div className="table-scroll"><table><thead><tr><th>序号</th>{visible.columns.map(c => <th key={c.key} aria-sort={sort?.fieldId===c.fieldId?(sort.direction==='asc'?'ascending':'descending'):'none'}><button className="sort-column" onClick={()=>{setSort({fieldId:c.fieldId,direction:sort?.fieldId===c.fieldId&&sort.direction==='asc'?'desc':'asc'});setPage(0);}}>{c.name} {sort?.fieldId===c.fieldId?(sort.direction==='asc'?'↑':'↓'):'↕'}</button></th>)}<th>行类型</th></tr></thead><tbody>{shown.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={visible.columns[j].key}>{displayCell(c)}</td>)}<td>{{DATA:'数据',SUBTOTAL:'小计',GRAND_TOTAL:'总计',UNKNOWN:'UNKNOWN'}[row.kind]}</td></tr>)}</tbody></table></div>
19
19
  {!rows.length && <p className="empty">没有匹配的数据</p>}
20
20
  <nav className="pager" aria-label="已加载数据分页"><button disabled={current===0} onClick={()=>setPage(current-1)}>上一页</button><span>{current+1} / {pages}</span><button disabled={current===pages-1} onClick={()=>setPage(current+1)}>下一页</button></nav></>;
21
21
  }
@@ -32,6 +32,7 @@ export function Widget({state,binding,pageSize,onRetry,compact=false}: {state:Wi
32
32
  {state.error&&<div className="component-error" role="alert"><p>{'code' in state.error?String(state.error.code)+' · ':''}{state.error.message}</p><button onClick={onRetry}>重试组件</button></div>}
33
33
  {result&&<>
34
34
  {compact?<details className="query-details"><summary>查询范围{selected?` · ${selected.returnedRowCount} 行`:''}</summary><p>{result.range.mode} · {result.normalization} · 后续数据:{String(result.range.hasNext)}</p><p>筛选作用:{binding.filterNames?.join('、')||'沿用源组件条件'}</p></details>:<p className="result-range">{result.range.mode} · 本次{selected?` ${selected.returnedRowCount} 行`:'多个模型,需显式选择'}{result.range.total.value!==null?` · 查询总行数 ${result.range.total.value}`:''} · hasNext={String(result.range.hasNext)} · {result.normalization}</p>}
35
+ {result.range.axes&&<p className="result-range">交叉表当前视图 · 横向还有数据:{result.range.axes.horizontal.hasNext===null?'未知':result.range.axes.horizontal.hasNext?'是':'否'} · 纵向还有数据:{result.range.axes.vertical.hasNext===null?'未知':result.range.axes.vertical.hasNext?'是':'否'}</p>}
35
36
  {(result.warnings.length>0||result.normalization!=='COMPLETE'||result.range.hasNext===true)&&<div className="notice" role="status">{result.range.hasNext||result.warnings.includes('NEXT_PAGE_UNSUPPORTED')?'当前仅展示首屏返回结果,本地分页不代表服务端续页。':'部分源图形未转换,当前展示已返回的数据模型。'}{compact?<details><summary>查看适配说明</summary><small>{result.normalization} · {result.warnings.join(' · ')}</small></details>:<small>{result.normalization} · {result.warnings.join(' · ')}</small>}</div>}
36
37
  <Boundary key={state.at} onRetry={onRetry}><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
37
38
  </>}
@@ -1,6 +1,6 @@
1
1
  import {normalize} from './normalize';
2
2
  import {encodeFilters} from './filters.js';
3
- import {configErrors} from './config.js';
3
+ import {configErrors, sameDimensionIds} from './config.js';
4
4
  import type {AppConfig, QueryResult, FilterState, WidgetBinding} from './types';
5
5
  export class FineBIError extends Error {
6
6
  constructor(public code: string, message: string) {super(message); this.name = 'FineBIError';}
@@ -14,10 +14,16 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
14
14
  const refer = encodeFilters(config.filters,filters);
15
15
  const ids = [...new Set(widgetIds ?? config.widgets.map(w => w.entryId))];
16
16
  if(!ids.length||ids.some(id=>!config.widgets.some(w=>w.entryId===id)))throw new Error('组件不在配置范围内');
17
+ const dimensionSelections:Record<string,string[]> = Object.create(null);
18
+ for(const id of ids) {
19
+ const bindings=config.widgets.filter(w=>w.entryId===id);
20
+ if(bindings.some(w=>!sameDimensionIds(w.dimensionIds,bindings[0].dimensionIds)))throw new Error('同一组件有不同字段选择,请按展示绑定分别查询');
21
+ if(bindings[0].dimensionIds!==undefined)dimensionSelections[id]=bindings[0].dimensionIds;
22
+ }
17
23
  const response = await fetchImpl(`${config.basePath.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
18
24
  method: 'POST', credentials: 'include', signal, redirect: 'manual',
19
25
  headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
20
- body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer,...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{})}),
26
+ body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer,...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{}),...(Object.keys(dimensionSelections).length?{dimensionSelections}:{})}),
21
27
  });
22
28
  if (response.status === 401 || response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
23
29
  if (response.status === 403) throw new FineBIError('PERMISSION_DENIED', '当前用户没有访问权限');
@@ -26,7 +32,15 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
26
32
  const code = body.errorCode ?? body.code;
27
33
  if (['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN','AUTH_REQUIRED'].includes(code)) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
28
34
  if (code === 'PERMISSION_DENIED') throw new FineBIError('PERMISSION_DENIED', '当前用户没有数据或字段权限');
29
- if (!response.ok || body.success !== true) throw new FineBIError('QUERY_FAILED', 'FineBI 查询失败,请检查组件、控件绑定及访问权限');
35
+ const developmentAuthErrors: Record<string,string> = {
36
+ AUTH_PLUGIN_REQUIRED:'请更新 FineBI 服务端插件以启用浏览器授权',
37
+ AUTH_HTTPS_REQUIRED:'浏览器授权需要 HTTPS FineBI 地址',
38
+ AUTH_PROTOCOL_ERROR:'认证响应不符合预期,请检查 FineBI 地址和版本',
39
+ AUTH_UNAVAILABLE:'授权服务暂不可用,请稍后重试',
40
+ AUTH_STORAGE_FAILED:'无法保存本地会话,请检查 .env.local 文件权限',
41
+ };
42
+ if (developmentAuthErrors[code]) throw new FineBIError(code, `${developmentAuthErrors[code]};详情见开发终端`);
43
+ if (!response.ok || body.success !== true) throw new FineBIError(typeof code==='string'?code:'QUERY_FAILED', 'FineBI 查询失败,请检查组件、控件绑定、平台版本及访问权限');
30
44
  const results: Record<string, QueryResult> = {};
31
45
  for (const id of ids) {
32
46
  if (!body.data?.[id]?.previewResult) throw new FineBIError('INVALID_RESPONSE', `接口未返回组件 ${id},不能视为空数据`);
@@ -35,8 +49,10 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
35
49
  return results;
36
50
  }
37
51
  export async function queryWidget(config:AppConfig,binding:WidgetBinding,filters:FilterState={},signal?:AbortSignal,fetchImpl:typeof fetch=fetch):Promise<QueryResult> {
52
+ validateConfig(config);
53
+ if(!config.widgets.includes(binding))throw new Error('展示绑定不在配置范围内');
38
54
  // Validate all draft names before projecting the verified component scope.
39
55
  encodeFilters(config.filters,filters);
40
56
  const scoped=binding.filterNames ? Object.fromEntries(Object.entries(filters).filter(([name])=>binding.filterNames!.includes(name))) : filters;
41
- return (await queryWidgets(config,scoped,signal,fetchImpl,[binding.entryId]))[binding.entryId];
57
+ return (await queryWidgets({...config,widgets:[binding]},scoped,signal,fetchImpl,[binding.entryId]))[binding.entryId];
42
58
  }
@@ -1 +1,3 @@
1
1
  export function configErrors(config: unknown): string[];
2
+ export function sameDimensionIds(left: string[] | undefined, right: string[] | undefined): boolean;
3
+ export function validateDimensionSelections(widgets: {entryId: string; dimensionIds?: string[]}[], widgetIds: string[], selections: unknown): void;
@@ -1,6 +1,19 @@
1
1
  import {validateFilterBindings} from './filters.js';
2
2
  const nonempty=value=>typeof value==='string'&&value.trim().length>0;
3
3
  const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
4
+ const validDimensionIds=value=>Array.isArray(value)&&value.every(nonempty)&&new Set(value).size===value.length;
5
+ export function sameDimensionIds(left,right) {
6
+ if(left===undefined||right===undefined)return left===right;
7
+ return validDimensionIds(left)&&validDimensionIds(right)&&left.length===right.length&&left.every(id=>right.includes(id));
8
+ }
9
+ export function validateDimensionSelections(widgets,widgetIds,selections) {
10
+ if(selections!==undefined&&(!record(selections)||Object.keys(selections).some(id=>!widgetIds.includes(id))))throw new Error('Invalid dimension selections');
11
+ for(const id of widgetIds) {
12
+ const selected=selections&&Object.hasOwn(selections,id)?selections[id]:undefined;
13
+ if(selected!==undefined&&!validDimensionIds(selected))throw new Error('Invalid dimensionIds');
14
+ if(!widgets.some(w=>w.entryId===id&&sameDimensionIds(w.dimensionIds,selected)))throw new Error('Unconfigured dimension selection');
15
+ }
16
+ }
4
17
  export function configErrors(config) {
5
18
  if(!record(config))return ['configuration must be a JSON object.'];
6
19
  const errors=[];
@@ -21,9 +34,10 @@ export function configErrors(config) {
21
34
  if(!Array.isArray(config.widgets)||!config.widgets.length)return [...errors,'widgets must be a non-empty array.'];
22
35
  for(const [i,w] of config.widgets.entries()) {
23
36
  if(!record(w)){errors.push(`widgets[${i}] must be an object.`);continue;}
24
- const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone'];
37
+ const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone','dimensionIds'];
25
38
  for(const field of Object.keys(w))if(!fields.includes(field))errors.push(`widgets[${i}].${field} is unsupported.`);
26
39
  for(const field of ['entryId','name'])if(!nonempty(w[field]))errors.push(`widgets[${i}].${field} is required.`);
40
+ if('dimensionIds' in w&&!validDimensionIds(w.dimensionIds))errors.push(`widgets[${i}].dimensionIds must be unique non-empty strings.`);
27
41
  if(!['table','kpi','bar','line','pie'].includes(w.type))errors.push(`widgets[${i}].type is unsupported.`);
28
42
  if('valueFormat' in w&&(w.type!=='kpi'||!['number','percent'].includes(w.valueFormat)))errors.push(`widgets[${i}].valueFormat requires a KPI and number or percent.`);
29
43
  if('categoryFormat' in w||'categoryTimeZone' in w) {