@finebi/finebi-cli 1.6.1 → 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 (152) 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/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 +4 -2
  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 -148
  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 +63 -60
  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 +18 -5
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -67
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -56
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -39
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -1
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -44
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  60. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
  61. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
  62. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  63. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
  64. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
  65. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
  66. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  67. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
  68. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
  69. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
  70. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
  71. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
  72. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
  73. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
  74. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
  75. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  76. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
  77. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
  78. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
  79. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  80. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  81. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  82. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  83. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
  84. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  85. package/src/app.js +157 -148
  86. package/src/browser-auth.js +3 -4
  87. package/src/config.js +28 -28
  88. package/src/error-catalog.js +2 -2
  89. package/src/help.js +63 -60
  90. package/src/install-skill.js +89 -89
  91. package/src/profiles.js +158 -158
  92. package/src/skill-files.js +94 -94
  93. package/templates/web-app/AGENTS.md +18 -5
  94. package/templates/web-app/README.md +12 -5
  95. package/templates/web-app/docs/acceptance.md +80 -67
  96. package/templates/web-app/docs/auth-and-deploy.md +17 -2
  97. package/templates/web-app/docs/data-contract.md +66 -38
  98. package/templates/web-app/docs/filters.md +2 -2
  99. package/templates/web-app/examples/cockpit.config.json +22 -22
  100. package/templates/web-app/examples/retail.config.json +185 -185
  101. package/templates/web-app/index.html +2 -2
  102. package/templates/web-app/public/app-config.json +11 -11
  103. package/templates/web-app/scripts/browser-session.mjs +59 -0
  104. package/templates/web-app/scripts/dev-proxy.mjs +25 -24
  105. package/templates/web-app/scripts/dev-session.mjs +57 -0
  106. package/templates/web-app/scripts/session-file.mjs +45 -0
  107. package/templates/web-app/src/App.tsx +63 -56
  108. package/templates/web-app/src/cockpit.css +3 -3
  109. package/templates/web-app/src/components/Chart.tsx +40 -40
  110. package/templates/web-app/src/components/DataTable.tsx +1 -1
  111. package/templates/web-app/src/components/Insights.tsx +17 -17
  112. package/templates/web-app/src/components/MonthPicker.tsx +32 -32
  113. package/templates/web-app/src/components/OptionFilter.tsx +7 -7
  114. package/templates/web-app/src/components/RangeFilter.tsx +9 -9
  115. package/templates/web-app/src/components/Widget.tsx +40 -39
  116. package/templates/web-app/src/components/calendar-locales.ts +3 -3
  117. package/templates/web-app/src/components/calendar.ts +14 -14
  118. package/templates/web-app/src/components/charts/Bar.tsx +6 -6
  119. package/templates/web-app/src/components/charts/Line.tsx +6 -6
  120. package/templates/web-app/src/components/charts/Pie.tsx +6 -6
  121. package/templates/web-app/src/components/month-picker.css +1 -1
  122. package/templates/web-app/src/finebi/client.ts +20 -4
  123. package/templates/web-app/src/finebi/config.d.ts +3 -1
  124. package/templates/web-app/src/finebi/config.js +58 -44
  125. package/templates/web-app/src/finebi/date.d.ts +1 -1
  126. package/templates/web-app/src/finebi/date.js +5 -5
  127. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  128. package/templates/web-app/src/finebi/filters.js +106 -106
  129. package/templates/web-app/src/finebi/normalize.ts +235 -77
  130. package/templates/web-app/src/finebi/types.ts +20 -18
  131. package/templates/web-app/src/main.tsx +5 -5
  132. package/templates/web-app/src/retail.css +12 -12
  133. package/templates/web-app/src/services/presentation.ts +36 -36
  134. package/templates/web-app/src/services/table-data.ts +47 -47
  135. package/templates/web-app/src/services/view-data.ts +18 -18
  136. package/templates/web-app/src/services/widget-queries.ts +18 -18
  137. package/templates/web-app/tests/browser/fixtures.mjs +28 -28
  138. package/templates/web-app/tests/browser/index.html +16 -16
  139. package/templates/web-app/tests/browser/main.tsx +149 -149
  140. package/templates/web-app/tests/browser/test-page.css +4 -4
  141. package/templates/web-app/tests/browser/vite.config.mjs +13 -13
  142. package/templates/web-app/tests/bundle.test.mjs +19 -19
  143. package/templates/web-app/tests/cockpit.test.mjs +73 -73
  144. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  145. package/templates/web-app/tests/proxy.test.mjs +20 -5
  146. package/templates/web-app/tests/retail.test.mjs +31 -31
  147. package/templates/web-app/tests/runtime.test.mjs +7 -0
  148. package/templates/web-app/tests/session.test.mjs +91 -0
  149. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  150. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  151. package/templates/web-app/tsconfig.json +4 -4
  152. package/templates/web-app/vite.config.ts +9 -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; series?: {fieldId:string;label:string}[]; description?:string; filterNames?:string[]; tableFieldIds?:string[]; localFilterFieldIds?:string[]; valueFormat?:'number'|'percent'; categoryFormat?:'epoch-month'; categoryTimeZone?: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; 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
+ 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>);
@@ -1,13 +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:first-child{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}}
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
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}}
13
+ @media(max-width:700px){.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:128px}}
@@ -1,36 +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
- }
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
+ }