@finebi/finebi-cli 1.6.0 → 1.6.2

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 (160) hide show
  1. package/README.md +16 -12
  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 +31 -2
  7. package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
  8. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
  9. package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
  10. package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
  11. package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
  12. package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
  13. package/skills/finebi-cli-agent/references/web-app.md +12 -6
  14. package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +157 -189
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +28 -28
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +65 -61
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +23 -7
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +19 -6
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +19 -4
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +48 -3
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +43 -2
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +6 -2
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -0
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2 -2
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +3 -2
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +26 -24
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -31
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -31
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +12 -5
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +16 -10
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -0
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -0
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -0
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -0
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -28
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -0
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -0
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -0
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -0
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -0
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +32 -11
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -0
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -0
  60. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  61. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  62. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  63. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  64. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
  65. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
  66. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  67. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +13 -0
  68. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -0
  69. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -0
  70. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  71. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -0
  72. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -0
  73. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -0
  74. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -0
  75. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -0
  76. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -0
  77. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -0
  78. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -0
  79. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  80. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +31 -4
  81. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -0
  82. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +8 -1
  83. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  84. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  85. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  86. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  87. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +11 -4
  88. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  89. package/src/app.js +157 -189
  90. package/src/browser-auth.js +3 -4
  91. package/src/config.js +28 -28
  92. package/src/error-catalog.js +2 -2
  93. package/src/help.js +65 -61
  94. package/src/install-skill.js +89 -89
  95. package/src/profiles.js +158 -158
  96. package/src/skill-files.js +94 -94
  97. package/templates/web-app/AGENTS.md +23 -7
  98. package/templates/web-app/README.md +19 -6
  99. package/templates/web-app/docs/acceptance.md +80 -0
  100. package/templates/web-app/docs/auth-and-deploy.md +19 -4
  101. package/templates/web-app/docs/data-contract.md +48 -3
  102. package/templates/web-app/docs/design.md +43 -2
  103. package/templates/web-app/docs/filters.md +6 -2
  104. package/templates/web-app/examples/cockpit.config.json +22 -0
  105. package/templates/web-app/examples/retail.config.json +185 -0
  106. package/templates/web-app/index.html +2 -2
  107. package/templates/web-app/package-lock.json +2 -2
  108. package/templates/web-app/package.json +3 -2
  109. package/templates/web-app/public/app-config.json +11 -11
  110. package/templates/web-app/scripts/browser-session.mjs +59 -0
  111. package/templates/web-app/scripts/dev-proxy.mjs +26 -24
  112. package/templates/web-app/scripts/dev-session.mjs +57 -0
  113. package/templates/web-app/scripts/session-file.mjs +45 -0
  114. package/templates/web-app/src/App.tsx +63 -31
  115. package/templates/web-app/src/cockpit.css +3 -0
  116. package/templates/web-app/src/components/Chart.tsx +40 -31
  117. package/templates/web-app/src/components/DataTable.tsx +12 -5
  118. package/templates/web-app/src/components/FilterBar.tsx +16 -10
  119. package/templates/web-app/src/components/Insights.tsx +17 -0
  120. package/templates/web-app/src/components/MonthPicker.tsx +32 -0
  121. package/templates/web-app/src/components/OptionFilter.tsx +7 -0
  122. package/templates/web-app/src/components/RangeFilter.tsx +9 -0
  123. package/templates/web-app/src/components/Widget.tsx +40 -28
  124. package/templates/web-app/src/components/calendar-locales.ts +3 -0
  125. package/templates/web-app/src/components/calendar.ts +14 -14
  126. package/templates/web-app/src/components/charts/Bar.tsx +6 -0
  127. package/templates/web-app/src/components/charts/Line.tsx +6 -0
  128. package/templates/web-app/src/components/charts/Pie.tsx +6 -0
  129. package/templates/web-app/src/components/month-picker.css +1 -0
  130. package/templates/web-app/src/finebi/client.ts +32 -11
  131. package/templates/web-app/src/finebi/config.d.ts +3 -0
  132. package/templates/web-app/src/finebi/config.js +58 -0
  133. package/templates/web-app/src/finebi/date.d.ts +1 -1
  134. package/templates/web-app/src/finebi/date.js +5 -5
  135. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  136. package/templates/web-app/src/finebi/filters.js +106 -106
  137. package/templates/web-app/src/finebi/normalize.ts +235 -77
  138. package/templates/web-app/src/finebi/types.ts +20 -18
  139. package/templates/web-app/src/main.tsx +5 -5
  140. package/templates/web-app/src/retail.css +13 -0
  141. package/templates/web-app/src/services/presentation.ts +36 -0
  142. package/templates/web-app/src/services/table-data.ts +47 -0
  143. package/templates/web-app/src/services/view-data.ts +18 -18
  144. package/templates/web-app/src/services/widget-queries.ts +18 -0
  145. package/templates/web-app/tests/browser/fixtures.mjs +28 -0
  146. package/templates/web-app/tests/browser/index.html +16 -0
  147. package/templates/web-app/tests/browser/main.tsx +149 -0
  148. package/templates/web-app/tests/browser/test-page.css +4 -0
  149. package/templates/web-app/tests/browser/vite.config.mjs +13 -0
  150. package/templates/web-app/tests/bundle.test.mjs +19 -0
  151. package/templates/web-app/tests/cockpit.test.mjs +73 -0
  152. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  153. package/templates/web-app/tests/proxy.test.mjs +31 -4
  154. package/templates/web-app/tests/retail.test.mjs +31 -0
  155. package/templates/web-app/tests/runtime.test.mjs +8 -1
  156. package/templates/web-app/tests/session.test.mjs +91 -0
  157. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  158. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  159. package/templates/web-app/tsconfig.json +4 -4
  160. package/templates/web-app/vite.config.ts +11 -4
