@finebi/finebi-cli 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/package.json +1 -1
  2. package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
  3. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
  4. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  5. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +160 -157
  6. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +27 -27
  7. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +55 -55
  8. package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
  9. package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
  10. package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
  11. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -80
  12. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
  13. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
  14. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -0
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -63
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -40
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -3
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -58
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -235
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -20
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -70
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -189
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  56. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  57. package/src/app.js +160 -157
  58. package/src/config.js +27 -27
  59. package/src/help.js +55 -55
  60. package/src/install-skill.js +89 -89
  61. package/src/profiles.js +158 -158
  62. package/src/skill-files.js +94 -94
  63. package/templates/web-app/docs/acceptance.md +80 -80
  64. package/templates/web-app/examples/cockpit.config.json +22 -22
  65. package/templates/web-app/examples/retail.config.json +185 -185
  66. package/templates/web-app/gitignore.template +6 -0
  67. package/templates/web-app/index.html +2 -2
  68. package/templates/web-app/public/app-config.json +11 -11
  69. package/templates/web-app/src/App.tsx +63 -63
  70. package/templates/web-app/src/cockpit.css +3 -3
  71. package/templates/web-app/src/components/Chart.tsx +40 -40
  72. package/templates/web-app/src/components/Insights.tsx +17 -17
  73. package/templates/web-app/src/components/MonthPicker.tsx +32 -32
  74. package/templates/web-app/src/components/OptionFilter.tsx +7 -7
  75. package/templates/web-app/src/components/RangeFilter.tsx +9 -9
  76. package/templates/web-app/src/components/Widget.tsx +40 -40
  77. package/templates/web-app/src/components/calendar-locales.ts +3 -3
  78. package/templates/web-app/src/components/calendar.ts +14 -14
  79. package/templates/web-app/src/components/charts/Bar.tsx +6 -6
  80. package/templates/web-app/src/components/charts/Line.tsx +6 -6
  81. package/templates/web-app/src/components/charts/Pie.tsx +6 -6
  82. package/templates/web-app/src/components/month-picker.css +1 -1
  83. package/templates/web-app/src/finebi/config.d.ts +3 -3
  84. package/templates/web-app/src/finebi/config.js +58 -58
  85. package/templates/web-app/src/finebi/date.d.ts +1 -1
  86. package/templates/web-app/src/finebi/date.js +5 -5
  87. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  88. package/templates/web-app/src/finebi/filters.js +106 -106
  89. package/templates/web-app/src/finebi/normalize.ts +235 -235
  90. package/templates/web-app/src/finebi/types.ts +20 -20
  91. package/templates/web-app/src/main.tsx +5 -5
  92. package/templates/web-app/src/retail.css +12 -12
  93. package/templates/web-app/src/services/presentation.ts +36 -36
  94. package/templates/web-app/src/services/table-data.ts +47 -47
  95. package/templates/web-app/src/services/view-data.ts +18 -18
  96. package/templates/web-app/src/services/widget-queries.ts +18 -18
  97. package/templates/web-app/tests/browser/fixtures.mjs +28 -28
  98. package/templates/web-app/tests/browser/index.html +16 -16
  99. package/templates/web-app/tests/browser/main.tsx +149 -149
  100. package/templates/web-app/tests/browser/test-page.css +4 -4
  101. package/templates/web-app/tests/browser/vite.config.mjs +13 -13
  102. package/templates/web-app/tests/bundle.test.mjs +19 -19
  103. package/templates/web-app/tests/cockpit.test.mjs +73 -73
  104. package/templates/web-app/tests/field-selection.test.mjs +70 -70
  105. package/templates/web-app/tests/retail.test.mjs +31 -31
  106. package/templates/web-app/tests/table-normalize.test.mjs +189 -189
  107. package/templates/web-app/tsconfig.json +4 -4
