@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,47 +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
+ 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
+ }
@@ -1,18 +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
- }
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
+ }
@@ -1,28 +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
- }
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
+ }
@@ -1,16 +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>
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>
@@ -1,149 +1,149 @@
1
- import {createElement} from 'react';
2
- import {createRoot} from 'react-dom/client';
3
- import {flushSync} from 'react-dom';
4
- import App from '../../src/App';
5
- import '../../src/styles.css';
6
- import {fixtureConfig, nativeFixture} from './fixtures.mjs';
7
- import './test-page.css';
8
-
9
- const root = createRoot(document.getElementById('root')!);
10
- const result = document.getElementById('test-result')!;
11
- const checks = document.getElementById('test-checks')!;
12
- const runButton = document.getElementById('test-run') as HTMLButtonElement;
13
- const layoutSelect = document.getElementById('test-layout') as HTMLSelectElement;
14
- let config = fixtureConfig(layoutSelect.value), scenario = 'normal', generation = 0, failOnce = false;
15
- let requests: any[] = [];
16
- const runtimeErrors: string[] = [];
17
- for (const level of ['error', 'warn'] as const) {
18
- const original = console[level].bind(console);
19
- console[level] = (...args) => {runtimeErrors.push(args.map(String).join(' ')); original(...args);};
20
- }
21
- window.addEventListener('error', event => runtimeErrors.push(event.message));
22
- window.addEventListener('unhandledrejection', event => runtimeErrors.push(String(event.reason)));
23
-
24
- // This page is a test-only network boundary. Unexpected app requests fail closed.
25
- window.fetch = async (input, init = {}) => {
26
- const url = new URL(input instanceof Request ? input.url : String(input), location.href);
27
- if (url.origin !== location.origin) throw new Error('浏览器夹具禁止外部请求');
28
- if (url.pathname.endsWith('/app-config.json')) return Response.json(config);
29
- if (url.pathname !== '/fixture-decision/v5/api/dashboard/report/fixture-dashboard/widgets/query') throw new Error(`未配置的夹具请求:${url.pathname}`);
30
- const body = JSON.parse(String(init.body));
31
- requests.push(body);
32
- await new Promise(resolve => setTimeout(resolve, 25));
33
- if (init.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
34
- const id = body.widgetIds[0];
35
- if (failOnce && id === 'chart1') {failOnce = false; return Response.json({}, {status: 403});}
36
- return Response.json({success: true, data: {[id]: nativeFixture(id, body.refer, scenario)}});
37
- };
38
-
39
- function assert(condition: unknown, message: string): asserts condition {if (!condition) throw new Error(message);}
40
- async function wait(check: () => boolean, label: string) {
41
- const deadline = performance.now() + 15000;
42
- while (!check()) {
43
- assert(performance.now() < deadline, `超时:${label}`);
44
- await new Promise(resolve => setTimeout(resolve, 40));
45
- }
46
- }
47
- function pass(message: string) {const li = document.createElement('li'); li.textContent = `PASS · ${message}`; checks.append(li);}
48
- const panels = () => Array.from(document.querySelectorAll<HTMLElement>('#root .panel'));
49
- async function settled() {
50
- await wait(() => panels().length === config.widgets.length && panels().every(panel => /更新于|查询失败/.test(panel.querySelector('.component-status')?.textContent ?? '')), '组件查询结束');
51
- }
52
- async function mount(state = 'normal', chartCount = 8) {
53
- scenario = state; config = fixtureConfig(layoutSelect.value, chartCount); requests = []; failOnce = state === 'failure';
54
- flushSync(() => root.render(createElement(App, {key: ++generation})));
55
- await settled();
56
- }
57
- function assertRequests(filtered: boolean) {
58
- assert(requests.length === config.widgets.length, '每个展示组件应恰好请求一次');
59
- for (const request of requests) {
60
- assert(request.reportEntryId === 'fixture-directory', '目录入口丢失');
61
- const expected = filtered && request.widgetIds[0] !== 'kpi3' ? {区域: ['华东']} : {};
62
- assert(JSON.stringify(request.refer) === JSON.stringify(expected), '筛选编码或组件作用范围错误');
63
- }
64
- }
65
- async function painted(count: number) {
66
- await wait(() => {
67
- const canvases = Array.from(document.querySelectorAll<HTMLCanvasElement>('#root .chart canvas'));
68
- return canvases.length === count && canvases.every(canvas => {
69
- const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
70
- let colored = 0;
71
- for (let i = 3; i < pixels.length; i += 4) if (pixels[i] && ++colored > 100) return true;
72
- return false;
73
- });
74
- }, '柱/线/饼图实际绘制');
75
- assert(runtimeErrors.length === 0, runtimeErrors.join('\n'));
76
- }
77
- function geometry(even: boolean) {
78
- assert(document.documentElement.scrollWidth <= innerWidth + 1, '页面存在横向溢出');
79
- const cards = panels().map(panel => panel.getBoundingClientRect());
80
- for (let i = 0; i < cards.length; i++) {
81
- const a = cards[i];
82
- assert(a.width > 0 && a.left >= -1 && a.right <= innerWidth + 1, '卡片超出视口');
83
- for (const b of cards.slice(i + 1)) assert(Math.min(a.right,b.right) - Math.max(a.left,b.left) <= 1 || Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) <= 1, '卡片重叠');
84
- }
85
- const grid = document.querySelector<HTMLElement>('#root .grid')!;
86
- const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
87
- const regular = Array.from(grid.querySelectorAll<HTMLElement>(':scope > .panel:not(.wide)')).map(el => el.getBoundingClientRect());
88
- if (even && columns === 2 && regular.length % 2 === 0) for (let i = 0; i < regular.length; i += 2) assert(Math.abs(regular[i].top - regular[i + 1].top) < 2, '偶数组件双列排列出现孤立卡片');
89
- }
90
- function button(text: string) {
91
- const found = Array.from(document.querySelectorAll<HTMLButtonElement>('#root button')).find(el => el.textContent?.trim() === text);
92
- assert(found, `找不到按钮:${text}`); return found;
93
- }
94
-
95
- async function retailNavigation() {
96
- if (layoutSelect.value !== 'retail') return;
97
- const nav = document.querySelector<HTMLElement>('#root .retail-nav')!;
98
- for (const id of ['components', 'data-notes', 'dashboard']) {
99
- nav.querySelector<HTMLAnchorElement>(`a[href="#${id}"]`)!.click();
100
- await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
101
- const bar = nav.getBoundingClientRect(), target = document.getElementById(id)!.getBoundingClientRect();
102
- assert(bar.top >= -1 && bar.bottom < innerHeight, '跳转后导航离开视口');
103
- assert(target.top >= bar.bottom - 1 && target.top < innerHeight, '锚点标题被导航遮住或离开视口');
104
- }
105
- assert(document.querySelector('#components h2')?.textContent === '销售表现', '销售分析未定位到区域标题');
106
- window.scrollTo(0, 0);
107
- pass('页内导航持续可见,三个锚点标题可见且不被遮挡');
108
- }
109
-
110
- async function run() {
111
- runButton.disabled = true; layoutSelect.disabled = true; checks.replaceChildren(); result.textContent = '运行中';
112
- const started = performance.now();
113
- try {
114
- assert(runtimeErrors.length === 0, '已有运行错误,请修复并刷新测试页:' + runtimeErrors.join('\n'));
115
- await mount(); await painted(8); assertRequests(false); geometry(true);
116
- assert(document.querySelector('#root .widget-table')?.textContent?.includes('受限'), '受限值未标记');
117
- pass('八张图实际绘制,长标题/边界/偶数排列、目录入口和受限值');
118
- const input = document.querySelector<HTMLInputElement>('#root input[aria-label="区域"]');
119
- assert(input, '文本筛选尚未加载');
120
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '华东');
121
- input.dispatchEvent(new Event('input', {bubbles: true}));
122
- await wait(() => document.querySelector('.filter-status')?.textContent === '待应用', '筛选草稿');
123
- assert(requests.length === config.widgets.length, '编辑草稿触发了查询');
124
- requests = []; button('应用筛选').click();
125
- await wait(() => requests.length === config.widgets.length, '应用发起查询'); await settled();
126
- assertRequests(true);
127
- assert(document.querySelector('#root .kpi')?.textContent === '120', '应用条件后指标未变化');
128
- pass('草稿不查询,应用后按真实编码及组件范围请求,显示值变化');
129
- requests = []; button('恢复全部默认').click();
130
- await wait(() => requests.length === config.widgets.length, '恢复默认查询'); await settled();
131
- assertRequests(false); assert(document.querySelector('#root .kpi')?.textContent === '260', '恢复默认后指标未恢复');
132
- pass('恢复默认省略覆盖,显示值恢复');
133
- await mount('normal', 7); await painted(7); geometry(false); pass('七张图、奇数组件无溢出与重叠');
134
- await mount('empty'); assert(document.querySelectorAll('#root .empty').length === config.widgets.length, '空结果未独立显示');
135
- assert(!document.querySelector('#root canvas'), '空结果保留了旧图'); pass('空结果不保留旧图');
136
- await mount('partial'); await painted(8);
137
- assert(document.querySelector('#root .notice')?.textContent?.includes('首屏'), '截断警告缺失');
138
- assert(document.querySelector('#root .notice')?.textContent?.includes('PARTIAL'), '部分适配标记缺失');
139
- pass('部分适配与首屏范围警告保持可见');
140
- await mount('failure'); assert(document.querySelectorAll('#root .component-error').length === 1, '单组件失败未隔离');
141
- assert(document.querySelector('#root .component-error')?.textContent?.includes('PERMISSION_DENIED'), '权限失败语义错误');
142
- requests = []; button('重试组件').click(); await wait(() => requests.length === 1, '组件重试'); await settled(); await painted(8);
143
- assert(requests.length === 1 && requests[0].widgetIds[0] === 'chart1', '重试影响了其他组件'); pass('权限失败独立显示,单项重试只请求失败组件');
144
- await mount(); await painted(8); geometry(true); await retailNavigation();
145
- result.textContent = `PASS · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${checks.children.length} 项 · ${((performance.now()-started)/1000).toFixed(1)}s`;
146
- } catch (error) {result.textContent = `FAIL · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${String(error)}`;}
147
- finally {runButton.disabled = false; layoutSelect.disabled = false;}
148
- }
149
- runButton.addEventListener('click', run);
1
+ import {createElement} from 'react';
2
+ import {createRoot} from 'react-dom/client';
3
+ import {flushSync} from 'react-dom';
4
+ import App from '../../src/App';
5
+ import '../../src/styles.css';
6
+ import {fixtureConfig, nativeFixture} from './fixtures.mjs';
7
+ import './test-page.css';
8
+
9
+ const root = createRoot(document.getElementById('root')!);
10
+ const result = document.getElementById('test-result')!;
11
+ const checks = document.getElementById('test-checks')!;
12
+ const runButton = document.getElementById('test-run') as HTMLButtonElement;
13
+ const layoutSelect = document.getElementById('test-layout') as HTMLSelectElement;
14
+ let config = fixtureConfig(layoutSelect.value), scenario = 'normal', generation = 0, failOnce = false;
15
+ let requests: any[] = [];
16
+ const runtimeErrors: string[] = [];
17
+ for (const level of ['error', 'warn'] as const) {
18
+ const original = console[level].bind(console);
19
+ console[level] = (...args) => {runtimeErrors.push(args.map(String).join(' ')); original(...args);};
20
+ }
21
+ window.addEventListener('error', event => runtimeErrors.push(event.message));
22
+ window.addEventListener('unhandledrejection', event => runtimeErrors.push(String(event.reason)));
23
+
24
+ // This page is a test-only network boundary. Unexpected app requests fail closed.
25
+ window.fetch = async (input, init = {}) => {
26
+ const url = new URL(input instanceof Request ? input.url : String(input), location.href);
27
+ if (url.origin !== location.origin) throw new Error('浏览器夹具禁止外部请求');
28
+ if (url.pathname.endsWith('/app-config.json')) return Response.json(config);
29
+ if (url.pathname !== '/fixture-decision/v5/api/dashboard/report/fixture-dashboard/widgets/query') throw new Error(`未配置的夹具请求:${url.pathname}`);
30
+ const body = JSON.parse(String(init.body));
31
+ requests.push(body);
32
+ await new Promise(resolve => setTimeout(resolve, 25));
33
+ if (init.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
34
+ const id = body.widgetIds[0];
35
+ if (failOnce && id === 'chart1') {failOnce = false; return Response.json({}, {status: 403});}
36
+ return Response.json({success: true, data: {[id]: nativeFixture(id, body.refer, scenario)}});
37
+ };
38
+
39
+ function assert(condition: unknown, message: string): asserts condition {if (!condition) throw new Error(message);}
40
+ async function wait(check: () => boolean, label: string) {
41
+ const deadline = performance.now() + 15000;
42
+ while (!check()) {
43
+ assert(performance.now() < deadline, `超时:${label}`);
44
+ await new Promise(resolve => setTimeout(resolve, 40));
45
+ }
46
+ }
47
+ function pass(message: string) {const li = document.createElement('li'); li.textContent = `PASS · ${message}`; checks.append(li);}
48
+ const panels = () => Array.from(document.querySelectorAll<HTMLElement>('#root .panel'));
49
+ async function settled() {
50
+ await wait(() => panels().length === config.widgets.length && panels().every(panel => /更新于|查询失败/.test(panel.querySelector('.component-status')?.textContent ?? '')), '组件查询结束');
51
+ }
52
+ async function mount(state = 'normal', chartCount = 8) {
53
+ scenario = state; config = fixtureConfig(layoutSelect.value, chartCount); requests = []; failOnce = state === 'failure';
54
+ flushSync(() => root.render(createElement(App, {key: ++generation})));
55
+ await settled();
56
+ }
57
+ function assertRequests(filtered: boolean) {
58
+ assert(requests.length === config.widgets.length, '每个展示组件应恰好请求一次');
59
+ for (const request of requests) {
60
+ assert(request.reportEntryId === 'fixture-directory', '目录入口丢失');
61
+ const expected = filtered && request.widgetIds[0] !== 'kpi3' ? {区域: ['华东']} : {};
62
+ assert(JSON.stringify(request.refer) === JSON.stringify(expected), '筛选编码或组件作用范围错误');
63
+ }
64
+ }
65
+ async function painted(count: number) {
66
+ await wait(() => {
67
+ const canvases = Array.from(document.querySelectorAll<HTMLCanvasElement>('#root .chart canvas'));
68
+ return canvases.length === count && canvases.every(canvas => {
69
+ const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
70
+ let colored = 0;
71
+ for (let i = 3; i < pixels.length; i += 4) if (pixels[i] && ++colored > 100) return true;
72
+ return false;
73
+ });
74
+ }, '柱/线/饼图实际绘制');
75
+ assert(runtimeErrors.length === 0, runtimeErrors.join('\n'));
76
+ }
77
+ function geometry(even: boolean) {
78
+ assert(document.documentElement.scrollWidth <= innerWidth + 1, '页面存在横向溢出');
79
+ const cards = panels().map(panel => panel.getBoundingClientRect());
80
+ for (let i = 0; i < cards.length; i++) {
81
+ const a = cards[i];
82
+ assert(a.width > 0 && a.left >= -1 && a.right <= innerWidth + 1, '卡片超出视口');
83
+ for (const b of cards.slice(i + 1)) assert(Math.min(a.right,b.right) - Math.max(a.left,b.left) <= 1 || Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) <= 1, '卡片重叠');
84
+ }
85
+ const grid = document.querySelector<HTMLElement>('#root .grid')!;
86
+ const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
87
+ const regular = Array.from(grid.querySelectorAll<HTMLElement>(':scope > .panel:not(.wide)')).map(el => el.getBoundingClientRect());
88
+ if (even && columns === 2 && regular.length % 2 === 0) for (let i = 0; i < regular.length; i += 2) assert(Math.abs(regular[i].top - regular[i + 1].top) < 2, '偶数组件双列排列出现孤立卡片');
89
+ }
90
+ function button(text: string) {
91
+ const found = Array.from(document.querySelectorAll<HTMLButtonElement>('#root button')).find(el => el.textContent?.trim() === text);
92
+ assert(found, `找不到按钮:${text}`); return found;
93
+ }
94
+
95
+ async function retailNavigation() {
96
+ if (layoutSelect.value !== 'retail') return;
97
+ const nav = document.querySelector<HTMLElement>('#root .retail-nav')!;
98
+ for (const id of ['components', 'data-notes', 'dashboard']) {
99
+ nav.querySelector<HTMLAnchorElement>(`a[href="#${id}"]`)!.click();
100
+ await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
101
+ const bar = nav.getBoundingClientRect(), target = document.getElementById(id)!.getBoundingClientRect();
102
+ assert(bar.top >= -1 && bar.bottom < innerHeight, '跳转后导航离开视口');
103
+ assert(target.top >= bar.bottom - 1 && target.top < innerHeight, '锚点标题被导航遮住或离开视口');
104
+ }
105
+ assert(document.querySelector('#components h2')?.textContent === '销售表现', '销售分析未定位到区域标题');
106
+ window.scrollTo(0, 0);
107
+ pass('页内导航持续可见,三个锚点标题可见且不被遮挡');
108
+ }
109
+
110
+ async function run() {
111
+ runButton.disabled = true; layoutSelect.disabled = true; checks.replaceChildren(); result.textContent = '运行中';
112
+ const started = performance.now();
113
+ try {
114
+ assert(runtimeErrors.length === 0, '已有运行错误,请修复并刷新测试页:' + runtimeErrors.join('\n'));
115
+ await mount(); await painted(8); assertRequests(false); geometry(true);
116
+ assert(document.querySelector('#root .widget-table')?.textContent?.includes('受限'), '受限值未标记');
117
+ pass('八张图实际绘制,长标题/边界/偶数排列、目录入口和受限值');
118
+ const input = document.querySelector<HTMLInputElement>('#root input[aria-label="区域"]');
119
+ assert(input, '文本筛选尚未加载');
120
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '华东');
121
+ input.dispatchEvent(new Event('input', {bubbles: true}));
122
+ await wait(() => document.querySelector('.filter-status')?.textContent === '待应用', '筛选草稿');
123
+ assert(requests.length === config.widgets.length, '编辑草稿触发了查询');
124
+ requests = []; button('应用筛选').click();
125
+ await wait(() => requests.length === config.widgets.length, '应用发起查询'); await settled();
126
+ assertRequests(true);
127
+ assert(document.querySelector('#root .kpi')?.textContent === '120', '应用条件后指标未变化');
128
+ pass('草稿不查询,应用后按真实编码及组件范围请求,显示值变化');
129
+ requests = []; button('恢复全部默认').click();
130
+ await wait(() => requests.length === config.widgets.length, '恢复默认查询'); await settled();
131
+ assertRequests(false); assert(document.querySelector('#root .kpi')?.textContent === '260', '恢复默认后指标未恢复');
132
+ pass('恢复默认省略覆盖,显示值恢复');
133
+ await mount('normal', 7); await painted(7); geometry(false); pass('七张图、奇数组件无溢出与重叠');
134
+ await mount('empty'); assert(document.querySelectorAll('#root .empty').length === config.widgets.length, '空结果未独立显示');
135
+ assert(!document.querySelector('#root canvas'), '空结果保留了旧图'); pass('空结果不保留旧图');
136
+ await mount('partial'); await painted(8);
137
+ assert(document.querySelector('#root .notice')?.textContent?.includes('首屏'), '截断警告缺失');
138
+ assert(document.querySelector('#root .notice')?.textContent?.includes('PARTIAL'), '部分适配标记缺失');
139
+ pass('部分适配与首屏范围警告保持可见');
140
+ await mount('failure'); assert(document.querySelectorAll('#root .component-error').length === 1, '单组件失败未隔离');
141
+ assert(document.querySelector('#root .component-error')?.textContent?.includes('PERMISSION_DENIED'), '权限失败语义错误');
142
+ requests = []; button('重试组件').click(); await wait(() => requests.length === 1, '组件重试'); await settled(); await painted(8);
143
+ assert(requests.length === 1 && requests[0].widgetIds[0] === 'chart1', '重试影响了其他组件'); pass('权限失败独立显示,单项重试只请求失败组件');
144
+ await mount(); await painted(8); geometry(true); await retailNavigation();
145
+ result.textContent = `PASS · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${checks.children.length} 项 · ${((performance.now()-started)/1000).toFixed(1)}s`;
146
+ } catch (error) {result.textContent = `FAIL · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${String(error)}`;}
147
+ finally {runButton.disabled = false; layoutSelect.disabled = false;}
148
+ }
149
+ runButton.addEventListener('click', run);
@@ -1,4 +1,4 @@
1
- #browser-checks{padding:20px 24px;border-bottom:2px solid #adb8c5;background:#fff;color:#243042;overflow-wrap:anywhere}
2
- #browser-checks h1{font-size:19px;margin:0 0 8px}#browser-checks p,#browser-checks li{font-size:12px;line-height:1.7}
3
- #browser-checks label{display:inline-flex;align-items:center;gap:8px;margin:8px 12px 8px 0}#test-result{display:block;margin:12px 0;font-size:14px}
4
- #test-checks{padding-left:22px;margin:0}#test-run{background:#243042;color:white}
1
+ #browser-checks{padding:20px 24px;border-bottom:2px solid #adb8c5;background:#fff;color:#243042;overflow-wrap:anywhere}
2
+ #browser-checks h1{font-size:19px;margin:0 0 8px}#browser-checks p,#browser-checks li{font-size:12px;line-height:1.7}
3
+ #browser-checks label{display:inline-flex;align-items:center;gap:8px;margin:8px 12px 8px 0}#test-result{display:block;margin:12px 0;font-size:14px}
4
+ #test-checks{padding-left:22px;margin:0}#test-run{background:#243042;color:white}
@@ -1,13 +1,13 @@
1
- import {fileURLToPath} from 'node:url';
2
- import {defineConfig, normalizePath} from 'vite';
3
-
4
- const project = fileURLToPath(new URL('../../', import.meta.url));
5
- export default defineConfig({
6
- root: fileURLToPath(new URL('./', import.meta.url)),
7
- publicDir: false,
8
- envDir: false,
9
- cacheDir: fileURLToPath(new URL('../../temp/vite-browser', import.meta.url)),
10
- resolve: {alias: [{find: /^date-fns\/locale$/, replacement: fileURLToPath(new URL('../../src/components/calendar-locales.ts', import.meta.url))}]},
11
- server: {host: '127.0.0.1', port: 4188, strictPort: true, hmr: false,
12
- fs: {allow: [project], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', normalizePath(project) + 'temp/**']}},
13
- });
1
+ import {fileURLToPath} from 'node:url';
2
+ import {defineConfig, normalizePath} from 'vite';
3
+
4
+ const project = fileURLToPath(new URL('../../', import.meta.url));
5
+ export default defineConfig({
6
+ root: fileURLToPath(new URL('./', import.meta.url)),
7
+ publicDir: false,
8
+ envDir: false,
9
+ cacheDir: fileURLToPath(new URL('../../temp/vite-browser', import.meta.url)),
10
+ resolve: {alias: [{find: /^date-fns\/locale$/, replacement: fileURLToPath(new URL('../../src/components/calendar-locales.ts', import.meta.url))}]},
11
+ server: {host: '127.0.0.1', port: 4188, strictPort: true, hmr: false,
12
+ fs: {allow: [project], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', normalizePath(project) + 'temp/**']}},
13
+ });