@@ -1,77 +1,235 @@
1
- import type {Cell, Column, Dataset, QueryResult} from './types';
2
-
3
- // Native preview JSON is deliberately confined to this boundary.
4
- type Native = Record<string, any>;
5
- const unavailable = (reason: string, display: string | null = null): Cell => ({value: null, display, state: 'UNAVAILABLE', reason});
6
- const text = (value: unknown): string | null => typeof value === 'string' ? value : null;
7
- const column = (id: string, name: string, index: number, type: Column['type']): Column => ({key: `c${index}`, fieldId: id, name, type, encoding: ({STRING: 'STRING', DECIMAL: 'DECIMAL_STRING', DATE: 'EPOCH_MILLIS_STRING', UNKNOWN: null})[type]});
8
- const warn = (r: QueryResult, message: string) => {if (!r.warnings.includes(message)) r.warnings.push(message);};
9
- const partial = (r: QueryResult, message: string) => {r.normalization = 'PARTIAL'; warn(r, message);};
10
-
11
- function value(raw: unknown, display: string | null, authority: unknown, type: Column['type']): Cell {
12
- if (authority === false) return {value: null, display, state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
13
- if (authority !== true) return unavailable('AUTHORITY_UNAVAILABLE', display);
14
- if (raw == null) return display === null ? {value: null, display: null, state: 'NULL', reason: null} : unavailable('DISPLAY_ONLY', display);
15
- if (type === 'STRING' && typeof raw === 'string') return {value: raw, display, state: 'AVAILABLE', reason: null};
16
- if (type === 'DATE') {
17
- const s = String(raw);
18
- if ((typeof raw === 'string' || typeof raw === 'number' && Number.isSafeInteger(raw)) && /^[+-]?\d+$/.test(s)) {
19
- const n = BigInt(s);
20
- if (n >= -9223372036854775808n && n <= 9223372036854775807n) return {value: s, display, state: 'AVAILABLE', reason: null};
21
- }
22
- return unavailable('INVALID_TIMESTAMP_VALUE', display);
23
- }
24
- if (type === 'DECIMAL' && (typeof raw === 'string' || typeof raw === 'number')) {
25
- if (typeof raw === 'number' && (!Number.isFinite(raw) || Number.isInteger(raw) && !Number.isSafeInteger(raw))) return unavailable('UNSAFE_NUMERIC_VALUE', display);
26
- if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(raw))) return {value: String(raw), display, state: 'AVAILABLE', reason: null};
27
- }
28
- return unavailable(type === 'UNKNOWN' ? 'TYPE_UNAVAILABLE' : 'DISPLAY_ONLY', display);
29
- }
30
-
31
- export function normalize(source: Native): QueryResult {
32
- const p = source?.previewResult;
33
- const r: QueryResult = {schemaVersion: '1', resultType: 'UNKNOWN', normalization: 'COMPLETE', datasets: [], warnings: [], range: {mode: 'UNKNOWN', page: null, pageSize: null, pageUnit: null, hasNext: null, hasPrevious: null, total: {value: null, unit: null}}};
34
- if (p?.resultType === 'DETAIL' && Array.isArray(p.header) && Array.isArray(p.items)) {
35
- r.resultType = 'DETAIL_TABLE';
36
- r.range = {mode: 'PAGE', page: 1, pageSize: Number.isSafeInteger(p.size) ? p.size : null, pageUnit: 'ROW', hasNext: typeof p.hasNext === 'boolean' ? p.hasNext : null, hasPrevious: typeof p.hasPrev === 'boolean' ? p.hasPrev : null, total: {value: Number.isSafeInteger(p.row) && p.row >= 0 ? p.row : null, unit: 'DETAIL_ROW'}};
37
- const dataset: Dataset = {id: 'main', columns: p.header.map((h: Native, i: number) => column(h.dId, h.text, i, detailType(source.widgetBean?.dimensions?.[h.dId]?.type))), rows: [], returnedRowCount: p.items.length};
38
- for (const row of p.items) {
39
- if (!Array.isArray(row) || row.length !== dataset.columns.length) partial(r, 'ROW_WIDTH_MISMATCH');
40
- dataset.rows.push({kind: 'DATA', cells: dataset.columns.map((c, i) => {
41
- const cell = row?.[i];
42
- if (!cell || typeof cell !== 'object' || cell.dId && cell.dId !== c.fieldId) {partial(r, 'ROW_FIELD_MISMATCH'); return unavailable('MISSING_CELL');}
43
- return value(cell.value, text(cell.formatText) ?? text(cell.text), cell.authority, c.type);
44
- })});
45
- }
46
- r.datasets.push(dataset);
47
- if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
48
- if (r.range.total.value !== null && dataset.rows.length < r.range.total.value) warn(r, 'RESULT_TRUNCATED');
49
- } else if (p?.resultType === 'CHART') {
50
- r.resultType = 'CHART'; r.range.mode = 'UNPAGED';
51
- const models: Native[] = [];
52
- for (const geom of p.geoms || []) {
53
- if (geom?.dataModel) models.push(geom.dataModel); else partial(r, 'GEOMETRY_WITHOUT_INLINE_DATA');
54
- }
55
- if (p.shared?.data) models.push(p.shared.data);
56
- if (p.computedState) partial(r, 'CHART_COMPUTED_STATE_NOT_NORMALIZED');
57
- for (const model of new Set(models)) {
58
- if (!Array.isArray(model.fields) || !Array.isArray(model.colData) || !Number.isSafeInteger(model.rowCount) || model.rowCount < 0) {partial(r, 'INVALID_CHART_MODEL'); continue;}
59
- const dataset: Dataset = {id: `model${r.datasets.length}`, columns: model.fields.map((f: Native, i: number) => column(f.id, f.name, i, f.fieldType === 'MEASURE' ? 'DECIMAL' : f.fieldType === 'DIMENSION' ? 'STRING' : 'UNKNOWN')), rows: [], returnedRowCount: model.rowCount};
60
- if (model.colData.length !== model.fields.length || model.colData.some((c: unknown[]) => !Array.isArray(c) || c.length !== model.rowCount)) partial(r, 'ROW_WIDTH_MISMATCH');
61
- for (let i = 0; i < model.rowCount; i++) dataset.rows.push({kind: 'UNKNOWN', cells: dataset.columns.map((c, j) => {
62
- if (!Array.isArray(model.colData[j]) || i >= model.colData[j].length) return unavailable('MISSING_CELL');
63
- const raw = model.colData[j][i];
64
- if (raw === '***') return {value: null, display: '***', state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
65
- if (c.type === 'DECIMAL' && typeof raw === 'string') return unavailable('CHART_VALUE_SEMANTICS_UNKNOWN');
66
- return value(raw, c.type === 'STRING' ? text(raw) : null, true, c.type);
67
- })});
68
- r.datasets.push(dataset);
69
- }
70
- if (!r.datasets.length) {r.normalization = 'UNSUPPORTED'; warn(r, 'NO_INLINE_CHART_DATA');}
71
- } else {r.normalization = 'UNSUPPORTED'; warn(r, 'RESULT_TYPE_UNSUPPORTED');}
72
- return r;
73
- }
74
-
75
- function detailType(type: number): Column['type'] {
76
- return type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : [3,13,15,20].includes(type) ? 'DATE' : 'UNKNOWN';
77
- }
1
+ import type {AxisLabel, Cell, Column, Dataset, QueryResult, RowKind} from './types';
2
+
3
+ // Native preview JSON is deliberately confined to this boundary.
4
+ type Native = Record<string, any>;
5
+ const unavailable = (reason: string, display: string | null = null): Cell => ({value: null, display, state: 'UNAVAILABLE', reason});
6
+ const text = (value: unknown): string | null => typeof value === 'string' ? value : null;
7
+ const column = (id: string, name: string, index: number, type: Column['type']): Column => ({key: `c${index}`, fieldId: id, name, type, encoding: ({STRING: 'STRING', DECIMAL: 'DECIMAL_STRING', DATE: 'EPOCH_MILLIS_STRING', UNKNOWN: null})[type]});
8
+ const warn = (r: QueryResult, message: string) => {if (!r.warnings.includes(message)) r.warnings.push(message);};
9
+ const partial = (r: QueryResult, message: string) => {r.normalization = 'PARTIAL'; warn(r, message);};
10
+
11
+ function value(raw: unknown, display: string | null, authority: unknown, type: Column['type']): Cell {
12
+ if (authority === false) return {value: null, display, state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
13
+ if (authority !== true) return unavailable('AUTHORITY_UNAVAILABLE', display);
14
+ if (raw == null) return display === null ? {value: null, display: null, state: 'NULL', reason: null} : unavailable('DISPLAY_ONLY', display);
15
+ if (type === 'STRING' && typeof raw === 'string') return {value: raw, display, state: 'AVAILABLE', reason: null};
16
+ if (type === 'DATE') {
17
+ const s = String(raw);
18
+ if ((typeof raw === 'string' || typeof raw === 'number' && Number.isSafeInteger(raw)) && /^[+-]?\d+$/.test(s)) {
19
+ const n = BigInt(s);
20
+ if (n >= -9223372036854775808n && n <= 9223372036854775807n) return {value: s, display, state: 'AVAILABLE', reason: null};
21
+ }
22
+ return unavailable('INVALID_TIMESTAMP_VALUE', display);
23
+ }
24
+ if (type === 'DECIMAL' && (typeof raw === 'string' || typeof raw === 'number')) {
25
+ if (typeof raw === 'number' && (!Number.isFinite(raw) || Number.isInteger(raw) && !Number.isSafeInteger(raw))) return unavailable('UNSAFE_NUMERIC_VALUE', display);
26
+ if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(raw))) return {value: String(raw), display, state: 'AVAILABLE', reason: null};
27
+ }
28
+ return unavailable(type === 'UNKNOWN' ? 'TYPE_UNAVAILABLE' : 'DISPLAY_ONLY', display);
29
+ }
30
+
31
+ export function normalize(source: Native): QueryResult {
32
+ const p = source?.previewResult;
33
+ const r: QueryResult = {schemaVersion: '1', resultType: 'UNKNOWN', normalization: 'COMPLETE', datasets: [], warnings: [], range: {mode: 'UNKNOWN', page: null, pageSize: null, pageUnit: null, hasNext: null, hasPrevious: null, total: {value: null, unit: null}}};
34
+ if (p?.resultType === 'DETAIL' && Array.isArray(p.header) && Array.isArray(p.items)) {
35
+ r.resultType = 'DETAIL_TABLE';
36
+ r.range = {mode: 'PAGE', page: 1, pageSize: Number.isSafeInteger(p.size) ? p.size : null, pageUnit: 'ROW', hasNext: typeof p.hasNext === 'boolean' ? p.hasNext : null, hasPrevious: typeof p.hasPrev === 'boolean' ? p.hasPrev : null, total: {value: Number.isSafeInteger(p.row) && p.row >= 0 ? p.row : null, unit: 'DETAIL_ROW'}};
37
+ const dataset: Dataset = {id: 'main', columns: p.header.map((h: Native, i: number) => column(h.dId, h.text, i, detailType(source.widgetBean?.dimensions?.[h.dId]?.type))), rows: [], returnedRowCount: p.items.length};
38
+ for (const row of p.items) {
39
+ if (!Array.isArray(row) || row.length !== dataset.columns.length) partial(r, 'ROW_WIDTH_MISMATCH');
40
+ dataset.rows.push({kind: 'DATA', cells: dataset.columns.map((c, i) => {
41
+ const cell = row?.[i];
42
+ if (!cell || typeof cell !== 'object' || cell.dId && cell.dId !== c.fieldId) {partial(r, 'ROW_FIELD_MISMATCH'); return unavailable('MISSING_CELL');}
43
+ return value(cell.value, text(cell.formatText) ?? text(cell.text), cell.authority, c.type);
44
+ })});
45
+ }
46
+ r.datasets.push(dataset);
47
+ if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
48
+ if (r.range.total.value !== null && dataset.rows.length < r.range.total.value) warn(r, 'RESULT_TRUNCATED');
49
+ } else if (p?.resultType === 'BIGROUP') {
50
+ r.resultType = 'GROUP_TABLE';
51
+ if (!Array.isArray(p.header) || !Array.isArray(p.items)) {
52
+ r.normalization = 'UNSUPPORTED'; warn(r, 'INVALID_GROUP_TABLE'); return r;
53
+ }
54
+ const style = source.widgetBean?.settings?.tableStyle;
55
+ if (style?.showRowHeaderIcon === true) r.range.mode = 'INITIAL_VIEW';
56
+ else r.range = {mode: 'PAGE', page: 1, pageSize: Number.isSafeInteger(style?.rowCounts) && style.rowCounts > 0 ? style.rowCounts : null, pageUnit: 'GROUP', hasNext: typeof p.hasNext === 'boolean' ? p.hasNext : null, hasPrevious: typeof p.hasPrev === 'boolean' ? p.hasPrev : null, total: {value: null, unit: 'GROUP'}};
57
+ r.datasets.push(groupDataset(p, source.widgetBean, r));
58
+ if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
59
+ } else if (p?.resultType === 'BICROSS') {
60
+ r.resultType = 'CROSS_TABLE'; r.range.mode = 'INITIAL_VIEW';
61
+ const flag = (v: unknown) => typeof v === 'boolean' ? v : null;
62
+ const either = (a: boolean | null, b: boolean | null) => a === true || b === true ? true : a === false && b === false ? false : null;
63
+ const horizontal = {hasNext: flag(p.hasXNext), hasPrevious: flag(p.hasXPrev)};
64
+ const vertical = {hasNext: flag(p.hasYNext), hasPrevious: flag(p.hasYPrev)};
65
+ r.range.axes = {horizontal, vertical};
66
+ r.range.hasNext = either(horizontal.hasNext, vertical.hasNext);
67
+ r.range.hasPrevious = either(horizontal.hasPrevious, vertical.hasPrevious);
68
+ if (r.range.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
69
+ const dataset = crossDataset(p, r);
70
+ if (dataset) r.datasets.push(dataset);
71
+ } else if (p?.resultType === 'CHART') {
72
+ r.resultType = 'CHART'; r.range.mode = 'UNPAGED';
73
+ const models: Native[] = [];
74
+ for (const geom of p.geoms || []) {
75
+ if (geom?.dataModel) models.push(geom.dataModel); else partial(r, 'GEOMETRY_WITHOUT_INLINE_DATA');
76
+ }
77
+ if (p.shared?.data) models.push(p.shared.data);
78
+ if (p.computedState) partial(r, 'CHART_COMPUTED_STATE_NOT_NORMALIZED');
79
+ for (const model of new Set(models)) {
80
+ if (!Array.isArray(model.fields) || !Array.isArray(model.colData) || !Number.isSafeInteger(model.rowCount) || model.rowCount < 0) {partial(r, 'INVALID_CHART_MODEL'); continue;}
81
+ const dataset: Dataset = {id: `model${r.datasets.length}`, columns: model.fields.map((f: Native, i: number) => column(f.id, f.name, i, f.fieldType === 'MEASURE' ? 'DECIMAL' : f.fieldType === 'DIMENSION' ? 'STRING' : 'UNKNOWN')), rows: [], returnedRowCount: model.rowCount};
82
+ if (model.colData.length !== model.fields.length || model.colData.some((c: unknown[]) => !Array.isArray(c) || c.length !== model.rowCount)) partial(r, 'ROW_WIDTH_MISMATCH');
83
+ for (let i = 0; i < model.rowCount; i++) dataset.rows.push({kind: 'UNKNOWN', cells: dataset.columns.map((c, j) => {
84
+ if (!Array.isArray(model.colData[j]) || i >= model.colData[j].length) return unavailable('MISSING_CELL');
85
+ const raw = model.colData[j][i];
86
+ if (raw === '***') return {value: null, display: '***', state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
87
+ if (c.type === 'DECIMAL' && typeof raw === 'string') return unavailable('CHART_VALUE_SEMANTICS_UNKNOWN');
88
+ return value(raw, c.type === 'STRING' ? text(raw) : null, true, c.type);
89
+ })});
90
+ r.datasets.push(dataset);
91
+ }
92
+ if (!r.datasets.length) {r.normalization = 'UNSUPPORTED'; warn(r, 'NO_INLINE_CHART_DATA');}
93
+ } else {r.normalization = 'UNSUPPORTED'; warn(r, 'RESULT_TYPE_UNSUPPORTED');}
94
+ return r;
95
+ }
96
+
97
+ function detailType(type: number): Column['type'] {
98
+ return type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : [3,13,15,20].includes(type) ? 'DATE' : 'UNKNOWN';
99
+ }
100
+
101
+ function rowKind(values: Native[], axis = 'rowSumInfo'): RowKind {
102
+ const kinds = values.map(metric => {
103
+ const info = metric?.[axis];
104
+ if (typeof info?.totalSum !== 'boolean' || typeof info?.innerSum !== 'boolean' || info.totalSum && info.innerSum) return 'UNKNOWN';
105
+ return info.totalSum ? 'GRAND_TOTAL' : info.innerSum ? 'SUBTOTAL' : 'DATA';
106
+ });
107
+ return kinds.length && kinds.every(kind => kind === kinds[0]) ? kinds[0] : 'UNKNOWN';
108
+ }
109
+
110
+ // Native top leaves and left value arrays share the converter's slot order.
111
+ // Field IDs repeat across cross columns and must never be used to join cells.
112
+ function crossDataset(preview: Native, result: QueryResult): Dataset | null {
113
+ const reject = (reason: string) => {result.normalization = 'UNSUPPORTED'; warn(result, reason); return null;};
114
+ const record = (v: unknown): v is Native => !!v && typeof v === 'object' && !Array.isArray(v);
115
+ if (![preview.top, preview.left].every(axis => record(axis) && Array.isArray(axis.header) && Array.isArray(axis.items))) return reject('INVALID_CROSS_TABLE');
116
+ type Leaf = {path: AxisLabel[]; values: Native[]; restricted: boolean; unknownAuthority: boolean};
117
+ let invalid = false;
118
+ const leaves = (items: Native[]): Leaf[] => {
119
+ const output: Leaf[] = [];
120
+ const visit = (item: Native, parents: AxisLabel[], restricted: boolean, unknownAuthority: boolean) => {
121
+ if (!record(item) || item.children != null && !Array.isArray(item.children) || item.values != null && !Array.isArray(item.values)) {invalid = true; return;}
122
+ const children: Native[] = item.children ?? [], values: Native[] = item.values ?? [];
123
+ if (children.length && values.length || values.some(v => !record(v))) {invalid = true; return;}
124
+ restricted ||= item.authority === false;
125
+ const label = text(item.formatText) ?? text(item.text);
126
+ unknownAuthority ||= label !== null && item.authority !== true && item.authority !== false;
127
+ const path = [...parents];
128
+ if (label !== null) path.push({fieldId: text(item.dId), label: restricted ? '受限' : unknownAuthority ? '不可用' : label, state: restricted ? 'RESTRICTED' : unknownAuthority ? 'UNAVAILABLE' : 'AVAILABLE'});
129
+ if (children.length) for (const child of children) visit(child, path, restricted, unknownAuthority);
130
+ else if (values.length || item.dId != null) output.push({path, values, restricted, unknownAuthority});
131
+ };
132
+ for (const item of items) visit(item, [], false, false);
133
+ return output;
134
+ };
135
+ const top = leaves(preview.top.items), left = leaves(preview.left.items);
136
+ if (invalid) return reject('CROSS_TABLE_TOPOLOGY_UNSUPPORTED');
137
+ const slots = top.flatMap(leaf => (leaf.values.length ? leaf.values : [{}]).map(metric => {
138
+ const path = [...leaf.path], label = text(metric.formatText) ?? text(metric.text);
139
+ const restricted = leaf.restricted || metric.authority === false;
140
+ const unknownAuthority = leaf.unknownAuthority || label !== null && metric.authority !== true && metric.authority !== false;
141
+ if (label !== null && (path.at(-1)?.label !== label || path.at(-1)?.fieldId !== metric.dId)) path.push({fieldId: text(metric.dId), label: restricted ? '受限' : unknownAuthority ? '不可用' : label, state: restricted ? 'RESTRICTED' : unknownAuthority ? 'UNAVAILABLE' : 'AVAILABLE'});
142
+ const titleParts = path.map(p => p.label);
143
+ if (path.length > leaf.path.length && titleParts.at(-1) === titleParts.at(-2)) titleParts.pop();
144
+ return {path, title: titleParts.join(' / '), restricted, unknownAuthority, sourceFieldId: text(metric.dId)};
145
+ }));
146
+ // With no column dimensions the platform emits an empty top axis;
147
+ // each left values array still contains the ordered metric slots.
148
+ if (!slots.length && !preview.top.header.length && !preview.top.items.length) {
149
+ const width = Math.max(0, ...left.map(leaf => leaf.values.length));
150
+ for (let index = 0; index < width; index++) slots.push({path: [], title: '', restricted: false, unknownAuthority: false, sourceFieldId: null});
151
+ }
152
+ if (left.some(leaf => leaf.values.length > slots.length)) return reject('CROSS_TABLE_WIDTH_MISMATCH');
153
+ if (!slots.length && left.some(leaf => leaf.values.length)) return reject('CROSS_TABLE_WITHOUT_COLUMNS');
154
+ const dataset: Dataset = {id: 'main', columns: [column('cross:row', '行分组', 0, 'STRING'), ...slots.map((slot, index) => {
155
+ const metrics = left.map(leaf => leaf.values[index]).filter(metric => metric?.dId != null);
156
+ const kind = rowKind(metrics, 'colSumInfo');
157
+ const suffix = kind === 'GRAND_TOTAL' ? '(总计)' : kind === 'SUBTOTAL' ? '(小计)' : '';
158
+ return {...column(`cross:${index}`, (slot.title || `列 ${index + 1}`) + suffix, index + 1, 'DECIMAL'), path: slot.path, kind};
159
+ })], rows: [], returnedRowCount: left.length};
160
+ for (const leaf of left) {
161
+ if (leaf.values.length !== slots.length) partial(result, 'CROSS_TABLE_WIDTH_MISMATCH');
162
+ const kind = rowKind(leaf.values.filter(metric => metric.dId != null));
163
+ const titlePath = leaf.path.filter(p => kind !== 'DATA' || p.fieldId !== '__summary_dim_id__');
164
+ const metricIds = leaf.values.map(metric => text(metric.dId)).filter(id => id !== null);
165
+ const last = titlePath.at(-1);
166
+ if (last?.fieldId && last.label === titlePath.at(-2)?.label
167
+ && metricIds.length && metricIds.every(id => id === last.fieldId)) titlePath.pop();
168
+ const label = titlePath.map(p => p.label).join(' / ') || '—';
169
+ const rowCell: Cell = leaf.restricted ? value(null, '受限', false, 'STRING') : leaf.unknownAuthority ? unavailable('AUTHORITY_UNAVAILABLE') : value(label, label, true, 'STRING');
170
+ const cells = slots.map((slot, index): Cell => {
171
+ const metric = leaf.values[index];
172
+ const meta = {sourceFieldId: text(metric?.dId), columnKind: rowKind(metric ? [metric] : [], 'colSumInfo')};
173
+ if (leaf.restricted || slot.restricted || metric?.authority === false) return {...value(null, '受限', false, 'DECIMAL'), ...meta};
174
+ if (leaf.unknownAuthority || slot.unknownAuthority) return {...unavailable('AUTHORITY_UNAVAILABLE'), ...meta};
175
+ if (!metric) return {...unavailable('MISSING_CELL'), ...meta};
176
+ if (typeof metric.dId !== 'string' || !metric.dId.trim()) return {...unavailable('MISSING_FIELD_ID'), ...meta, sourceFieldId: null};
177
+ if (slot.sourceFieldId && metric.dId && slot.sourceFieldId !== metric.dId) {
178
+ partial(result, 'CROSS_TABLE_METRIC_MISMATCH'); return {...unavailable('AMBIGUOUS_FIELD'), ...meta};
179
+ }
180
+ const raw = metric.value ?? metric.text, display = text(metric.formatText) ?? text(metric.text);
181
+ if (typeof metric.dId === 'string' && metric.dId.trim() && metric.authority === true
182
+ && metric.value === null && metric.text === null && metric.formatText === null) {
183
+ return {...value(null, null, true, 'DECIMAL'), ...meta};
184
+ }
185
+ // Empty metric placeholders have no proven business-null semantics.
186
+ return {...(raw == null ? unavailable('DISPLAY_ONLY', display) : value(raw, display, metric.authority, 'DECIMAL')), ...meta};
187
+ });
188
+ dataset.rows.push({kind, path: leaf.path, cells: [rowCell, ...cells]});
189
+ }
190
+ return dataset;
191
+ }
192
+
193
+ function groupDataset(preview: Native, widget: Native, result: QueryResult): Dataset {
194
+ const list = (items: unknown): Native[] => Array.isArray(items) ? items : [];
195
+ const metrics = new Set<string>();
196
+ const collect = (items: Native[]) => {
197
+ for (const item of items) {
198
+ for (const metric of list(item?.values)) if (typeof metric?.dId === 'string') metrics.add(metric.dId);
199
+ collect(list(item?.children));
200
+ }
201
+ };
202
+ collect(preview.items);
203
+ const dataset: Dataset = {id: 'main', columns: preview.header.map((header: Native, index: number) => {
204
+ const type = widget?.dimensions?.[header?.dId]?.type;
205
+ return column(header?.dId, header?.text, index, metrics.has(header?.dId) ? 'DECIMAL' : type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : 'UNKNOWN');
206
+ }), rows: [], returnedRowCount: 0};
207
+ const ids = new Set(dataset.columns.map(c => c.fieldId));
208
+ const visit = (item: Native, parents: Map<string, Native>) => {
209
+ if (!item || typeof item !== 'object') {partial(result, 'MISSING_GROUP_NODE'); return;}
210
+ const path = new Map(parents);
211
+ if (item.dId != null) path.set(item.dId, item);
212
+ const values = list(item.values), children = list(item.children);
213
+ if (item.values != null && !Array.isArray(item.values) || item.children != null && !Array.isArray(item.children)) partial(result, 'INVALID_GROUP_NODE');
214
+ if (item.dId != null && !ids.has(item.dId) || values.some(metric => !ids.has(metric?.dId))) partial(result, 'UNMAPPED_GROUP_FIELD');
215
+ if (values.length || !children.length) dataset.rows.push({kind: rowKind(values), cells: dataset.columns.map(c => {
216
+ const matching = values.filter(metric => metric?.dId === c.fieldId), dimension = path.get(c.fieldId);
217
+ if (matching.length === 1 && !dimension) {
218
+ const metric = matching[0], display = text(metric.formatText) ?? text(metric.text), raw = metric.value ?? metric.text;
219
+ if (raw == null && metric.authority === true) return unavailable('DISPLAY_ONLY', display);
220
+ return value(raw, display, metric.authority, c.type);
221
+ }
222
+ if (!matching.length && dimension) {
223
+ const display = text(dimension.formatText) ?? text(dimension.text);
224
+ if (dimension.authority === false) return value(null, display, false, c.type);
225
+ return c.type === 'STRING' ? value(dimension.text, display, dimension.authority, c.type) : unavailable('GROUP_KEY_SEMANTICS_UNKNOWN', display);
226
+ }
227
+ if (matching.length) partial(result, 'AMBIGUOUS_FIELD');
228
+ return unavailable(matching.length ? 'AMBIGUOUS_FIELD' : 'MISSING_CELL');
229
+ })});
230
+ for (const child of children) visit(child, path);
231
+ };
232
+ for (const item of preview.items) visit(item, new Map());
233
+ dataset.returnedRowCount = dataset.rows.length;
234
+ return dataset;
235
+ }
@@ -1,18 +1,20 @@
1
- export type Cell = {value: string | null; display: string | null; state: 'AVAILABLE' | 'NULL' | 'UNAVAILABLE' | 'RESTRICTED'; reason: string | null};
2
- export type Column = {key: string; fieldId: string; name: string; type: 'STRING' | 'DECIMAL' | 'DATE' | 'UNKNOWN'; encoding: string | null};
3
- export type Dataset = {id: string; columns: Column[]; rows: {kind: 'DATA' | 'UNKNOWN'; cells: Cell[]}[]; returnedRowCount: number};
4
- export type QueryResult = {
5
- schemaVersion: '1'; resultType: 'DETAIL_TABLE' | 'CHART' | 'UNKNOWN';
6
- normalization: 'COMPLETE' | 'PARTIAL' | 'UNSUPPORTED'; datasets: Dataset[]; warnings: string[];
7
- range: {mode: 'PAGE' | 'UNPAGED' | 'UNKNOWN'; page: number | null; pageSize: number | null; pageUnit: string | null; hasNext: boolean | null; hasPrevious: boolean | null; total: {value: number | null; unit: string | null}};
8
- };
9
- export type WidgetBinding = {entryId: string; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string};
10
- export type FilterBinding = {name: string; label: string} & (
11
- {type: 'date' | 'date-range' | 'year' | 'month' | 'month-range' | 'quarter'; options?: never; allowNull?: never}
12
- | {type: 'text' | 'number'; options?: never; allowNull?: boolean}
13
- | {type?: 'select' | 'multi-select'; options: {label: string; value: string}[]; allowNull?: boolean}
14
- | {type: 'time-label'; options: {label: string; value: string}[]; allowNull?: never}
15
- );
16
- export type FilterValue = string | string[] | {start:string;end:string} | null | undefined;
17
- export type FilterState = Record<string,FilterValue>;
18
- export type AppConfig = {schemaVersion: '1'; title: string; basePath: string; layout: 'overview' | 'workbench'; dashboardId: string; maxRows: number; pageSize: number; widgets: WidgetBinding[]; filters: FilterBinding[]};
1
+ export type Cell = {value: string | null; display: string | null; state: 'AVAILABLE' | 'NULL' | 'UNAVAILABLE' | 'RESTRICTED'; reason: string | null; sourceFieldId?: string | null; columnKind?: RowKind};
2
+ export type AxisLabel = {fieldId: string | null; label: string; state: Cell['state']};
3
+ export type Column = {key: string; fieldId: string; name: string; type: 'STRING' | 'DECIMAL' | 'DATE' | 'UNKNOWN'; encoding: string | null; path?: AxisLabel[]; kind?: RowKind};
4
+ export type RowKind = 'DATA' | 'SUBTOTAL' | 'GRAND_TOTAL' | 'UNKNOWN';
5
+ export type Dataset = {id: string; columns: Column[]; rows: {kind: RowKind; cells: Cell[]; path?: AxisLabel[]}[]; returnedRowCount: number};
6
+ export type QueryResult = {
7
+ schemaVersion: '1'; resultType: 'DETAIL_TABLE' | 'GROUP_TABLE' | 'CROSS_TABLE' | 'CHART' | 'UNKNOWN';
8
+ normalization: 'COMPLETE' | 'PARTIAL' | 'UNSUPPORTED'; datasets: Dataset[]; warnings: string[];
9
+ range: {mode: 'PAGE' | 'INITIAL_VIEW' | 'UNPAGED' | 'UNKNOWN'; page: number | null; pageSize: number | null; pageUnit: string | null; hasNext: boolean | null; hasPrevious: boolean | null; total: {value: number | null; unit: string | null}; axes?: {horizontal: {hasNext: boolean | null; hasPrevious: boolean | null}; vertical: {hasNext: boolean | null; hasPrevious: boolean | null}}};
10
+ };
11
+ export type WidgetBinding = {entryId: string; dimensionIds?: string[]; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string; series?: {fieldId:string;label:string}[]; description?:string; filterNames?:string[]; tableFieldIds?:string[]; localFilterFieldIds?:string[]; valueFormat?:'number'|'percent'; categoryFormat?:'epoch-month'; categoryTimeZone?:string};
12
+ export type FilterBinding = {name: string; label: string} & (
13
+ {type: 'date' | 'date-range' | 'year' | 'month' | 'month-range' | 'quarter'; options?: never; allowNull?: never}
14
+ | {type: 'text' | 'number'; options?: never; allowNull?: boolean}
15
+ | {type?: 'select' | 'multi-select'; options: {label: string; value: string}[]; allowNull?: boolean}
16
+ | {type: 'time-label'; options: {label: string; value: string}[]; allowNull?: never}
17
+ );
18
+ export type FilterValue = string | string[] | {start:string;end:string} | null | undefined;
19
+ export type FilterState = Record<string,FilterValue>;
20
+ export type AppConfig = {schemaVersion: '1'; title: string; description?:string; basePath: string; layout: 'overview' | 'workbench' | 'cockpit' | 'retail'; dashboardId: string; directoryEntryId?:string; insights?:boolean; dataNotes?:string[]; maxRows: number; pageSize: number; widgets: WidgetBinding[]; filters: FilterBinding[]};
@@ -1,5 +1,5 @@
1
- import React from 'react';
2
- import {createRoot} from 'react-dom/client';
3
- import App from './App';
4
- import './styles.css';
5
- createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>);
1
+ import React from 'react';
2
+ import {createRoot} from 'react-dom/client';
3
+ import App from './App';
4
+ import './styles.css';
5
+ createRoot(document.getElementById('root')!).render(<React.StrictMode><App/></React.StrictMode>);
@@ -0,0 +1,13 @@
1
+ .retail{--chart-1:#3158d5;--chart-2:#e18047;--chart-3:#82a7a0;--chart-4:#9b8fc6;--chart-5:#d2b977;background:#f5f3ee;color:#24272d;min-height:100vh;font-family:"Segoe UI","Microsoft YaHei",sans-serif}
2
+ .retail main{max-width:1480px;padding:48px 48px 24px}.retail a{color:#3158d5}.retail button,.retail select,.retail input{border-radius:5px}.retail :focus-visible{outline:3px solid #e18047;outline-offset:3px}
3
+ .retail-nav{position:sticky;top:0;z-index:30}.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:100px}
4
+ .retail-nav{height:78px;padding:0 48px;border-bottom:1px solid #dcd9d1;display:flex;align-items:center;justify-content:space-between;gap:30px;background:#faf9f6}.retail-nav a{text-decoration:none;color:#40434b}.retail-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:17px;white-space:nowrap}.retail-brand b{display:grid;place-items:center;background:#24272d;color:#faf9f6;font-family:Georgia,serif;font-size:30px;width:39px;height:39px;border-radius:4px}.retail-brand small{font-size:12px;font-weight:400;border-left:1px solid #bbb7ad;margin-left:10px;padding-left:12px}.retail-nav>div{display:flex;gap:36px;font-size:13px}.retail-nav>div a[aria-current="location"]{color:#3158d5;font-weight:650}.retail-live{font-size:12px;white-space:nowrap}.retail-live:before{content:"";display:inline-block;width:6px;height:6px;background:#3158d5;border-radius:50%;margin-right:8px}
5
+ .retail header{align-items:flex-end;margin-bottom:30px;gap:30px}.retail .eyebrow{font-size:10px;letter-spacing:2.5px;color:#6d706e}.retail h1{font-size:36px;letter-spacing:-1.2px;font-weight:650;margin:12px 0}.retail header .muted{font-size:13px;max-width:750px;line-height:1.9}.retail .muted{color:#75776f}.retail .header-actions{display:flex;align-items:center;gap:20px;white-space:nowrap;font-size:12px}.retail .header-actions button{background:#24272d;color:#fff;border-color:#24272d;padding:11px 18px}.retail .header-actions a{color:#656863;text-decoration:none}
6
+ .retail .filter-card{border-radius:8px;border-color:#dedbd3;background:#faf9f6;box-shadow:none;padding:20px 24px;margin-bottom:8px}.retail .filter-heading{margin-bottom:14px}.retail .filter-heading p{display:none}.retail .filter-symbol{height:28px;width:28px;background:#eaeefa;border-radius:5px;color:#3158d5}.retail .filter-heading h2{font-size:13px;color:#424740}.retail .filter-fields{gap:14px}.retail .filter-fields:has(>.filter-field:only-child){max-width:760px}.retail .filter-label-row{margin-bottom:5px}.retail .filter-label-row label{color:#575d65}.retail .filter-bottom{margin-top:9px;padding-top:12px;border-color:#e3e0d8}.retail .filter-scope{font-size:11px;color:#797d7e;line-height:1.8;margin:4px 0}.retail .field-hint{min-height:0;margin-top:6px}.retail .filter-status{background:#e9edf9;color:#3158d5}.retail .filter-status.pending{background:#fff0db;color:#855316}.retail .query-status{font-size:11px;color:#75776f;text-align:right;margin:12px 0 28px}
7
+ .retail .section-heading{display:flex;align-items:baseline;gap:16px;margin:35px 0 18px}.retail .section-heading>span{font:10px "Segoe UI",sans-serif;letter-spacing:1.5px;color:#85877d}.retail .section-heading h2{font-size:18px;letter-spacing:-.3px;margin:0}.retail .section-heading p{font-size:11px;color:#85877d;margin:0 0 0 auto}
8
+ .retail .kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;border:1px solid #d6d3ca;border-radius:8px;overflow:hidden;background:#fffefb}.retail .kpi-grid .panel{border:0;border-right:1px solid #dedbd3;border-radius:0;background:transparent;padding:23px 25px 18px;display:flex;flex-direction:column;position:relative}.retail .kpi-grid .panel:last-child{border-right:0}.retail .kpi-grid .panel:first-child{background:#eaeefb}.retail .kpi-grid .panel-heading{display:block}.retail .kpi-grid h2{font-size:13px;font-weight:550;margin-bottom:12px}.retail .kpi-grid .component-status{font-size:10px;color:#878b90}.retail .kpi{font-size:clamp(25px,2.45vw,39px);font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-1.5px;color:#252a34;order:1;margin:10px 0 6px}.retail .kpi-grid .panel:first-child .kpi{color:#3158d5}.retail .kpi-grid .muted{order:2;font-size:11px;margin:2px 0;min-height:32px}.retail .kpi-grid .query-details{order:3}.retail .kpi-grid .notice{order:4}
9
+ .retail .grid{gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}.retail .grid .panel{border:1px solid #dedbd3;border-radius:8px;padding:26px;background:#fffefb;box-shadow:none}.retail .panel-heading{display:flex;justify-content:space-between;align-items:baseline;gap:14px}.retail .panel-heading h2{font-size:16px;font-weight:600;letter-spacing:-.3px;margin-bottom:6px}.retail .component-status{color:#898d88;font-size:10px;white-space:nowrap}.retail .component-status.failed{color:#a74632}.retail .grid .muted{font-size:11px;margin:6px 0 10px}.retail .query-details{font-size:10px;color:#91938c;margin:8px 0}.retail .query-details summary{display:inline-block}.retail .notice{font-size:11px;background:#f9f2e4;border-color:#d9ae66;padding:8px 11px;border-radius:2px;color:#857044}.retail .notice details{font-size:10px;margin:2px 0;color:inherit}.retail .series-controls{display:flex;gap:18px;flex-wrap:wrap;font-size:12px;margin:16px 0}.retail .series-controls input{accent-color:#3158d5;margin-right:6px}.retail .chart-sort{font-size:10px;margin:10px 0}.retail .chart-sort select{border-color:#e4e2da;background:#fffefb;font-size:10px;padding:4px 8px}.retail .chart{height:310px}.retail details{font-size:11px}.retail .data-notes{border-top:1px solid #d6d3ca;padding-top:22px;margin-top:30px;line-height:1.9;color:#737870}.retail .data-notes>summary{font-size:13px;color:#404740;font-weight:600}.retail th{background:#f3f2ec;color:#525954;font-size:11px}.retail td{border-color:#ebe9e1;font-size:12px}.retail tbody tr:hover{background:#f6f7fc}.retail .table-tools{font-size:12px;flex-wrap:wrap}.retail .table-tools input{max-width:100%}.retail footer{font-size:10px;color:#8b8d85;text-align:center;letter-spacing:.5px}
10
+ @media(max-width:1000px){.retail main{padding:30px 24px}.retail-nav{padding:0 24px}.retail .kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.retail .kpi-grid .panel{border-bottom:1px solid #dedbd3}.retail .kpi-grid .panel:nth-child(2n){border-right:0}.retail .kpi-grid .panel:nth-last-child(-n+2){border-bottom:0}.retail header{align-items:flex-start}.retail h1{font-size:29px}.retail .header-actions{flex-direction:column;gap:10px;align-items:flex-end}}
11
+ @media(max-width:700px){.retail main{padding:24px 16px}.retail-nav{height:auto;min-height:68px;padding:16px;flex-wrap:wrap;gap:16px}.retail-nav>div{order:3;width:100%;justify-content:space-between;gap:12px;font-size:12px}.retail .retail-brand{font-size:14px}.retail h1{font-size:26px}.retail header{display:block}.retail .header-actions{flex-direction:row;justify-content:flex-end;margin-top:16px}.retail .grid{grid-template-columns:1fr}.retail .grid .panel{padding:20px 16px}.retail .kpi-grid .panel{padding:18px 15px}.retail .kpi{font-size:26px;letter-spacing:-1px}.retail .section-heading{gap:10px;flex-wrap:wrap;margin-top:28px}.retail .section-heading p{width:100%;margin:0}.retail .section-heading>span{font-size:9px}.retail .panel-heading{display:block}.retail .chart{height:285px}.retail .filter-card{padding:18px 16px}.retail .filter-bottom{align-items:stretch;flex-direction:column}.retail .filter-buttons{justify-content:flex-end}.retail .filter-buttons .ant-btn{font-size:12px}.retail .pager{gap:8px;flex-wrap:wrap;font-size:11px}}
12
+ @media(min-width:900px){.retail .filter-card{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:32px;align-items:start}.retail .filter-heading{grid-column:1/-1;margin-bottom:12px}.retail .filter-fields{width:min(100%,640px)}.retail .filter-bottom{border:0;padding:26px 0 0;margin:0;flex-direction:column;align-items:flex-end;gap:8px}.retail .filter-bottom>p{order:2;font-size:10px}.retail .filter-fields:has(>.filter-field:only-child){max-width:640px}}
13
+ @media(max-width:700px){.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:128px}}
@@ -0,0 +1,36 @@
1
+ import type {Cell,WidgetBinding} from '../finebi/types';
2
+ import {displayCell} from './view-data';
3
+
4
+ // Decimal strings stay strings/integers: formatting a large amount must not lose cents.
5
+ export function formatMetric(cell:Cell,format?:WidgetBinding['valueFormat']):string {
6
+ if(!format||cell.state!=='AVAILABLE'||cell.value===null)return displayCell(cell);
7
+ const match=/^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(cell.value);
8
+ if(!match)return displayCell(cell);
9
+ const exponent=Number(match[4]??0),fraction=match[3]??'';
10
+ if(Math.abs(exponent)>100)return displayCell(cell);
11
+ let digits=(match[2]+fraction).replace(/^0+(?=\d)/,'');
12
+ let scale=fraction.length-exponent;
13
+ if(format==='percent') {
14
+ scale-=2;
15
+ const shift=2-scale;
16
+ let amount=BigInt(digits);
17
+ if(shift>=0)amount*=10n**BigInt(shift);
18
+ else {const divisor=10n**BigInt(-shift);amount=(amount+divisor/2n)/divisor;}
19
+ digits=amount.toString();scale=2;
20
+ }
21
+ if(scale<0){digits+='0'.repeat(-scale);scale=0;}
22
+ digits=digits.padStart(scale+1,'0');
23
+ const whole=(scale?digits.slice(0,-scale):digits).replace(/\B(?=(\d{3})+(?!\d))/g,',');
24
+ const tail=scale?digits.slice(-scale):'';
25
+ const decimal=format==='percent'?tail:tail.replace(/0+$/,'');
26
+ const sign=match[1]&&/[1-9]/.test(digits)?'-':'';
27
+ return `${sign}${whole}${decimal?'.'+decimal:''}${format==='percent'?'%':''}`;
28
+ }
29
+
30
+ export function formatCategory(cell:Cell,binding:Pick<WidgetBinding,'categoryFormat'|'categoryTimeZone'>):string {
31
+ if(binding.categoryFormat!=='epoch-month'||!binding.categoryTimeZone||cell.state!=='AVAILABLE'||!cell.value||! /^-?\d+$/.test(cell.value))return displayCell(cell);
32
+ const timestamp=Number(cell.value);
33
+ if(!Number.isSafeInteger(timestamp)||!Number.isFinite(new Date(timestamp).getTime()))return displayCell(cell);
34
+ const parts=new Intl.DateTimeFormat('en',{year:'numeric',month:'2-digit',timeZone:binding.categoryTimeZone}).formatToParts(new Date(timestamp));
35
+ return `${parts.find(p=>p.type==='year')!.value}-${parts.find(p=>p.type==='month')!.value}`;
36
+ }
@@ -0,0 +1,47 @@
1
+ import type {Cell,Dataset,WidgetBinding} from '../finebi/types';
2
+ import {displayCell} from './view-data';
3
+ function decimal(value:string) {
4
+ const match=/^([+-]?)(\d*\.?\d+|\d+\.)(?:e([+-]?\d+))?$/i.exec(value);
5
+ if(!match)throw new Error('无效十进制值');
6
+ const [whole,fraction='']=match[2].split('.');
7
+ const digits=(whole+fraction).replace(/^0+/,'').replace(/0+$/,'');
8
+ const significant=(whole+fraction).replace(/^0+/,'');
9
+ return {sign:!digits?0:match[1]==='-'?-1:1,digits,order:BigInt(significant.length)+BigInt(match[3]||0)-BigInt(fraction.length)};
10
+ }
11
+ export function compareDecimal(a:string,b:string) {
12
+ const x=decimal(a),y=decimal(b);
13
+ if(x.sign!==y.sign)return x.sign<y.sign?-1:1;
14
+ if(x.sign===0)return 0;
15
+ const length=Math.max(x.digits.length,y.digits.length);
16
+ const left=x.digits.padEnd(length,'0'),right=y.digits.padEnd(length,'0');
17
+ const order=x.order!==y.order?(x.order<y.order?-1:1):left===right?0:left<right?-1:1;
18
+ return order===0?0:order*x.sign;
19
+ }
20
+ export function tableDataset(dataset:Dataset,binding:WidgetBinding):Dataset {
21
+ if(!binding.tableFieldIds)return dataset;
22
+ const indices=binding.tableFieldIds.map(id=>fieldIndex(dataset,id));
23
+ return {...dataset,columns:indices.map(i=>dataset.columns[i]),rows:dataset.rows.map(row=>({...row,cells:indices.map(i=>row.cells[i])}))};
24
+ }
25
+ export function fieldIndex(dataset:Dataset,id:string) {
26
+ const index=dataset.columns.findIndex(c=>c.fieldId===id);
27
+ if(index<0)throw new Error(`结果字段绑定缺失:${id}`);
28
+ return index;
29
+ }
30
+ type Options={search:string;sort?:{fieldId:string;direction:'asc'|'desc'};filters:Record<string,string>};
31
+ export function tableRows(dataset:Dataset,{search,sort,filters}:Options) {
32
+ const columns=Object.entries(filters).map(([id,value])=>({index:fieldIndex(dataset,id),value}));
33
+ const rows=dataset.rows.filter(row=>row.cells.some(c=>displayCell(c).toLocaleLowerCase().includes(search.toLocaleLowerCase()))&&columns.every(({index,value})=>!value||displayCell(row.cells[index])===value));
34
+ if(!sort)return rows;
35
+ const index=fieldIndex(dataset,sort.fieldId),numeric=dataset.columns[index].type==='DECIMAL';
36
+ return [...rows].sort((a,b)=>{
37
+ const x=a.cells[index],y=b.cells[index];
38
+ const valid=(c:Cell)=>c.state==='AVAILABLE'&&c.value!==null;
39
+ if(!valid(x)||!valid(y))return valid(x)?-1:valid(y)?1:0;
40
+ const order=numeric?compareDecimal(x.value!,y.value!):displayCell(x).localeCompare(displayCell(y),'zh-CN');
41
+ return sort.direction==='asc'?order:-order;
42
+ });
43
+ }
44
+ export function tableCsv(dataset:Dataset,rows:Dataset['rows']) {
45
+ const escape=(value:string)=>`"${(/^[\s]*[=+\-@]/.test(value)?"'"+value:value).replace(/"/g,'""')}"`;
46
+ return '\uFEFF'+[[...dataset.columns.map(c=>c.name),'行类型'],...rows.map(row=>[...row.cells.map(displayCell),row.kind])].map(row=>row.map(escape).join(',')).join('\r\n');
47
+ }
@@ -1,18 +1,18 @@
1
- import type {Cell, Dataset, QueryResult, WidgetBinding} from '../finebi/types';
2
- export function selectDataset(result: QueryResult, id?: string): Dataset {
3
- if (result.normalization === 'UNSUPPORTED') throw new Error('当前结果类型尚未支持,请使用 FineBI 原生查看页');
4
- const dataset = id ? result.datasets.find(d => d.id === id) : result.datasets.length === 1 ? result.datasets[0] : undefined;
5
- if (!dataset) throw new Error('请绑定唯一的数据模型 datasetId');
6
- return dataset;
7
- }
8
- export const displayCell = (cell: Cell) => cell.state === 'RESTRICTED' ? '受限' : cell.display ?? (cell.state === 'AVAILABLE' ? cell.value : null) ?? (cell.state === 'NULL' ? '—' : '不可用');
9
- export function chartValues(dataset: Dataset, binding: Pick<WidgetBinding, 'categoryFieldId' | 'valueFieldId'>) {
10
- const x = dataset.columns.findIndex(c => c.fieldId === binding.categoryFieldId);
11
- const y = dataset.columns.findIndex(c => c.fieldId === binding.valueFieldId);
12
- if (x < 0 || y < 0 || dataset.columns[y].type !== 'DECIMAL') throw new Error('图表字段绑定缺失或类型不匹配');
13
- return dataset.rows.map(row => {
14
- const cell = row.cells[y];
15
- const n = cell.state === 'AVAILABLE' ? Number(cell.value) : NaN;
16
- return {name: displayCell(row.cells[x]), value: Number.isFinite(n) ? n : null};
17
- });
18
- }
1
+ import type {Cell, Dataset, QueryResult, WidgetBinding} from '../finebi/types';
2
+ export function selectDataset(result: QueryResult, id?: string): Dataset {
3
+ if (result.normalization === 'UNSUPPORTED') throw new Error('当前结果类型尚未支持,请使用 FineBI 原生查看页');
4
+ const dataset = id ? result.datasets.find(d => d.id === id) : result.datasets.length === 1 ? result.datasets[0] : undefined;
5
+ if (!dataset) throw new Error('请绑定唯一的数据模型 datasetId');
6
+ return dataset;
7
+ }
8
+ export const displayCell = (cell: Cell) => cell.state === 'RESTRICTED' ? '受限' : cell.display ?? (cell.state === 'AVAILABLE' ? cell.value : null) ?? (cell.state === 'NULL' ? '—' : '不可用');
9
+ export function chartValues(dataset: Dataset, binding: Pick<WidgetBinding, 'categoryFieldId' | 'valueFieldId'>) {
10
+ const x = dataset.columns.findIndex(c => c.fieldId === binding.categoryFieldId);
11
+ const y = dataset.columns.findIndex(c => c.fieldId === binding.valueFieldId);
12
+ if (x < 0 || y < 0 || dataset.columns[y].type !== 'DECIMAL') throw new Error('图表字段绑定缺失或类型不匹配');
13
+ return dataset.rows.map(row => {
14
+ const cell = row.cells[y];
15
+ const n = cell.state === 'AVAILABLE' ? Number(cell.value) : NaN;
16
+ return {name: displayCell(row.cells[x]), value: Number.isFinite(n) ? n : null};
17
+ });
18
+ }
@@ -0,0 +1,18 @@
1
+ import {queryWidget} from '../finebi/client';
2
+ import type {AppConfig,FilterState,QueryResult,WidgetBinding} from '../finebi/types';
3
+ export type WidgetState={loading:boolean;result?:QueryResult;error?:Error;at?:string};
4
+ export const widgetKey=(binding:WidgetBinding,index:number)=>`${index}:${binding.entryId}`;
5
+ export async function loadWidgets(config:AppConfig,filters:FilterState,signal:AbortSignal,onState:(id:string,state:WidgetState)=>void,fetchImpl:typeof fetch=fetch) {
6
+ const queue=config.widgets.map((binding,index)=>({binding,key:widgetKey(binding,index)}));
7
+ const worker=async()=>{
8
+ while(queue.length&&!signal.aborted) {
9
+ const {binding,key}=queue.shift()!;
10
+ onState(key,{loading:true});
11
+ try {
12
+ const result=await queryWidget(config,binding,filters,signal,fetchImpl);
13
+ if(!signal.aborted)onState(key,{loading:false,result,at:new Date().toLocaleTimeString()});
14
+ } catch(error) {if(!signal.aborted)onState(key,{loading:false,error:error instanceof Error?error:new Error('查询失败')});}
15
+ }
16
+ };
17
+ await Promise.all(Array.from({length:Math.min(3,queue.length)},worker));
18
+ }
@@ -0,0 +1,28 @@
1
+ // Synthetic native responses, confined to the browser acceptance page.
2
+ export function fixtureConfig(layout, chartCount = 8) {
3
+ return {schemaVersion: '1', title: '合成门店经营验证', basePath: '/fixture-decision', layout,
4
+ dashboardId: 'fixture-dashboard', directoryEntryId: 'fixture-directory', maxRows: 1000, pageSize: 8,
5
+ filters: [{name: '区域', label: '区域', type: 'text'}], widgets: [
6
+ ...Array.from({length: 4}, (_, i) => ({entryId: `kpi${i}`, name: i === 3 ? '独立范围指标' : `合成指标 ${i + 1}`,
7
+ type: 'kpi', valueFieldId: 'amount', valueFormat: 'number', filterNames: i === 3 ? [] : ['区域']})),
8
+ ...Array.from({length: chartCount}, (_, i) => ({entryId: `chart${i}`, name: `${['趋势','对比','构成'][i % 3]} ${i + 1} · 较长标题用于检查文字换行和布局边界`,
9
+ type: ['line','bar','pie'][i % 3], categoryFieldId: 'region', valueFieldId: 'amount', filterNames: ['区域']})),
10
+ {entryId: 'table', name: '合成明细', type: 'table', filterNames: ['区域']},
11
+ ]};
12
+ }
13
+
14
+ export function nativeFixture(id, refer, scenario) {
15
+ const filtered = refer['区域']?.[0] === '华东';
16
+ const kpi = id.startsWith('kpi');
17
+ const headers = kpi ? [{dId: 'amount', text: '金额'}] : [{dId: 'region', text: '区域'}, {dId: 'amount', text: '金额'}];
18
+ const cell = (value, authority = true) => ({value, authority});
19
+ let rows = kpi ? [[cell(filtered ? '120' : '260')]]
20
+ : (filtered ? [['华东','120']] : [['华东','120'],['华北','80'],['华南','60']]).map(([region, amount]) => [cell(region), cell(amount)]);
21
+ if (!filtered && id === 'chart0') rows = [[cell('华东'),cell('120')],[cell('华北'),cell(null)],[cell('华南'),cell('-20')]];
22
+ if (!filtered && id === 'table') rows[1][1] = cell('hidden', false);
23
+ if (scenario === 'empty') rows = [];
24
+ const partial = scenario === 'partial' && id === 'chart0';
25
+ if (partial) rows[1] = [cell('华北')];
26
+ return {previewResult: {resultType: 'DETAIL', header: headers, items: rows, row: partial ? 30 : rows.length,
27
+ size: 1000, hasNext: partial}, widgetBean: {dimensions: {region: {type: 1}, amount: {type: 2}}}};
28
+ }
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>FineBI 模板浏览器验收</title></head>
4
+ <body>
5
+ <section id="browser-checks" aria-label="浏览器验收">
6
+ <h1>模板浏览器验收 · 合成数据</h1>
7
+ <p>复用实际页面组件,不连接 FineBI。调整浏览器视口后重新运行;真实身份、业务口径与视觉质量需另外核对。</p>
8
+ <label>测试布局 <select id="test-layout"><option>overview</option><option>workbench</option><option>cockpit</option><option selected>retail</option></select></label>
9
+ <button id="test-run">运行浏览器检查</button>
10
+ <strong id="test-result" role="status">未运行</strong>
11
+ <ol id="test-checks"></ol>
12
+ </section>
13
+ <div id="root"></div>
14
+ <script type="module" src="./main.tsx"></script>
15
+ </body>
16
+ </html>