@@ -1,40 +1,40 @@
1
- import {useEffect, useRef, useState} from 'react';
2
- import * as echarts from 'echarts/core';
3
- import {TooltipComponent} from 'echarts/components';
4
- import {CanvasRenderer} from 'echarts/renderers';
5
- import type {Dataset, WidgetBinding} from '../finebi/types';
6
- import {chartValues,displayCell} from '../services/view-data';
7
- import {formatCategory} from '../services/presentation';
8
- echarts.use([TooltipComponent, CanvasRenderer]);
9
- export function Chart({dataset, binding}: {dataset: Dataset; binding: WidgetBinding}) {
10
- const container = useRef<HTMLDivElement>(null);
11
- const [order, setOrder] = useState('source');
12
- const [metric,setMetric]=useState(0),[hidden,setHidden]=useState<string[]>([]);
13
- const series=binding.series??[{fieldId:binding.valueFieldId!,label:binding.name}];
14
- const all=series.map(s=>({...s,values:chartValues(dataset,{categoryFieldId:binding.categoryFieldId,valueFieldId:s.fieldId})}));
15
- const active=binding.type==='bar'?[all[metric]??all[0]]:all.filter(s=>!hidden.includes(s.fieldId));
16
- const values=all[0].values;
17
- useEffect(() => {
18
- if (!container.current) return;
19
- const chart = echarts.init(container.current);
20
- const style=getComputedStyle(container.current);
21
- const palette=['#197a58','#4988bd','#d6aa4e','#8b75b5','#ec4899'].map((color,i)=>style.getPropertyValue(`--chart-${i+1}`).trim()||color);
22
- const categoryIndex=dataset.columns.findIndex(c=>c.fieldId===binding.categoryFieldId);
23
- const categoryNames=dataset.rows.map(row=>formatCategory(row.cells[categoryIndex],binding));
24
- const indices=values.map((_,i)=>i),ordered=active[0]?.values??values;
25
- if(order!=='source')indices.sort((i,j)=>ordered[i].value===null?1:ordered[j].value===null?-1:(ordered[i].value!-ordered[j].value!)*(order==='asc'?1:-1));
26
- chart.setOption({
27
- color: palette,
28
- tooltip: {trigger: binding.type === 'pie' ? 'item' : 'axis', renderMode: 'richText'},
29
- ...(binding.type === 'pie' ? {legend:{bottom:0}} : {
30
- grid:{left:24,right:24,top:24,bottom:24,containLabel:true},
31
- xAxis:{type:'category',data:indices.map(i=>categoryNames[i]),axisLabel:{rotate:values.length>8?30:0}},
32
- yAxis:{type:'value'},
33
- }),
34
- series: active.map(s=>({name:s.label,type:binding.type,connectNulls:false,data:indices.map(i=>binding.type==='pie'?s.values[i]:s.values[i].value),radius:binding.type==='pie'?['42%','67%']:undefined})),
35
- });
36
- const observer = new ResizeObserver(() => chart.resize()); observer.observe(container.current);
37
- return () => {observer.disconnect(); chart.dispose();};
38
- }, [dataset, binding, order,metric,hidden]);
39
- return <>{all.length>1&&<div className="series-controls">{binding.type==='bar'?<label>显示指标 <select aria-label={`${binding.name}指标`} value={metric} onChange={e=>setMetric(Number(e.target.value))}>{all.map((s,i)=><option key={s.fieldId} value={i}>{s.label}</option>)}</select></label>:all.map(s=><label key={s.fieldId}><input type="checkbox" checked={!hidden.includes(s.fieldId)} onChange={e=>setHidden(old=>e.target.checked?old.filter(id=>id!==s.fieldId):[...old,s.fieldId])}/>{s.label}</label>)}</div>}<label className="chart-sort">显示顺序 <select aria-label={`${binding.name}显示顺序`} value={order} onChange={e => setOrder(e.target.value)}><option value="source">查询顺序</option><option value="desc">已返回值降序</option><option value="asc">已返回值升序</option></select></label><div className="chart" ref={container} role="img" aria-label={binding.name}/><details><summary>查看图表原始显示值(已加载结果)</summary><div className="table-scroll"><table><thead><tr>{dataset.columns.map(c=><th key={c.key}>{c.name}</th>)}</tr></thead><tbody>{dataset.rows.map((row,i)=><tr key={i}>{row.cells.map((cell,j)=><td key={j}>{displayCell(cell)}</td>)}</tr>)}</tbody></table></div></details></>;
40
- }
1
+ import {useEffect, useRef, useState} from 'react';
2
+ import * as echarts from 'echarts/core';
3
+ import {TooltipComponent} from 'echarts/components';
4
+ import {CanvasRenderer} from 'echarts/renderers';
5
+ import type {Dataset, WidgetBinding} from '../finebi/types';
6
+ import {chartValues,displayCell} from '../services/view-data';
7
+ import {formatCategory} from '../services/presentation';
8
+ echarts.use([TooltipComponent, CanvasRenderer]);
9
+ export function Chart({dataset, binding}: {dataset: Dataset; binding: WidgetBinding}) {
10
+ const container = useRef<HTMLDivElement>(null);
11
+ const [order, setOrder] = useState('source');
12
+ const [metric,setMetric]=useState(0),[hidden,setHidden]=useState<string[]>([]);
13
+ const series=binding.series??[{fieldId:binding.valueFieldId!,label:binding.name}];
14
+ const all=series.map(s=>({...s,values:chartValues(dataset,{categoryFieldId:binding.categoryFieldId,valueFieldId:s.fieldId})}));
15
+ const active=binding.type==='bar'?[all[metric]??all[0]]:all.filter(s=>!hidden.includes(s.fieldId));
16
+ const values=all[0].values;
17
+ useEffect(() => {
18
+ if (!container.current) return;
19
+ const chart = echarts.init(container.current);
20
+ const style=getComputedStyle(container.current);
21
+ const palette=['#197a58','#4988bd','#d6aa4e','#8b75b5','#ec4899'].map((color,i)=>style.getPropertyValue(`--chart-${i+1}`).trim()||color);
22
+ const categoryIndex=dataset.columns.findIndex(c=>c.fieldId===binding.categoryFieldId);
23
+ const categoryNames=dataset.rows.map(row=>formatCategory(row.cells[categoryIndex],binding));
24
+ const indices=values.map((_,i)=>i),ordered=active[0]?.values??values;
25
+ if(order!=='source')indices.sort((i,j)=>ordered[i].value===null?1:ordered[j].value===null?-1:(ordered[i].value!-ordered[j].value!)*(order==='asc'?1:-1));
26
+ chart.setOption({
27
+ color: palette,
28
+ tooltip: {trigger: binding.type === 'pie' ? 'item' : 'axis', renderMode: 'richText'},
29
+ ...(binding.type === 'pie' ? {legend:{bottom:0}} : {
30
+ grid:{left:24,right:24,top:24,bottom:24,containLabel:true},
31
+ xAxis:{type:'category',data:indices.map(i=>categoryNames[i]),axisLabel:{rotate:values.length>8?30:0}},
32
+ yAxis:{type:'value'},
33
+ }),
34
+ series: active.map(s=>({name:s.label,type:binding.type,connectNulls:false,data:indices.map(i=>binding.type==='pie'?s.values[i]:s.values[i].value),radius:binding.type==='pie'?['42%','67%']:undefined})),
35
+ });
36
+ const observer = new ResizeObserver(() => chart.resize()); observer.observe(container.current);
37
+ return () => {observer.disconnect(); chart.dispose();};
38
+ }, [dataset, binding, order,metric,hidden]);
39
+ return <>{all.length>1&&<div className="series-controls">{binding.type==='bar'?<label>显示指标 <select aria-label={`${binding.name}指标`} value={metric} onChange={e=>setMetric(Number(e.target.value))}>{all.map((s,i)=><option key={s.fieldId} value={i}>{s.label}</option>)}</select></label>:all.map(s=><label key={s.fieldId}><input type="checkbox" checked={!hidden.includes(s.fieldId)} onChange={e=>setHidden(old=>e.target.checked?old.filter(id=>id!==s.fieldId):[...old,s.fieldId])}/>{s.label}</label>)}</div>}<label className="chart-sort">显示顺序 <select aria-label={`${binding.name}显示顺序`} value={order} onChange={e => setOrder(e.target.value)}><option value="source">查询顺序</option><option value="desc">已返回值降序</option><option value="asc">已返回值升序</option></select></label><div className="chart" ref={container} role="img" aria-label={binding.name}/><details><summary>查看图表原始显示值(已加载结果)</summary><div className="table-scroll"><table><thead><tr>{dataset.columns.map(c=><th key={c.key}>{c.name}</th>)}</tr></thead><tbody>{dataset.rows.map((row,i)=><tr key={i}>{row.cells.map((cell,j)=><td key={j}>{displayCell(cell)}</td>)}</tr>)}</tbody></table></div></details></>;
40
+ }
@@ -1,17 +1,17 @@
1
- import type {AppConfig} from '../finebi/types';
2
- import type {WidgetState} from '../services/widget-queries';
3
- import {widgetKey} from '../services/widget-queries';
4
- import {displayCell,selectDataset} from '../services/view-data';
5
- export function Insights({config,states}:{config:AppConfig;states:Record<string,WidgetState>}) {
6
- const notes=config.widgets.map((binding,index)=>{
7
- const state=states[widgetKey(binding,index)];
8
- if(!state||state.loading)return `${binding.name}:等待当前查询`;
9
- if(state.error)return `${binding.name}:查询失败,未参与观察`;
10
- try {
11
- const result=state.result!,data=selectDataset(result,binding.datasetId);
12
- if(binding.type==='kpi'&&data.rows.length===1){const i=data.columns.findIndex(c=>c.fieldId===binding.valueFieldId);if(i<0)throw Error();return `${binding.name}:${displayCell(data.rows[0].cells[i])}(${result.normalization})`;}
13
- return `${binding.name}:已返回 ${data.returnedRowCount} 行,${result.normalization}${result.range.hasNext?',还有后续数据':''}${data.rows.some(r=>r.kind==='UNKNOWN')?';含 UNKNOWN 行,不自动汇总':''}`;
14
- } catch{return `${binding.name}:绑定或适配不可用,未参与观察`;}
15
- });
16
- return <section className="insight-panel" id="observations"><div><span className="eyebrow">CURRENT QUERY</span><h2>经营观察</h2><p>根据当前组件返回动态生成,范围以各组件提示为准。</p></div><ul>{notes.map((note,i)=><li key={i}>{note}</li>)}</ul></section>;
17
- }
1
+ import type {AppConfig} from '../finebi/types';
2
+ import type {WidgetState} from '../services/widget-queries';
3
+ import {widgetKey} from '../services/widget-queries';
4
+ import {displayCell,selectDataset} from '../services/view-data';
5
+ export function Insights({config,states}:{config:AppConfig;states:Record<string,WidgetState>}) {
6
+ const notes=config.widgets.map((binding,index)=>{
7
+ const state=states[widgetKey(binding,index)];
8
+ if(!state||state.loading)return `${binding.name}:等待当前查询`;
9
+ if(state.error)return `${binding.name}:查询失败,未参与观察`;
10
+ try {
11
+ const result=state.result!,data=selectDataset(result,binding.datasetId);
12
+ if(binding.type==='kpi'&&data.rows.length===1){const i=data.columns.findIndex(c=>c.fieldId===binding.valueFieldId);if(i<0)throw Error();return `${binding.name}:${displayCell(data.rows[0].cells[i])}(${result.normalization})`;}
13
+ return `${binding.name}:已返回 ${data.returnedRowCount} 行,${result.normalization}${result.range.hasNext?',还有后续数据':''}${data.rows.some(r=>r.kind==='UNKNOWN')?';含 UNKNOWN 行,不自动汇总':''}`;
14
+ } catch{return `${binding.name}:绑定或适配不可用,未参与观察`;}
15
+ });
16
+ return <section className="insight-panel" id="observations"><div><span className="eyebrow">CURRENT QUERY</span><h2>经营观察</h2><p>根据当前组件返回动态生成,范围以各组件提示为准。</p></div><ul>{notes.map((note,i)=><li key={i}>{note}</li>)}</ul></section>;
17
+ }
@@ -1,32 +1,32 @@
1
- import {useId,useLayoutEffect,useRef,useState} from 'react';
2
- import './month-picker.css';
3
- const clamp=(n:number,min:number,max:number)=>Math.max(min,Math.min(max,n));
4
- const pad=(n:number)=>String(n).padStart(4,'0');
5
- export function MonthPicker({id,label,value,onChange}:{id:string;label:string;value:string;onChange:(value:string)=>void}) {
6
- const popupId=useId(),root=useRef<HTMLDivElement>(null),trigger=useRef<HTMLButtonElement>(null),grid=useRef<HTMLDivElement>(null);
7
- const now=new Date(),initialYear=value?Number(value.slice(0,4)):now.getFullYear();
8
- const [open,setOpen]=useState(false),[mode,setMode]=useState<'months'|'years'>('months');
9
- const [year,setYear]=useState(initialYear),[cursor,setCursor]=useState(0),[focusTick,setFocusTick]=useState(0);
10
- const base=clamp(Math.floor((year-1)/12)*12+1,1,9988);
11
- const focusGrid=()=>setFocusTick(n=>n+1);
12
- useLayoutEffect(()=>{if(open&&focusTick)grid.current?.querySelector<HTMLButtonElement>(`[data-cell="${cursor}"]`)?.focus();},[focusTick,open,cursor,mode,year]);
13
- const close=(restore=false)=>{setOpen(false);if(restore)trigger.current?.focus();};
14
- const show=(focus=false)=>{setYear(initialYear);setMode('months');setCursor(value?Number(value.slice(5))-1:now.getMonth());setOpen(true);if(focus)focusGrid();};
15
- const choose=(next:string)=>{onChange(next);close(true);};
16
- const navigate=(delta:number)=>{setYear(y=>clamp(y+delta*(mode==='months'?1:12),1,9999));};
17
- const keyDown=(e:React.KeyboardEvent)=>{
18
- if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close(true);return;}
19
- const delta={ArrowLeft:-1,ArrowRight:1,ArrowUp:-3,ArrowDown:3}[e.key];
20
- if(delta!==undefined){e.preventDefault();setCursor(c=>clamp(c+delta,0,11));focusGrid();}
21
- else if(e.key==='Home'||e.key==='End'){e.preventDefault();setCursor(e.key==='Home'?0:11);focusGrid();}
22
- else if(e.key==='PageUp'||e.key==='PageDown'){e.preventDefault();navigate(e.key==='PageUp'?-1:1);focusGrid();}
23
- };
24
- return <div ref={root} className="month-picker" onBlur={e=>{if(e.relatedTarget&&!e.currentTarget.contains(e.relatedTarget))close();}}>
25
- <button id={id} ref={trigger} type="button" className={`month-trigger ${value?'has-value':''}`} aria-label={label} aria-haspopup="dialog" aria-expanded={open} aria-controls={popupId} onClick={()=>open?close():show()} onKeyDown={e=>{if(e.key==='ArrowDown'){e.preventDefault();show(true);}}}><span>{value?`${value.slice(0,4)}年${value.slice(5)}月`:'源看板默认'}</span><span aria-hidden="true">▦</span></button>
26
- {open&&<><div className="month-dismiss" onPointerDown={()=>close()}/><div className="month-popover" role="dialog" aria-label={`${label}选择面板`} id={popupId} onKeyDown={e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close(true);}}}>
27
- <div className="month-head"><button type="button" aria-label={mode==='months'?'上一年':'上一组年份'} disabled={year===1} onClick={()=>navigate(-1)}>‹</button><button type="button" className="month-year" aria-label={mode==='months'?'选择年份':'返回月份'} onClick={()=>{setMode(mode==='months'?'years':'months');setCursor(mode==='months'?year-base:0);focusGrid();}}>{mode==='months'?`${pad(year)}年`:`${pad(base)} — ${pad(base+11)}`}</button><button type="button" aria-label={mode==='months'?'下一年':'下一组年份'} disabled={year===9999} onClick={()=>navigate(1)}>›</button></div>
28
- <div ref={grid} className="month-grid" role="grid" aria-label={mode==='months'?'月份':'年份'} onKeyDown={keyDown}>{Array.from({length:4},(_,row)=><div role="row" key={row}>{Array.from({length:3},(_,column)=>{const i=row*3+column,selected=mode==='months'?value===`${pad(year)}-${String(i+1).padStart(2,'0')}`:Number(value.slice(0,4))===base+i;return <button key={i} type="button" role="gridcell" data-cell={i} tabIndex={cursor===i?0:-1} aria-selected={selected} className={selected?'selected':''} onFocus={()=>setCursor(i)} onClick={()=>{if(mode==='months')choose(`${pad(year)}-${String(i+1).padStart(2,'0')}`);else{setYear(base+i);setMode('months');setCursor(value?Number(value.slice(5))-1:0);focusGrid();}}}>{mode==='months'?`${i+1}月`:pad(base+i)}</button>;})}</div>)}</div>
29
- <div className="month-actions"><button type="button" onClick={()=>choose('')}>默认条件</button><button type="button" onClick={()=>choose(`${pad(now.getFullYear())}-${String(now.getMonth()+1).padStart(2,'0')}`)}>本月</button><button type="button" onClick={()=>choose('')}>清除</button></div><p>选择后点击「应用筛选」取数</p>
30
- </div></>}
31
- </div>;
32
- }
1
+ import {useId,useLayoutEffect,useRef,useState} from 'react';
2
+ import './month-picker.css';
3
+ const clamp=(n:number,min:number,max:number)=>Math.max(min,Math.min(max,n));
4
+ const pad=(n:number)=>String(n).padStart(4,'0');
5
+ export function MonthPicker({id,label,value,onChange}:{id:string;label:string;value:string;onChange:(value:string)=>void}) {
6
+ const popupId=useId(),root=useRef<HTMLDivElement>(null),trigger=useRef<HTMLButtonElement>(null),grid=useRef<HTMLDivElement>(null);
7
+ const now=new Date(),initialYear=value?Number(value.slice(0,4)):now.getFullYear();
8
+ const [open,setOpen]=useState(false),[mode,setMode]=useState<'months'|'years'>('months');
9
+ const [year,setYear]=useState(initialYear),[cursor,setCursor]=useState(0),[focusTick,setFocusTick]=useState(0);
10
+ const base=clamp(Math.floor((year-1)/12)*12+1,1,9988);
11
+ const focusGrid=()=>setFocusTick(n=>n+1);
12
+ useLayoutEffect(()=>{if(open&&focusTick)grid.current?.querySelector<HTMLButtonElement>(`[data-cell="${cursor}"]`)?.focus();},[focusTick,open,cursor,mode,year]);
13
+ const close=(restore=false)=>{setOpen(false);if(restore)trigger.current?.focus();};
14
+ const show=(focus=false)=>{setYear(initialYear);setMode('months');setCursor(value?Number(value.slice(5))-1:now.getMonth());setOpen(true);if(focus)focusGrid();};
15
+ const choose=(next:string)=>{onChange(next);close(true);};
16
+ const navigate=(delta:number)=>{setYear(y=>clamp(y+delta*(mode==='months'?1:12),1,9999));};
17
+ const keyDown=(e:React.KeyboardEvent)=>{
18
+ if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close(true);return;}
19
+ const delta={ArrowLeft:-1,ArrowRight:1,ArrowUp:-3,ArrowDown:3}[e.key];
20
+ if(delta!==undefined){e.preventDefault();setCursor(c=>clamp(c+delta,0,11));focusGrid();}
21
+ else if(e.key==='Home'||e.key==='End'){e.preventDefault();setCursor(e.key==='Home'?0:11);focusGrid();}
22
+ else if(e.key==='PageUp'||e.key==='PageDown'){e.preventDefault();navigate(e.key==='PageUp'?-1:1);focusGrid();}
23
+ };
24
+ return <div ref={root} className="month-picker" onBlur={e=>{if(e.relatedTarget&&!e.currentTarget.contains(e.relatedTarget))close();}}>
25
+ <button id={id} ref={trigger} type="button" className={`month-trigger ${value?'has-value':''}`} aria-label={label} aria-haspopup="dialog" aria-expanded={open} aria-controls={popupId} onClick={()=>open?close():show()} onKeyDown={e=>{if(e.key==='ArrowDown'){e.preventDefault();show(true);}}}><span>{value?`${value.slice(0,4)}年${value.slice(5)}月`:'源看板默认'}</span><span aria-hidden="true">▦</span></button>
26
+ {open&&<><div className="month-dismiss" onPointerDown={()=>close()}/><div className="month-popover" role="dialog" aria-label={`${label}选择面板`} id={popupId} onKeyDown={e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();close(true);}}}>
27
+ <div className="month-head"><button type="button" aria-label={mode==='months'?'上一年':'上一组年份'} disabled={year===1} onClick={()=>navigate(-1)}>‹</button><button type="button" className="month-year" aria-label={mode==='months'?'选择年份':'返回月份'} onClick={()=>{setMode(mode==='months'?'years':'months');setCursor(mode==='months'?year-base:0);focusGrid();}}>{mode==='months'?`${pad(year)}年`:`${pad(base)} — ${pad(base+11)}`}</button><button type="button" aria-label={mode==='months'?'下一年':'下一组年份'} disabled={year===9999} onClick={()=>navigate(1)}>›</button></div>
28
+ <div ref={grid} className="month-grid" role="grid" aria-label={mode==='months'?'月份':'年份'} onKeyDown={keyDown}>{Array.from({length:4},(_,row)=><div role="row" key={row}>{Array.from({length:3},(_,column)=>{const i=row*3+column,selected=mode==='months'?value===`${pad(year)}-${String(i+1).padStart(2,'0')}`:Number(value.slice(0,4))===base+i;return <button key={i} type="button" role="gridcell" data-cell={i} tabIndex={cursor===i?0:-1} aria-selected={selected} className={selected?'selected':''} onFocus={()=>setCursor(i)} onClick={()=>{if(mode==='months')choose(`${pad(year)}-${String(i+1).padStart(2,'0')}`);else{setYear(base+i);setMode('months');setCursor(value?Number(value.slice(5))-1:0);focusGrid();}}}>{mode==='months'?`${i+1}月`:pad(base+i)}</button>;})}</div>)}</div>
29
+ <div className="month-actions"><button type="button" onClick={()=>choose('')}>默认条件</button><button type="button" onClick={()=>choose(`${pad(now.getFullYear())}-${String(now.getMonth()+1).padStart(2,'0')}`)}>本月</button><button type="button" onClick={()=>choose('')}>清除</button></div><p>选择后点击「应用筛选」取数</p>
30
+ </div></>}
31
+ </div>;
32
+ }
@@ -1,7 +1,7 @@
1
- import Select from 'antd/es/select';
2
- import type {FilterBinding,FilterValue} from '../finebi/types';
3
-
4
- export default function OptionFilter({id,binding,value,onChange}:{id:string;binding:FilterBinding;value:FilterValue;onChange:(value:FilterValue)=>void}) {
5
- const type=binding.type??'select',text=typeof value==='string'?value:'';
6
- return <Select id={id} aria-label={binding.label} size="large" className="filter-select" mode={type==='multi-select'?'multiple':undefined} allowClear showSearch={{optionFilterProp:'label'}} maxTagCount="responsive" placeholder={`选择${binding.label},支持搜索`} options={binding.options} disabled={value===null} value={type==='multi-select'?(Array.isArray(value)?value:[]):text||undefined} onChange={selected=>onChange(selected??'')} notFoundContent={`没有匹配的${binding.label}`}/>;
7
- }
1
+ import Select from 'antd/es/select';
2
+ import type {FilterBinding,FilterValue} from '../finebi/types';
3
+
4
+ export default function OptionFilter({id,binding,value,onChange}:{id:string;binding:FilterBinding;value:FilterValue;onChange:(value:FilterValue)=>void}) {
5
+ const type=binding.type??'select',text=typeof value==='string'?value:'';
6
+ return <Select id={id} aria-label={binding.label} size="large" className="filter-select" mode={type==='multi-select'?'multiple':undefined} allowClear showSearch={{optionFilterProp:'label'}} maxTagCount="responsive" placeholder={`选择${binding.label},支持搜索`} options={binding.options} disabled={value===null} value={type==='multi-select'?(Array.isArray(value)?value:[]):text||undefined} onChange={selected=>onChange(selected??'')} notFoundContent={`没有匹配的${binding.label}`}/>;
7
+ }
@@ -1,9 +1,9 @@
1
- import generatePicker from 'antd/es/date-picker/generatePicker';
2
- import {calendarConfig, calendarValue} from './calendar';
3
-
4
- const CalendarPicker=generatePicker<Date>(calendarConfig);
5
- type Interval={start:string;end:string};
6
- export default function RangeFilter({id,month,value,onChange}:{id:string;month:boolean;value:Interval;onChange:(value:Interval)=>void}) {
7
- const update=(_dates:unknown,strings:[string,string]|null)=>onChange({start:strings?.[0]||'',end:strings?.[1]||''});
8
- return <CalendarPicker.RangePicker size="large" className="filter-date-range" id={{start:id+'-start',end:id+'-end'}} picker={month?'month':'date'} format={month?'YYYY-MM':'YYYY-MM-DD'} placeholder={month?['开始月份','结束月份']:['开始日期','结束日期']} allowEmpty={[true,true]} allowClear value={[calendarValue(value.start),calendarValue(value.end)]} minDate={calendarValue('0001-01-01')!} maxDate={calendarValue('9999-12-31')!} onCalendarChange={update} onChange={update} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault();}}/>;
9
- }
1
+ import generatePicker from 'antd/es/date-picker/generatePicker';
2
+ import {calendarConfig, calendarValue} from './calendar';
3
+
4
+ const CalendarPicker=generatePicker<Date>(calendarConfig);
5
+ type Interval={start:string;end:string};
6
+ export default function RangeFilter({id,month,value,onChange}:{id:string;month:boolean;value:Interval;onChange:(value:Interval)=>void}) {
7
+ const update=(_dates:unknown,strings:[string,string]|null)=>onChange({start:strings?.[0]||'',end:strings?.[1]||''});
8
+ return <CalendarPicker.RangePicker size="large" className="filter-date-range" id={{start:id+'-start',end:id+'-end'}} picker={month?'month':'date'} format={month?'YYYY-MM':'YYYY-MM-DD'} placeholder={month?['开始月份','结束月份']:['开始日期','结束日期']} allowEmpty={[true,true]} allowClear value={[calendarValue(value.start),calendarValue(value.end)]} minDate={calendarValue('0001-01-01')!} maxDate={calendarValue('9999-12-31')!} onCalendarChange={update} onChange={update} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault();}}/>;
9
+ }
@@ -1,40 +1,40 @@
1
- import {Component, lazy, Suspense, type ReactNode} from 'react';
2
- import type {QueryResult, WidgetBinding} from '../finebi/types';
3
- import {displayCell, selectDataset} from '../services/view-data';
4
- import {DataTable} from './DataTable';
5
- import type {WidgetState} from '../services/widget-queries';
6
- import {formatMetric} from '../services/presentation';
7
- const charts={bar:lazy(()=>import('./charts/Bar')),line:lazy(()=>import('./charts/Line')),pie:lazy(()=>import('./charts/Pie'))};
8
- export class Boundary extends Component<{children: ReactNode;onRetry?:()=>void}, {error: string}> {
9
- state = {error: ''};
10
- static getDerivedStateFromError(error: Error) {return {error: error.message};}
11
- render() {return this.state.error ? <div role="alert"><p>{this.state.error}</p>{this.props.onRetry&&<button onClick={this.props.onRetry}>重试组件</button>}</div> : this.props.children;}
12
- }
13
- function Content({result, binding, pageSize}: {result: QueryResult; binding: WidgetBinding; pageSize: number}) {
14
- const data = selectDataset(result, binding.datasetId);
15
- if (!data.rows.length) return <p className="empty">当前查询没有数据</p>;
16
- if (binding.type === 'table') return <DataTable dataset={data} pageSize={pageSize} binding={binding}/>;
17
- if (binding.type === 'kpi') {
18
- const index = data.columns.findIndex(c => c.fieldId === binding.valueFieldId);
19
- if (data.rows.length !== 1 || index < 0) throw new Error('KPI 需要单行指标结果,不能自动累加图表行');
20
- return <div className="kpi" title={displayCell(data.rows[0].cells[index])}>{formatMetric(data.rows[0].cells[index],binding.valueFormat)}</div>;
21
- }
22
- const Chart=charts[binding.type];
23
- return <Suspense fallback={<p role="status">正在加载图表…</p>}><Chart dataset={data} binding={binding}/></Suspense>;
24
- }
25
- export function Widget({state,binding,pageSize,onRetry,compact=false}: {state:WidgetState; binding:WidgetBinding;pageSize:number;onRetry:()=>void;compact?:boolean}) {
26
- const result=state.result;
27
- const selected=result?.datasets.find(d=>binding.datasetId?d.id===binding.datasetId:result.datasets.length===1);
28
- return <section className={`panel widget-${binding.type} ${binding.type==='table'?'wide':''}`}><div className="panel-heading"><h2>{binding.name}</h2><span className={`component-status ${state.error?'failed':''}`}>{state.loading?'加载中':state.error?'查询失败':state.at?`更新于 ${state.at}`:'等待查询'}</span></div>
29
- {binding.description&&<p className="muted">{binding.description}</p>}
30
- {!compact&&binding.filterNames&&<p className="filter-scope">控件覆盖:{binding.filterNames.length?binding.filterNames.join('、'):'沿用源组件条件'}</p>}
31
- {state.loading&&<p className="empty" role="status">正在获取实时数据…</p>}
32
- {state.error&&<div className="component-error" role="alert"><p>{'code' in state.error?String(state.error.code)+' · ':''}{state.error.message}</p><button onClick={onRetry}>重试组件</button></div>}
33
- {result&&<>
34
- {compact?<details className="query-details"><summary>查询范围{selected?` · ${selected.returnedRowCount} 行`:''}</summary><p>{result.range.mode} · {result.normalization} · 后续数据:{String(result.range.hasNext)}</p><p>筛选作用:{binding.filterNames?.join('、')||'沿用源组件条件'}</p></details>:<p className="result-range">{result.range.mode} · 本次{selected?` ${selected.returnedRowCount} 行`:'多个模型,需显式选择'}{result.range.total.value!==null?` · 查询总行数 ${result.range.total.value}`:''} · hasNext={String(result.range.hasNext)} · {result.normalization}</p>}
35
- {result.range.axes&&<p className="result-range">交叉表当前视图 · 横向还有数据:{result.range.axes.horizontal.hasNext===null?'未知':result.range.axes.horizontal.hasNext?'是':'否'} · 纵向还有数据:{result.range.axes.vertical.hasNext===null?'未知':result.range.axes.vertical.hasNext?'是':'否'}</p>}
36
- {(result.warnings.length>0||result.normalization!=='COMPLETE'||result.range.hasNext===true)&&<div className="notice" role="status">{result.range.hasNext||result.warnings.includes('NEXT_PAGE_UNSUPPORTED')?'当前仅展示首屏返回结果,本地分页不代表服务端续页。':'部分源图形未转换,当前展示已返回的数据模型。'}{compact?<details><summary>查看适配说明</summary><small>{result.normalization} · {result.warnings.join(' · ')}</small></details>:<small>{result.normalization} · {result.warnings.join(' · ')}</small>}</div>}
37
- <Boundary key={state.at} onRetry={onRetry}><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
38
- </>}
39
- </section>;
40
- }
1
+ import {Component, lazy, Suspense, type ReactNode} from 'react';
2
+ import type {QueryResult, WidgetBinding} from '../finebi/types';
3
+ import {displayCell, selectDataset} from '../services/view-data';
4
+ import {DataTable} from './DataTable';
5
+ import type {WidgetState} from '../services/widget-queries';
6
+ import {formatMetric} from '../services/presentation';
7
+ const charts={bar:lazy(()=>import('./charts/Bar')),line:lazy(()=>import('./charts/Line')),pie:lazy(()=>import('./charts/Pie'))};
8
+ export class Boundary extends Component<{children: ReactNode;onRetry?:()=>void}, {error: string}> {
9
+ state = {error: ''};
10
+ static getDerivedStateFromError(error: Error) {return {error: error.message};}
11
+ render() {return this.state.error ? <div role="alert"><p>{this.state.error}</p>{this.props.onRetry&&<button onClick={this.props.onRetry}>重试组件</button>}</div> : this.props.children;}
12
+ }
13
+ function Content({result, binding, pageSize}: {result: QueryResult; binding: WidgetBinding; pageSize: number}) {
14
+ const data = selectDataset(result, binding.datasetId);
15
+ if (!data.rows.length) return <p className="empty">当前查询没有数据</p>;
16
+ if (binding.type === 'table') return <DataTable dataset={data} pageSize={pageSize} binding={binding}/>;
17
+ if (binding.type === 'kpi') {
18
+ const index = data.columns.findIndex(c => c.fieldId === binding.valueFieldId);
19
+ if (data.rows.length !== 1 || index < 0) throw new Error('KPI 需要单行指标结果,不能自动累加图表行');
20
+ return <div className="kpi" title={displayCell(data.rows[0].cells[index])}>{formatMetric(data.rows[0].cells[index],binding.valueFormat)}</div>;
21
+ }
22
+ const Chart=charts[binding.type];
23
+ return <Suspense fallback={<p role="status">正在加载图表…</p>}><Chart dataset={data} binding={binding}/></Suspense>;
24
+ }
25
+ export function Widget({state,binding,pageSize,onRetry,compact=false}: {state:WidgetState; binding:WidgetBinding;pageSize:number;onRetry:()=>void;compact?:boolean}) {
26
+ const result=state.result;
27
+ const selected=result?.datasets.find(d=>binding.datasetId?d.id===binding.datasetId:result.datasets.length===1);
28
+ return <section className={`panel widget-${binding.type} ${binding.type==='table'?'wide':''}`}><div className="panel-heading"><h2>{binding.name}</h2><span className={`component-status ${state.error?'failed':''}`}>{state.loading?'加载中':state.error?'查询失败':state.at?`更新于 ${state.at}`:'等待查询'}</span></div>
29
+ {binding.description&&<p className="muted">{binding.description}</p>}
30
+ {!compact&&binding.filterNames&&<p className="filter-scope">控件覆盖:{binding.filterNames.length?binding.filterNames.join('、'):'沿用源组件条件'}</p>}
31
+ {state.loading&&<p className="empty" role="status">正在获取实时数据…</p>}
32
+ {state.error&&<div className="component-error" role="alert"><p>{'code' in state.error?String(state.error.code)+' · ':''}{state.error.message}</p><button onClick={onRetry}>重试组件</button></div>}
33
+ {result&&<>
34
+ {compact?<details className="query-details"><summary>查询范围{selected?` · ${selected.returnedRowCount} 行`:''}</summary><p>{result.range.mode} · {result.normalization} · 后续数据:{String(result.range.hasNext)}</p><p>筛选作用:{binding.filterNames?.join('、')||'沿用源组件条件'}</p></details>:<p className="result-range">{result.range.mode} · 本次{selected?` ${selected.returnedRowCount} 行`:'多个模型,需显式选择'}{result.range.total.value!==null?` · 查询总行数 ${result.range.total.value}`:''} · hasNext={String(result.range.hasNext)} · {result.normalization}</p>}
35
+ {result.range.axes&&<p className="result-range">交叉表当前视图 · 横向还有数据:{result.range.axes.horizontal.hasNext===null?'未知':result.range.axes.horizontal.hasNext?'是':'否'} · 纵向还有数据:{result.range.axes.vertical.hasNext===null?'未知':result.range.axes.vertical.hasNext?'是':'否'}</p>}
36
+ {(result.warnings.length>0||result.normalization!=='COMPLETE'||result.range.hasNext===true)&&<div className="notice" role="status">{result.range.hasNext||result.warnings.includes('NEXT_PAGE_UNSUPPORTED')?'当前仅展示首屏返回结果,本地分页不代表服务端续页。':'部分源图形未转换,当前展示已返回的数据模型。'}{compact?<details><summary>查看适配说明</summary><small>{result.normalization} · {result.warnings.join(' · ')}</small></details>:<small>{result.normalization} · {result.warnings.join(' · ')}</small>}</div>}
37
+ <Boundary key={state.at} onRetry={onRetry}><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
38
+ </>}
39
+ </section>;
40
+ }
@@ -1,3 +1,3 @@
1
- // Keep this list aligned with the languages configured by the application.
2
- export {zhCN} from 'date-fns/locale/zh-CN';
3
- export {enUS} from 'date-fns/locale/en-US';
1
+ // Keep this list aligned with the languages configured by the application.
2
+ export {zhCN} from 'date-fns/locale/zh-CN';
3
+ export {enUS} from 'date-fns/locale/en-US';
@@ -1,14 +1,14 @@
1
- import dateFnsConfig from '@rc-component/picker/generate/dateFns';
2
- import {isDateValue} from '../finebi/date.js';
3
-
4
- // Keep the picker on the same 0001–9999 calendar range as the filter contract.
5
- export const calendarConfig = dateFnsConfig;
6
- export function calendarValue(value:string):Date|null {
7
- const fullDate = value.length===7 ? value+'-01' : value;
8
- if (!isDateValue(fullDate)) return null;
9
- const [year,month,day] = fullDate.split('-').map(Number);
10
- const date = new Date(0);
11
- date.setHours(0,0,0,0);
12
- date.setFullYear(year,month-1,day);
13
- return date;
14
- }
1
+ import dateFnsConfig from '@rc-component/picker/generate/dateFns';
2
+ import {isDateValue} from '../finebi/date.js';
3
+
4
+ // Keep the picker on the same 0001–9999 calendar range as the filter contract.
5
+ export const calendarConfig = dateFnsConfig;
6
+ export function calendarValue(value:string):Date|null {
7
+ const fullDate = value.length===7 ? value+'-01' : value;
8
+ if (!isDateValue(fullDate)) return null;
9
+ const [year,month,day] = fullDate.split('-').map(Number);
10
+ const date = new Date(0);
11
+ date.setHours(0,0,0,0);
12
+ date.setFullYear(year,month-1,day);
13
+ return date;
14
+ }
@@ -1,6 +1,6 @@
1
- import {use} from 'echarts/core';
2
- import {BarChart} from 'echarts/charts';
3
- import {GridComponent} from 'echarts/components';
4
-
5
- use([BarChart, GridComponent]);
6
- export {Chart as default} from '../Chart';
1
+ import {use} from 'echarts/core';
2
+ import {BarChart} from 'echarts/charts';
3
+ import {GridComponent} from 'echarts/components';
4
+
5
+ use([BarChart, GridComponent]);
6
+ export {Chart as default} from '../Chart';
@@ -1,6 +1,6 @@
1
- import {use} from 'echarts/core';
2
- import {LineChart} from 'echarts/charts';
3
- import {GridComponent} from 'echarts/components';
4
-
5
- use([LineChart, GridComponent]);
6
- export {Chart as default} from '../Chart';
1
+ import {use} from 'echarts/core';
2
+ import {LineChart} from 'echarts/charts';
3
+ import {GridComponent} from 'echarts/components';
4
+
5
+ use([LineChart, GridComponent]);
6
+ export {Chart as default} from '../Chart';
@@ -1,6 +1,6 @@
1
- import {use} from 'echarts/core';
2
- import {PieChart} from 'echarts/charts';
3
- import {LegendComponent} from 'echarts/components';
4
-
5
- use([PieChart, LegendComponent]);
6
- export {Chart as default} from '../Chart';
1
+ import {use} from 'echarts/core';
2
+ import {PieChart} from 'echarts/charts';
3
+ import {LegendComponent} from 'echarts/components';
4
+
5
+ use([PieChart, LegendComponent]);
6
+ export {Chart as default} from '../Chart';
@@ -1 +1 @@
1
- .month-picker{position:relative;--month-accent:#197a58;--month-soft:#eaf6ef}.month-trigger{display:flex;justify-content:space-between;align-items:center;width:100%;height:46px;padding:0 14px;border:1px solid #dce4df;border-radius:10px;text-align:left;color:#87978f;background:#fff}.month-trigger.has-value{color:#204c3c}.month-trigger:focus-visible,.month-popover button:focus-visible{outline:3px solid #a1d6bc;outline-offset:2px}.month-trigger[aria-expanded=true]{border-color:var(--month-accent)}.month-dismiss{position:fixed;inset:0;z-index:20}.month-popover{position:absolute;right:0;top:calc(100% + 8px);z-index:21;width:min(310px,calc(100vw - 48px));padding:15px;border:1px solid #dce8e1;border-radius:15px;background:#fff;box-shadow:0 14px 45px #153f2926;color:#264c3d;max-height:65vh;overflow:auto}.month-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.month-popover button{border:0;background:transparent;border-radius:8px;padding:8px 12px}.month-head>button{font-size:19px}.month-head .month-year{font-size:14px;font-weight:700}.month-grid>[role=row]{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:6px 0}.month-grid button{height:41px}.month-popover button:hover{background:var(--month-soft)}.month-grid button.selected{background:var(--month-accent);color:#fff}.month-actions{display:flex;justify-content:space-between;gap:4px;border-top:1px solid #e9efeb;margin-top:13px;padding-top:10px}.month-actions button{font-size:12px;color:var(--month-accent);padding:7px}.month-popover p{font-size:11px;color:#87978f;margin:9px 0 0;text-align:center}
1
+ .month-picker{position:relative;--month-accent:#197a58;--month-soft:#eaf6ef}.month-trigger{display:flex;justify-content:space-between;align-items:center;width:100%;height:46px;padding:0 14px;border:1px solid #dce4df;border-radius:10px;text-align:left;color:#87978f;background:#fff}.month-trigger.has-value{color:#204c3c}.month-trigger:focus-visible,.month-popover button:focus-visible{outline:3px solid #a1d6bc;outline-offset:2px}.month-trigger[aria-expanded=true]{border-color:var(--month-accent)}.month-dismiss{position:fixed;inset:0;z-index:20}.month-popover{position:absolute;right:0;top:calc(100% + 8px);z-index:21;width:min(310px,calc(100vw - 48px));padding:15px;border:1px solid #dce8e1;border-radius:15px;background:#fff;box-shadow:0 14px 45px #153f2926;color:#264c3d;max-height:65vh;overflow:auto}.month-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.month-popover button{border:0;background:transparent;border-radius:8px;padding:8px 12px}.month-head>button{font-size:19px}.month-head .month-year{font-size:14px;font-weight:700}.month-grid>[role=row]{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:6px 0}.month-grid button{height:41px}.month-popover button:hover{background:var(--month-soft)}.month-grid button.selected{background:var(--month-accent);color:#fff}.month-actions{display:flex;justify-content:space-between;gap:4px;border-top:1px solid #e9efeb;margin-top:13px;padding-top:10px}.month-actions button{font-size:12px;color:var(--month-accent);padding:7px}.month-popover p{font-size:11px;color:#87978f;margin:9px 0 0;text-align:center}
@@ -1,3 +1,3 @@
1
- export function configErrors(config: unknown): string[];
2
- export function sameDimensionIds(left: string[] | undefined, right: string[] | undefined): boolean;
3
- export function validateDimensionSelections(widgets: {entryId: string; dimensionIds?: string[]}[], widgetIds: string[], selections: unknown): void;
1
+ export function configErrors(config: unknown): string[];
2
+ export function sameDimensionIds(left: string[] | undefined, right: string[] | undefined): boolean;
3
+ export function validateDimensionSelections(widgets: {entryId: string; dimensionIds?: string[]}[], widgetIds: string[], selections: unknown): void;
@@ -1,58 +1,58 @@
1
- import {validateFilterBindings} from './filters.js';
2
- const nonempty=value=>typeof value==='string'&&value.trim().length>0;
3
- const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
4
- const validDimensionIds=value=>Array.isArray(value)&&value.every(nonempty)&&new Set(value).size===value.length;
5
- export function sameDimensionIds(left,right) {
6
- if(left===undefined||right===undefined)return left===right;
7
- return validDimensionIds(left)&&validDimensionIds(right)&&left.length===right.length&&left.every(id=>right.includes(id));
8
- }
9
- export function validateDimensionSelections(widgets,widgetIds,selections) {
10
- if(selections!==undefined&&(!record(selections)||Object.keys(selections).some(id=>!widgetIds.includes(id))))throw new Error('Invalid dimension selections');
11
- for(const id of widgetIds) {
12
- const selected=selections&&Object.hasOwn(selections,id)?selections[id]:undefined;
13
- if(selected!==undefined&&!validDimensionIds(selected))throw new Error('Invalid dimensionIds');
14
- if(!widgets.some(w=>w.entryId===id&&sameDimensionIds(w.dimensionIds,selected)))throw new Error('Unconfigured dimension selection');
15
- }
16
- }
17
- export function configErrors(config) {
18
- if(!record(config))return ['configuration must be a JSON object.'];
19
- const errors=[];
20
- const allowed=['schemaVersion','title','basePath','layout','dashboardId','directoryEntryId','maxRows','pageSize','widgets','filters','templateVersion','description','insights','dataNotes'];
21
- for(const key of Object.keys(config))if(!allowed.includes(key))errors.push(`unsupported config field: ${key}.`);
22
- if(config.schemaVersion!=='1')errors.push('schemaVersion must be "1".');
23
- if(!nonempty(config.title))errors.push('title is required.');
24
- if(!nonempty(config.basePath)||!/^\/(?!\/)[\w/-]+$/.test(config.basePath))errors.push('basePath must be a same-origin path.');
25
- if(!['overview','workbench','cockpit','retail'].includes(config.layout))errors.push('layout must be overview, workbench, cockpit or retail.');
26
- if(!nonempty(config.dashboardId))errors.push('project is not bound: dashboardId must be configured.');
27
- if('directoryEntryId' in config&&!nonempty(config.directoryEntryId))errors.push('directoryEntryId must be a non-empty string.');
28
- if(!Number.isSafeInteger(config.maxRows)||config.maxRows<1||config.maxRows>10000)errors.push('maxRows must be an integer from 1 to 10000.');
29
- if(!Number.isSafeInteger(config.pageSize)||config.pageSize<1||config.pageSize>100)errors.push('pageSize must be an integer from 1 to 100.');
30
- if('description' in config&&!nonempty(config.description))errors.push('description must be a non-empty string.');
31
- if('insights' in config&&typeof config.insights!=='boolean')errors.push('insights must be boolean.');
32
- if('dataNotes' in config&&(!Array.isArray(config.dataNotes)||config.dataNotes.some(v=>!nonempty(v))))errors.push('dataNotes must be text array.');
33
- try{validateFilterBindings(config.filters);}catch(error){errors.push(error.message);}
34
- if(!Array.isArray(config.widgets)||!config.widgets.length)return [...errors,'widgets must be a non-empty array.'];
35
- for(const [i,w] of config.widgets.entries()) {
36
- if(!record(w)){errors.push(`widgets[${i}] must be an object.`);continue;}
37
- const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone','dimensionIds'];
38
- for(const field of Object.keys(w))if(!fields.includes(field))errors.push(`widgets[${i}].${field} is unsupported.`);
39
- for(const field of ['entryId','name'])if(!nonempty(w[field]))errors.push(`widgets[${i}].${field} is required.`);
40
- if('dimensionIds' in w&&!validDimensionIds(w.dimensionIds))errors.push(`widgets[${i}].dimensionIds must be unique non-empty strings.`);
41
- if(!['table','kpi','bar','line','pie'].includes(w.type))errors.push(`widgets[${i}].type is unsupported.`);
42
- if('valueFormat' in w&&(w.type!=='kpi'||!['number','percent'].includes(w.valueFormat)))errors.push(`widgets[${i}].valueFormat requires a KPI and number or percent.`);
43
- if('categoryFormat' in w||'categoryTimeZone' in w) {
44
- if(!['line','bar'].includes(w.type)||w.categoryFormat!=='epoch-month'||!nonempty(w.categoryTimeZone))errors.push(`widgets[${i}].categoryFormat requires a line/bar, epoch-month and an explicit categoryTimeZone.`);
45
- else try{new Intl.DateTimeFormat('en',{timeZone:w.categoryTimeZone});}catch{errors.push(`widgets[${i}].categoryTimeZone is invalid.`);}
46
- }
47
- for(const field of ['datasetId','categoryFieldId','valueFieldId','description'])if(field in w&&!nonempty(w[field]))errors.push(`widgets[${i}].${field} must be non-empty.`);
48
- if(w.type!=='table'&&!nonempty(w.valueFieldId)&&!w.series)errors.push(`widgets[${i}].valueFieldId is required.`);
49
- if(!['table','kpi'].includes(w.type)&&!nonempty(w.categoryFieldId))errors.push(`widgets[${i}].categoryFieldId is required.`);
50
- if('series' in w&&(!['line','bar'].includes(w.type)||!Array.isArray(w.series)||!w.series.length||w.series.some(s=>!record(s)||Object.keys(s).some(k=>!['fieldId','label'].includes(k))||!nonempty(s.fieldId)||!nonempty(s.label))||new Set(w.series.map(s=>s?.fieldId)).size!==w.series.length))errors.push(`widgets[${i}].series is invalid.`);
51
- for(const field of ['filterNames','tableFieldIds','localFilterFieldIds'])if(field in w) {
52
- if(!Array.isArray(w[field])||w[field].some(v=>!nonempty(v))||new Set(w[field]).size!==w[field].length)errors.push(`widgets[${i}].${field} must be unique strings.`);
53
- else if(field==='filterNames'&&w[field].some(name=>!Array.isArray(config.filters)||!config.filters.some(f=>f?.name===name)))errors.push(`widgets[${i}].filterNames references an unknown filter.`);
54
- else if(field!=='filterNames'&&(w.type!=='table'||!w[field].length))errors.push(`widgets[${i}].${field} is only for tables and must be non-empty.`);
55
- }
56
- }
57
- return errors;
58
- }
1
+ import {validateFilterBindings} from './filters.js';
2
+ const nonempty=value=>typeof value==='string'&&value.trim().length>0;
3
+ const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
4
+ const validDimensionIds=value=>Array.isArray(value)&&value.every(nonempty)&&new Set(value).size===value.length;
5
+ export function sameDimensionIds(left,right) {
6
+ if(left===undefined||right===undefined)return left===right;
7
+ return validDimensionIds(left)&&validDimensionIds(right)&&left.length===right.length&&left.every(id=>right.includes(id));
8
+ }
9
+ export function validateDimensionSelections(widgets,widgetIds,selections) {
10
+ if(selections!==undefined&&(!record(selections)||Object.keys(selections).some(id=>!widgetIds.includes(id))))throw new Error('Invalid dimension selections');
11
+ for(const id of widgetIds) {
12
+ const selected=selections&&Object.hasOwn(selections,id)?selections[id]:undefined;
13
+ if(selected!==undefined&&!validDimensionIds(selected))throw new Error('Invalid dimensionIds');
14
+ if(!widgets.some(w=>w.entryId===id&&sameDimensionIds(w.dimensionIds,selected)))throw new Error('Unconfigured dimension selection');
15
+ }
16
+ }
17
+ export function configErrors(config) {
18
+ if(!record(config))return ['configuration must be a JSON object.'];
19
+ const errors=[];
20
+ const allowed=['schemaVersion','title','basePath','layout','dashboardId','directoryEntryId','maxRows','pageSize','widgets','filters','templateVersion','description','insights','dataNotes'];
21
+ for(const key of Object.keys(config))if(!allowed.includes(key))errors.push(`unsupported config field: ${key}.`);
22
+ if(config.schemaVersion!=='1')errors.push('schemaVersion must be "1".');
23
+ if(!nonempty(config.title))errors.push('title is required.');
24
+ if(!nonempty(config.basePath)||!/^\/(?!\/)[\w/-]+$/.test(config.basePath))errors.push('basePath must be a same-origin path.');
25
+ if(!['overview','workbench','cockpit','retail'].includes(config.layout))errors.push('layout must be overview, workbench, cockpit or retail.');
26
+ if(!nonempty(config.dashboardId))errors.push('project is not bound: dashboardId must be configured.');
27
+ if('directoryEntryId' in config&&!nonempty(config.directoryEntryId))errors.push('directoryEntryId must be a non-empty string.');
28
+ if(!Number.isSafeInteger(config.maxRows)||config.maxRows<1||config.maxRows>10000)errors.push('maxRows must be an integer from 1 to 10000.');
29
+ if(!Number.isSafeInteger(config.pageSize)||config.pageSize<1||config.pageSize>100)errors.push('pageSize must be an integer from 1 to 100.');
30
+ if('description' in config&&!nonempty(config.description))errors.push('description must be a non-empty string.');
31
+ if('insights' in config&&typeof config.insights!=='boolean')errors.push('insights must be boolean.');
32
+ if('dataNotes' in config&&(!Array.isArray(config.dataNotes)||config.dataNotes.some(v=>!nonempty(v))))errors.push('dataNotes must be text array.');
33
+ try{validateFilterBindings(config.filters);}catch(error){errors.push(error.message);}
34
+ if(!Array.isArray(config.widgets)||!config.widgets.length)return [...errors,'widgets must be a non-empty array.'];
35
+ for(const [i,w] of config.widgets.entries()) {
36
+ if(!record(w)){errors.push(`widgets[${i}] must be an object.`);continue;}
37
+ const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone','dimensionIds'];
38
+ for(const field of Object.keys(w))if(!fields.includes(field))errors.push(`widgets[${i}].${field} is unsupported.`);
39
+ for(const field of ['entryId','name'])if(!nonempty(w[field]))errors.push(`widgets[${i}].${field} is required.`);
40
+ if('dimensionIds' in w&&!validDimensionIds(w.dimensionIds))errors.push(`widgets[${i}].dimensionIds must be unique non-empty strings.`);
41
+ if(!['table','kpi','bar','line','pie'].includes(w.type))errors.push(`widgets[${i}].type is unsupported.`);
42
+ if('valueFormat' in w&&(w.type!=='kpi'||!['number','percent'].includes(w.valueFormat)))errors.push(`widgets[${i}].valueFormat requires a KPI and number or percent.`);
43
+ if('categoryFormat' in w||'categoryTimeZone' in w) {
44
+ if(!['line','bar'].includes(w.type)||w.categoryFormat!=='epoch-month'||!nonempty(w.categoryTimeZone))errors.push(`widgets[${i}].categoryFormat requires a line/bar, epoch-month and an explicit categoryTimeZone.`);
45
+ else try{new Intl.DateTimeFormat('en',{timeZone:w.categoryTimeZone});}catch{errors.push(`widgets[${i}].categoryTimeZone is invalid.`);}
46
+ }
47
+ for(const field of ['datasetId','categoryFieldId','valueFieldId','description'])if(field in w&&!nonempty(w[field]))errors.push(`widgets[${i}].${field} must be non-empty.`);
48
+ if(w.type!=='table'&&!nonempty(w.valueFieldId)&&!w.series)errors.push(`widgets[${i}].valueFieldId is required.`);
49
+ if(!['table','kpi'].includes(w.type)&&!nonempty(w.categoryFieldId))errors.push(`widgets[${i}].categoryFieldId is required.`);
50
+ if('series' in w&&(!['line','bar'].includes(w.type)||!Array.isArray(w.series)||!w.series.length||w.series.some(s=>!record(s)||Object.keys(s).some(k=>!['fieldId','label'].includes(k))||!nonempty(s.fieldId)||!nonempty(s.label))||new Set(w.series.map(s=>s?.fieldId)).size!==w.series.length))errors.push(`widgets[${i}].series is invalid.`);
51
+ for(const field of ['filterNames','tableFieldIds','localFilterFieldIds'])if(field in w) {
52
+ if(!Array.isArray(w[field])||w[field].some(v=>!nonempty(v))||new Set(w[field]).size!==w[field].length)errors.push(`widgets[${i}].${field} must be unique strings.`);
53
+ else if(field==='filterNames'&&w[field].some(name=>!Array.isArray(config.filters)||!config.filters.some(f=>f?.name===name)))errors.push(`widgets[${i}].filterNames references an unknown filter.`);
54
+ else if(field!=='filterNames'&&(w.type!=='table'||!w[field].length))errors.push(`widgets[${i}].${field} is only for tables and must be non-empty.`);
55
+ }
56
+ }
57
+ return errors;
58
+ }
@@ -1 +1 @@
1
- export function isDateValue(value: unknown): boolean;
1
+ export function isDateValue(value: unknown): boolean;
@@ -1,5 +1,5 @@
1
- export function isDateValue(value) {
2
- if (typeof value !== 'string' || !/^(?!0000)\d{4}-\d{2}-\d{2}$/.test(value)) return false;
3
- const date = new Date(`${value}T00:00:00.000Z`);
4
- return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
5
- }
1
+ export function isDateValue(value) {
2
+ if (typeof value !== 'string' || !/^(?!0000)\d{4}-\d{2}-\d{2}$/.test(value)) return false;
3
+ const date = new Date(`${value}T00:00:00.000Z`);
4
+ return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
5
+ }
@@ -1,4 +1,4 @@
1
- import type {FilterBinding, FilterState} from './types';
2
- export function validateFilterBindings(bindings: unknown): void;
3
- export function encodeFilters(bindings: FilterBinding[], state?: FilterState): Record<string,string[]>;
4
- export function validateRefer(bindings: FilterBinding[], refer?: unknown): void;
1
+ import type {FilterBinding, FilterState} from './types';
2
+ export function validateFilterBindings(bindings: unknown): void;
3
+ export function encodeFilters(bindings: FilterBinding[], state?: FilterState): Record<string,string[]>;
4
+ export function validateRefer(bindings: FilterBinding[], refer?: unknown): void;