@finebi/finebi-cli 1.5.0 → 1.6.0
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.
- package/README.md +10 -0
- package/package.json +4 -3
- package/skills/finebi-cli-agent/SKILL.md +8 -4
- package/skills/finebi-cli-agent/references/command-workflows.md +7 -3
- package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +8 -1018
- package/skills/finebi-cli-agent/references/dashboard-dsl-core.md +194 -0
- package/skills/finebi-cli-agent/references/dashboard-dsl-examples.md +378 -0
- package/skills/finebi-cli-agent/references/dashboard-dsl-items.md +456 -0
- package/skills/finebi-cli-agent/references/mapping-rules.md +14 -14
- package/skills/finebi-cli-agent/references/scenario-workflows.md +1 -1
- package/skills/finebi-cli-agent/references/web-app.md +13 -0
- package/skills/finebi-cli-agent/references/widget-dsl-api-examples.md +3 -4
- package/skills/finebi-cli-agent/references/widget-dsl-api.md +10 -1374
- package/skills/finebi-cli-agent/references/widget-dsl-contexts.md +142 -0
- package/skills/finebi-cli-agent/references/widget-dsl-core.md +374 -0
- package/skills/finebi-cli-agent/references/widget-dsl-encodings.md +457 -0
- package/skills/finebi-cli-agent/references/widget-dsl-styles.md +413 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +27 -27
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +78 -55
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/index.js +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +27 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +30 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +21 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +89 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2191 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +15 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +56 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +14 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +42 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +28 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +37 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +77 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/styles.css +8 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/calendar.test.mjs +25 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +84 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +136 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +189 -0
- package/src/config.js +27 -27
- package/src/help.js +78 -55
- package/src/index.js +5 -0
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +94 -94
- package/templates/web-app/AGENTS.md +27 -0
- package/templates/web-app/README.md +30 -0
- package/templates/web-app/docs/auth-and-deploy.md +17 -0
- package/templates/web-app/docs/data-contract.md +21 -0
- package/templates/web-app/docs/design.md +13 -0
- package/templates/web-app/docs/filters.md +89 -0
- package/templates/web-app/index.html +2 -0
- package/templates/web-app/package-lock.json +2191 -0
- package/templates/web-app/package.json +15 -0
- package/templates/web-app/public/app-config.json +11 -0
- package/templates/web-app/scripts/dev-proxy.mjs +56 -0
- package/templates/web-app/src/App.tsx +31 -0
- package/templates/web-app/src/components/Chart.tsx +31 -0
- package/templates/web-app/src/components/DataTable.tsx +14 -0
- package/templates/web-app/src/components/FilterBar.tsx +42 -0
- package/templates/web-app/src/components/Widget.tsx +28 -0
- package/templates/web-app/src/components/calendar.ts +14 -0
- package/templates/web-app/src/finebi/client.ts +37 -0
- package/templates/web-app/src/finebi/date.d.ts +1 -0
- package/templates/web-app/src/finebi/date.js +5 -0
- package/templates/web-app/src/finebi/filters.d.ts +4 -0
- package/templates/web-app/src/finebi/filters.js +106 -0
- package/templates/web-app/src/finebi/normalize.ts +77 -0
- package/templates/web-app/src/finebi/types.ts +18 -0
- package/templates/web-app/src/main.tsx +5 -0
- package/templates/web-app/src/services/view-data.ts +18 -0
- package/templates/web-app/src/styles.css +8 -0
- package/templates/web-app/tests/calendar.test.mjs +25 -0
- package/templates/web-app/tests/proxy.test.mjs +84 -0
- package/templates/web-app/tests/runtime.test.mjs +136 -0
- package/templates/web-app/tsconfig.json +4 -0
- package/templates/web-app/vite.config.ts +13 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "finebi-web-app",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {"node": ">=18.17"},
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "vite --host 127.0.0.1",
|
|
9
|
+
"build": "tsc --noEmit && vite build",
|
|
10
|
+
"preview": "vite preview --host 127.0.0.1",
|
|
11
|
+
"test": "node --test tests/*.test.mjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {"@rc-component/picker": "1.12.2", "antd": "6.6.7", "date-fns": "4.1.0", "echarts": "5.6.0", "react": "18.3.1", "react-dom": "18.3.1"},
|
|
14
|
+
"devDependencies": {"@types/node": "22.13.10", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "typescript": "5.8.3", "vite": "5.4.21"}
|
|
15
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Development only: the browser never receives the developer's session token.
|
|
2
|
+
import {validateRefer} from '../src/finebi/filters.js';
|
|
3
|
+
export function createDevMiddleware(config, env = process.env, fetchImpl = fetch) {
|
|
4
|
+
const prefix = config.basePath.replace(/\/$/, '');
|
|
5
|
+
const queryPath = `${prefix}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`;
|
|
6
|
+
let token = env.FINEBI_DEV_TOKEN || '';
|
|
7
|
+
let login;
|
|
8
|
+
const reply = (res, status, errorCode) => {res.writeHead(status, {'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify({success:false,errorCode}));};
|
|
9
|
+
async function session() {
|
|
10
|
+
if (token) return token;
|
|
11
|
+
if (!env.FINEBI_DEV_USERNAME || !env.FINEBI_DEV_PASSWORD) throw new Error('AUTH_REQUIRED');
|
|
12
|
+
if (!login) login = (async()=>{
|
|
13
|
+
const response = await fetchImpl(`${env.FINEBI_DEV_BASE_URL.replace(/\/$/, '')}/login`, {method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''}),signal:AbortSignal.timeout(30000)});
|
|
14
|
+
const body = await response.json();
|
|
15
|
+
if (!response.ok || typeof body?.data?.accessToken !== 'string' || !body.data.accessToken) throw new Error('AUTH_REQUIRED');
|
|
16
|
+
token=body.data.accessToken;
|
|
17
|
+
return token;
|
|
18
|
+
})().finally(()=>{login=undefined;});
|
|
19
|
+
return login;
|
|
20
|
+
}
|
|
21
|
+
return async (req, res, next) => {
|
|
22
|
+
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
23
|
+
if (!pathname.startsWith(prefix+'/') && pathname !== prefix) return next();
|
|
24
|
+
const host = req.headers.host || '';
|
|
25
|
+
const origin = req.headers.origin;
|
|
26
|
+
if (!/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) || origin && origin !== `http://${host}` || req.headers['sec-fetch-site'] === 'cross-site') return reply(res,403,'PERMISSION_DENIED');
|
|
27
|
+
if (pathname !== queryPath || req.method !== 'POST' || req.headers['x-requested-with'] !== 'XMLHttpRequest') return reply(res,403,'PERMISSION_DENIED');
|
|
28
|
+
let body;
|
|
29
|
+
try {
|
|
30
|
+
let raw='';
|
|
31
|
+
for await (const chunk of req) {raw+=chunk.toString();if (raw.length>65536) throw new Error('BODY_TOO_LARGE');}
|
|
32
|
+
body=JSON.parse(raw);
|
|
33
|
+
if (!Array.isArray(body.widgetIds) || !body.widgetIds.length || body.widgetIds.some(id=>!config.widgets.some(w=>w.entryId===id)) || !Number.isSafeInteger(body.pageLimit) || body.pageLimit<1 || body.pageLimit>config.maxRows) throw new Error('INVALID_REQUEST');
|
|
34
|
+
validateRefer(config.filters || [],body.refer);
|
|
35
|
+
} catch {return reply(res,400,'INVALID_REQUEST');}
|
|
36
|
+
if (!env.FINEBI_DEV_BASE_URL) return reply(res,401,'AUTH_REQUIRED');
|
|
37
|
+
try {
|
|
38
|
+
const target = new URL(env.FINEBI_DEV_BASE_URL);
|
|
39
|
+
if (!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash) throw new Error('INVALID_TARGET');
|
|
40
|
+
const auth=await session();
|
|
41
|
+
const response=await fetchImpl(`${target.href.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
|
|
42
|
+
method:'POST',redirect:'manual',signal:AbortSignal.timeout(30000),
|
|
43
|
+
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest',Cookie:`fine_auth_token=${auth}`},
|
|
44
|
+
body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {}}),
|
|
45
|
+
});
|
|
46
|
+
if (response.status===401 || response.status>=300 && response.status<400 || response.headers.get('content-type')?.includes('text/html')) {token='';return reply(res,401,'AUTH_REQUIRED');}
|
|
47
|
+
if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
|
|
48
|
+
const json=await response.json();
|
|
49
|
+
if (response.status===401 || ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json.errorCode)) {token='';return reply(res,401,'AUTH_REQUIRED');}
|
|
50
|
+
if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
|
|
51
|
+
// Forward only the query envelope, never upstream headers or login payloads.
|
|
52
|
+
res.writeHead(response.status,{'Content-Type':'application/json','Cache-Control':'no-store'});
|
|
53
|
+
res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || 'QUERY_FAILED'}));
|
|
54
|
+
} catch (error) {reply(res,error.message==='AUTH_REQUIRED'?401:502,error.message==='AUTH_REQUIRED'?'AUTH_REQUIRED':'UPSTREAM_UNAVAILABLE');}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {useEffect, useState} from 'react';
|
|
2
|
+
import {FineBIError, queryWidgets, validateConfig} from './finebi/client';
|
|
3
|
+
import type {AppConfig, QueryResult, FilterState} from './finebi/types';
|
|
4
|
+
import {Widget} from './components/Widget';
|
|
5
|
+
import {FilterBar} from './components/FilterBar';
|
|
6
|
+
function Dashboard({config}: {config: AppConfig}) {
|
|
7
|
+
const [filters, setFilters] = useState<FilterState>({});
|
|
8
|
+
const [refresh, setRefresh] = useState(0);
|
|
9
|
+
const [state, setState] = useState<{loading:boolean; results?:Record<string,QueryResult>; error?:Error; at?:string}>({loading:true});
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const controller = new AbortController(); let current = true;
|
|
12
|
+
setState({loading:true});
|
|
13
|
+
queryWidgets(config, filters, controller.signal).then(results => {if(current) setState({loading:false,results,at:new Date().toLocaleTimeString()});}).catch(error => {if(current) setState({loading:false,error});});
|
|
14
|
+
return () => {current=false;controller.abort();};
|
|
15
|
+
},[config,filters,refresh]);
|
|
16
|
+
return <div className={`app ${config.layout}`}>
|
|
17
|
+
{config.layout==='workbench' && <aside><strong>FineBI</strong><a href="#dashboard">数据概览</a></aside>}
|
|
18
|
+
<main id="dashboard"><header><div><span className="eyebrow">FINEBI · LIVE DATA</span><h1>{config.title}</h1><p className="muted">{state.at ? `最近更新 ${state.at}` : '连接 FineBI 数据'} · {config.widgets.length} 个展示组件</p></div><button onClick={()=>setRefresh(n=>n+1)}>刷新</button></header>
|
|
19
|
+
{config.filters.length>0 && <FilterBar bindings={config.filters} onApply={setFilters}/>}
|
|
20
|
+
{state.loading && <p className="state" role="status">正在获取实时数据…</p>}
|
|
21
|
+
{state.error && <div className="state error" role="alert"><h2>{state.error instanceof FineBIError && state.error.code==='AUTH_REQUIRED'?'需要登录 FineBI':'数据暂不可用'}</h2><p>{state.error.message}</p><a href={config.basePath} target="_blank" rel="noreferrer">打开 FineBI</a><button onClick={()=>setRefresh(n=>n+1)}>重试</button></div>}
|
|
22
|
+
{state.results && <><p className="live" role="status">实时数据已更新</p><div className="grid">{config.widgets.map((binding,i)=><Widget key={`${refresh}-${JSON.stringify(filters)}-${i}`} result={state.results![binding.entryId]} binding={binding} pageSize={config.pageSize}/>)}</div></>}
|
|
23
|
+
<footer>数据范围以当前查询结果为准 · 权限由 FineBI 服务端控制</footer></main>
|
|
24
|
+
</div>;
|
|
25
|
+
}
|
|
26
|
+
export default function App() {
|
|
27
|
+
const [config, setConfig] = useState<AppConfig>(); const [error, setError] = useState('');
|
|
28
|
+
useEffect(()=>{const controller=new AbortController(); fetch('./app-config.json',{signal:controller.signal,cache:'no-store'}).then(r=>{if(!r.ok)throw Error('应用配置加载失败');return r.json();}).then(c=>{validateConfig(c);if(!Number.isSafeInteger(c.pageSize)||c.pageSize<1||c.pageSize>100)throw Error('pageSize 必须在 1–100 范围内');if(!Array.isArray(c.filters))throw Error('filters 必须为数组');setConfig(c);}).catch(e=>{if(!controller.signal.aborted)setError(e.message);});return()=>controller.abort();},[]);
|
|
29
|
+
if(error)return <main className="state" role="alert"><h1>请完成应用配置</h1><p>{error}</p><p>在 public/app-config.json 中绑定 CLI 发现的仪表板实例与字段。</p></main>;
|
|
30
|
+
return config ? <Dashboard config={config}/> : <p className="state">正在加载应用…</p>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import * as echarts from 'echarts/core';
|
|
3
|
+
import {BarChart, LineChart, PieChart} from 'echarts/charts';
|
|
4
|
+
import {GridComponent, TooltipComponent, LegendComponent} from 'echarts/components';
|
|
5
|
+
import {CanvasRenderer} from 'echarts/renderers';
|
|
6
|
+
import type {Dataset, WidgetBinding} from '../finebi/types';
|
|
7
|
+
import {chartValues} from '../services/view-data';
|
|
8
|
+
echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, 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 values = chartValues(dataset, binding);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!container.current) return;
|
|
15
|
+
const chart = echarts.init(container.current);
|
|
16
|
+
const data = [...values];
|
|
17
|
+
if (order !== 'source') data.sort((a, b) => a.value === null ? 1 : b.value === null ? -1 : (a.value - b.value) * (order === 'asc' ? 1 : -1));
|
|
18
|
+
chart.setOption({
|
|
19
|
+
color: ['#2563eb','#14b8a6','#f59e0b','#8b5cf6','#ec4899'],
|
|
20
|
+
tooltip: {trigger: binding.type === 'pie' ? 'item' : 'axis', renderMode: 'richText'},
|
|
21
|
+
legend: binding.type === 'pie' ? {bottom: 0} : undefined,
|
|
22
|
+
grid: {left: 65, right: 24, top: 24, bottom: 65},
|
|
23
|
+
xAxis: binding.type === 'pie' ? undefined : {type: 'category', data: data.map(d => d.name), axisLabel: {rotate: data.length > 8 ? 30 : 0}},
|
|
24
|
+
yAxis: binding.type === 'pie' ? undefined : {type: 'value'},
|
|
25
|
+
series: [{type: binding.type, data: binding.type === 'pie' ? data : data.map(d => d.value), radius: binding.type === 'pie' ? ['42%','67%'] : undefined}],
|
|
26
|
+
});
|
|
27
|
+
const observer = new ResizeObserver(() => chart.resize()); observer.observe(container.current);
|
|
28
|
+
return () => {observer.disconnect(); chart.dispose();};
|
|
29
|
+
}, [dataset, binding, order]);
|
|
30
|
+
return <><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><table><tbody>{values.map((v,i) => <tr key={i}><td>{v.name}</td><td>{v.value ?? '不可用'}</td></tr>)}</tbody></table></details></>;
|
|
31
|
+
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {useState} from 'react';
|
|
2
|
+
import type {Dataset} from '../finebi/types';
|
|
3
|
+
import {displayCell} from '../services/view-data';
|
|
4
|
+
export function DataTable({dataset, pageSize}: {dataset: Dataset; pageSize: number}) {
|
|
5
|
+
const [search, setSearch] = useState('');
|
|
6
|
+
const [page, setPage] = useState(0);
|
|
7
|
+
const rows = dataset.rows.filter(row => row.cells.some(c => displayCell(c).toLocaleLowerCase().includes(search.toLocaleLowerCase())));
|
|
8
|
+
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
|
|
9
|
+
const current = Math.min(page, pages - 1);
|
|
10
|
+
return <><div className="table-tools"><label>搜索已加载数据 <input placeholder="搜索已加载数据" value={search} onChange={e => {setSearch(e.target.value);setPage(0);}}/></label><span>已加载 {dataset.returnedRowCount} 行 · 匹配 {rows.length} 行</span></div>
|
|
11
|
+
<div className="table-scroll"><table><thead><tr><th>序号</th>{dataset.columns.map(c => <th key={c.key}>{c.name}</th>)}</tr></thead><tbody>{rows.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={dataset.columns[j].key}>{displayCell(c)}</td>)}</tr>)}</tbody></table></div>
|
|
12
|
+
{!rows.length && <p className="empty">没有匹配的数据</p>}
|
|
13
|
+
<nav className="pager" aria-label="已加载数据分页"><button disabled={current===0} onClick={()=>setPage(current-1)}>上一页</button><span>{current+1} / {pages}</span><button disabled={current===pages-1} onClick={()=>setPage(current+1)}>下一页</button></nav></>;
|
|
14
|
+
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {useId, useState} from 'react';
|
|
2
|
+
import {Alert, Button, Checkbox, ConfigProvider, DatePicker, Input, Select} from 'antd';
|
|
3
|
+
import zhCN from 'antd/locale/zh_CN';
|
|
4
|
+
import {calendarConfig, calendarValue} from './calendar';
|
|
5
|
+
import {encodeFilters} from '../finebi/filters.js';
|
|
6
|
+
import type {FilterBinding, FilterState, FilterValue} from '../finebi/types';
|
|
7
|
+
|
|
8
|
+
const CalendarPicker = DatePicker.generatePicker<Date>(calendarConfig);
|
|
9
|
+
|
|
10
|
+
export function FilterBar({bindings,onApply}: {bindings:FilterBinding[];onApply:(state:FilterState)=>void}) {
|
|
11
|
+
const id=useId();
|
|
12
|
+
const [draft,setDraft]=useState<FilterState>({});
|
|
13
|
+
const [applied,setApplied]=useState<FilterState>({});
|
|
14
|
+
const [error,setError]=useState('');
|
|
15
|
+
const update=(name:string,value:FilterValue)=>{setDraft(old=>({...old,[name]:value}));setError('');};
|
|
16
|
+
const apply=(state:FilterState)=>{
|
|
17
|
+
try {encodeFilters(bindings,state);setApplied(state);onApply(state);setError('');}
|
|
18
|
+
catch(e){setError((e as Error).message);}
|
|
19
|
+
};
|
|
20
|
+
const dirty=JSON.stringify(draft)!==JSON.stringify(applied);
|
|
21
|
+
return <ConfigProvider locale={zhCN} theme={{token:{colorPrimary:'#2563eb',colorText:'#253858',colorBorder:'#dce4ef',borderRadius:10,controlHeightLG:46,fontFamily:'Inter,"Microsoft YaHei",system-ui,sans-serif'},components:{Select:{optionSelectedBg:'#edf4ff'},DatePicker:{cellActiveWithRangeBg:'#edf4ff'}}}}>
|
|
22
|
+
<form className="filter-card" onSubmit={e=>{e.preventDefault();apply(draft);}}>
|
|
23
|
+
<div className="filter-heading"><div className="filter-heading-title"><span className="filter-symbol" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><path d="M4 7h16M7 12h10M10 17h4"/></svg></span><div><h2>筛选条件</h2><p>选择筛选条件,查看对应的数据</p></div></div><span className={`filter-status ${dirty?'pending':''}`}>{dirty?'待应用':'当前条件已应用'}</span></div>
|
|
24
|
+
<div className="filter-fields">{bindings.map((binding,index)=>{
|
|
25
|
+
const type=binding.type??'select', value=draft[binding.name], text=typeof value==='string'?value:'';
|
|
26
|
+
const isRange=type==='date-range'||type==='month-range';
|
|
27
|
+
const interval=value && typeof value==='object' && !Array.isArray(value)?value:{start:'',end:''};
|
|
28
|
+
const fieldId=`${id}-filter-${index}`;
|
|
29
|
+
const updateRange=(_dates:unknown,strings:[string,string]|null)=>update(binding.name,{start:strings?.[0]||'',end:strings?.[1]||''});
|
|
30
|
+
return <div className={`filter-field ${isRange?'date-field':''}`} key={binding.name}>
|
|
31
|
+
<div className="filter-label-row"><label htmlFor={isRange?fieldId+'-start':fieldId}>{binding.label}<span>{type==='multi-select'?'可多选':type==='month-range'?'按起止月份筛选':isRange?'按起止日期筛选':''}</span></label><button className="field-reset" type="button" aria-label={`恢复${binding.label}默认`} onClick={()=>update(binding.name,'')}>重置</button></div>
|
|
32
|
+
{isRange ? <><label className="sr-only" htmlFor={fieldId+'-start'}>{binding.label}开始日期</label><label className="sr-only" htmlFor={fieldId+'-end'}>{binding.label}结束日期</label><CalendarPicker.RangePicker size="large" className="filter-date-range" id={{start:fieldId+'-start',end:fieldId+'-end'}} picker={type==='month-range'?'month':'date'} format={type==='month-range'?'YYYY-MM':'YYYY-MM-DD'} placeholder={type==='month-range'?['开始月份','结束月份']:['开始日期','结束日期']} allowEmpty={[true,true]} allowClear value={[calendarValue(interval.start),calendarValue(interval.end)]} minDate={calendarValue('0001-01-01')!} maxDate={calendarValue('9999-12-31')!} onCalendarChange={updateRange} onChange={updateRange} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault();}}/></>
|
|
33
|
+
: binding.options ? <Select id={fieldId} 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=>update(binding.name,selected??'')} notFoundContent={`没有匹配的${binding.label}`}/>
|
|
34
|
+
: <Input id={fieldId} aria-label={binding.label} size="large" type={type==='date'?'date':type==='month'?'month':'text'} min={type==='date'?'0001-01-01':type==='month'?'0001-01':undefined} max={type==='date'?'9999-12-31':type==='month'?'9999-12':undefined} inputMode={type==='number'?'decimal':undefined} placeholder={type==='quarter'?'YYYY-Q,例如 2026-3':type==='year'?'YYYY':type==='text'?'输入精确匹配值':undefined} disabled={value===null} value={text} onChange={e=>update(binding.name,e.target.value)}/>}
|
|
35
|
+
<div className="field-hint">{binding.allowNull ? <Checkbox checked={value===null} onChange={e=>update(binding.name,e.target.checked?null:'')}>仅筛选空值</Checkbox> : isRange ? <span>可只填一端;全部留空沿用默认条件</span> : <span>留空沿用默认条件</span>}</div>
|
|
36
|
+
</div>;
|
|
37
|
+
})}</div>
|
|
38
|
+
<div className="filter-bottom"><p className={dirty?'unsaved-note':''}>{dirty?'条件已修改,点击「应用筛选」更新数据':'未设置的条件沿用看板保存值'}</p><div className="filter-buttons"><Button size="large" onClick={()=>{setDraft({});apply({});}}>恢复全部默认</Button><Button size="large" type="primary" htmlType="submit">应用筛选</Button></div></div>
|
|
39
|
+
{error && <Alert title={error} type="error" showIcon className="filter-alert"/>}
|
|
40
|
+
</form>
|
|
41
|
+
</ConfigProvider>;
|
|
42
|
+
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {Component, type ReactNode} from 'react';
|
|
2
|
+
import type {QueryResult, WidgetBinding} from '../finebi/types';
|
|
3
|
+
import {displayCell, selectDataset} from '../services/view-data';
|
|
4
|
+
import {Chart} from './Chart';
|
|
5
|
+
import {DataTable} from './DataTable';
|
|
6
|
+
class Boundary extends Component<{children: ReactNode}, {error: string}> {
|
|
7
|
+
state = {error: ''};
|
|
8
|
+
static getDerivedStateFromError(error: Error) {return {error: error.message};}
|
|
9
|
+
render() {return this.state.error ? <p role="alert">{this.state.error}</p> : this.props.children;}
|
|
10
|
+
}
|
|
11
|
+
function Content({result, binding, pageSize}: {result: QueryResult; binding: WidgetBinding; pageSize: number}) {
|
|
12
|
+
const data = selectDataset(result, binding.datasetId);
|
|
13
|
+
if (!data.rows.length) return <p className="empty">当前查询没有数据</p>;
|
|
14
|
+
if (binding.type === 'table') return <DataTable dataset={data} pageSize={pageSize}/>;
|
|
15
|
+
if (binding.type === 'kpi') {
|
|
16
|
+
const index = data.columns.findIndex(c => c.fieldId === binding.valueFieldId);
|
|
17
|
+
if (data.rows.length !== 1 || index < 0) throw new Error('KPI 需要单行指标结果,不能自动累加图表行');
|
|
18
|
+
return <div className="kpi">{displayCell(data.rows[0].cells[index])}</div>;
|
|
19
|
+
}
|
|
20
|
+
return <Chart dataset={data} binding={binding}/>;
|
|
21
|
+
}
|
|
22
|
+
export function Widget({result, binding, pageSize}: {result: QueryResult; binding: WidgetBinding; pageSize: number}) {
|
|
23
|
+
return <section className={`panel ${binding.type==='table'?'wide':''}`}><h2>{binding.name}</h2>
|
|
24
|
+
{result.range.total.value !== null && <p className="muted">查询总行数 {result.range.total.value} · 本次返回 {result.datasets[0]?.returnedRowCount ?? 0}</p>}
|
|
25
|
+
{result.warnings.length > 0 && <p className="notice" role="status">{result.warnings.includes('NEXT_PAGE_UNSUPPORTED') ? '接口仍标记有后续数据;当前仅展示本次返回结果,本地分页不代表服务端续页。' : '部分结果未能完整转换。'}<small>{result.warnings.join(' · ')}</small></p>}
|
|
26
|
+
<Boundary><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
|
|
27
|
+
</section>;
|
|
28
|
+
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {normalize} from './normalize';
|
|
2
|
+
import {encodeFilters, validateFilterBindings} from './filters.js';
|
|
3
|
+
import type {AppConfig, QueryResult, FilterState} from './types';
|
|
4
|
+
export class FineBIError extends Error {
|
|
5
|
+
constructor(public code: string, message: string) {super(message); this.name = 'FineBIError';}
|
|
6
|
+
}
|
|
7
|
+
export function validateConfig(config: AppConfig) {
|
|
8
|
+
if (config.schemaVersion !== '1') throw new Error('不支持的配置版本');
|
|
9
|
+
if (!/^\/(?!\/)[\w/-]+$/.test(config.basePath)) throw new Error('basePath 必须是同源绝对路径');
|
|
10
|
+
if (!config.dashboardId || !config.widgets?.length) throw new Error('请先在 app-config.json 绑定仪表板和组件');
|
|
11
|
+
if (!Number.isSafeInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 10000) throw new Error('maxRows 必须在 1–10000 范围内');
|
|
12
|
+
validateFilterBindings(config.filters);
|
|
13
|
+
}
|
|
14
|
+
export async function queryWidgets(config: AppConfig, filters: FilterState = {}, signal?: AbortSignal, fetchImpl: typeof fetch = fetch): Promise<Record<string, QueryResult>> {
|
|
15
|
+
validateConfig(config);
|
|
16
|
+
const refer = encodeFilters(config.filters,filters);
|
|
17
|
+
const ids = [...new Set(config.widgets.map(w => w.entryId))];
|
|
18
|
+
const response = await fetchImpl(`${config.basePath.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
|
|
19
|
+
method: 'POST', credentials: 'include', signal, redirect: 'manual',
|
|
20
|
+
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
|
|
21
|
+
body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer}),
|
|
22
|
+
});
|
|
23
|
+
if (response.status === 401 || response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
|
|
24
|
+
if (response.status === 403) throw new FineBIError('PERMISSION_DENIED', '当前用户没有访问权限');
|
|
25
|
+
if (response.headers.get('content-type')?.includes('text/html')) throw new FineBIError('AUTH_REQUIRED', '返回了登录页面,请重新登录 FineBI');
|
|
26
|
+
const body = await response.json().catch(() => {throw new FineBIError('INVALID_RESPONSE', 'FineBI 返回了无法解析的响应');});
|
|
27
|
+
const code = body.errorCode ?? body.code;
|
|
28
|
+
if (['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN','AUTH_REQUIRED'].includes(code)) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
|
|
29
|
+
if (code === 'PERMISSION_DENIED') throw new FineBIError('PERMISSION_DENIED', '当前用户没有数据或字段权限');
|
|
30
|
+
if (!response.ok || body.success !== true) throw new FineBIError('QUERY_FAILED', 'FineBI 查询失败,请检查组件、控件绑定及访问权限');
|
|
31
|
+
const results: Record<string, QueryResult> = {};
|
|
32
|
+
for (const id of ids) {
|
|
33
|
+
if (!body.data?.[id]?.previewResult) throw new FineBIError('INVALID_RESPONSE', `接口未返回组件 ${id},不能视为空数据`);
|
|
34
|
+
results[id] = normalize(body.data[id]);
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isDateValue(value: unknown): boolean;
|
|
@@ -0,0 +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;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {isDateValue} from './date.js';
|
|
2
|
+
|
|
3
|
+
const types = new Set(['text','select','multi-select','number','date','date-range','year','month','month-range','quarter','time-label']);
|
|
4
|
+
const choices = new Set(['select','multi-select','time-label']);
|
|
5
|
+
const nullable = new Set(['text','select','multi-select','number']);
|
|
6
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
8
|
+
const literal = value => typeof value === 'string' && value !== '' && value !== 'null' && !value.includes(',');
|
|
9
|
+
const kind = binding => binding.type ?? 'select';
|
|
10
|
+
const range = binding => ['date-range','month-range'].includes(kind(binding));
|
|
11
|
+
const keys = binding => range(binding) ? [binding.name+'_s',binding.name+'_e'] : [binding.name];
|
|
12
|
+
const fail = message => {throw new Error(`无效筛选:${message}`);};
|
|
13
|
+
|
|
14
|
+
// Shared by app inspect, browser requests and the development proxy.
|
|
15
|
+
export function validateFilterBindings(bindings) {
|
|
16
|
+
if (!Array.isArray(bindings)) fail('filters 必须为数组');
|
|
17
|
+
const names = new Set(), wireKeys = new Set();
|
|
18
|
+
for (const binding of bindings) {
|
|
19
|
+
if (!record(binding)) fail('配置必须为对象');
|
|
20
|
+
for (const key of Object.keys(binding)) if (!['name','label','type','options','allowNull'].includes(key)) fail(`不支持的配置字段 ${key}`);
|
|
21
|
+
if (!nonempty(binding.name) || !nonempty(binding.label)) fail('name 和 label 必须为非空文本');
|
|
22
|
+
if (names.has(binding.name)) fail(`重复名称 ${binding.name}`);
|
|
23
|
+
names.add(binding.name);
|
|
24
|
+
if (!types.has(kind(binding))) fail(`不支持的类型 ${binding.type}`);
|
|
25
|
+
if ('allowNull' in binding && (typeof binding.allowNull !== 'boolean' || !nullable.has(kind(binding)))) fail(`${binding.name} 不支持此 allowNull 配置`);
|
|
26
|
+
for (const key of keys(binding)) {
|
|
27
|
+
if (wireKeys.has(key)) fail(`请求键冲突 ${key}`);
|
|
28
|
+
wireKeys.add(key);
|
|
29
|
+
}
|
|
30
|
+
if (!choices.has(kind(binding))) {
|
|
31
|
+
if ('options' in binding) fail(`${binding.name} 不配置 options`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(binding.options)) fail(`${binding.name} 必须配置 options`);
|
|
35
|
+
const values = new Set();
|
|
36
|
+
for (const option of binding.options) {
|
|
37
|
+
if (!record(option) || Object.keys(option).some(key => !['label','value'].includes(key)) || !nonempty(option.label)
|
|
38
|
+
|| !(kind(binding)==='time-label' ? nonempty(option.value) : literal(option.value))) fail(`${binding.name} 含无法编码的选项(空串、逗号或保留字 null)`);
|
|
39
|
+
if (values.has(option.value)) fail(`${binding.name} 含重复选项`);
|
|
40
|
+
values.add(option.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function temporal(type, value) {
|
|
46
|
+
if (typeof value !== 'string') return false;
|
|
47
|
+
if (type === 'date' || type === 'date-range') return isDateValue(value);
|
|
48
|
+
if (type === 'year') return /^\d{4}$/.test(value) && value !== '0000';
|
|
49
|
+
if (type === 'quarter') return /^\d{4}-[1-4]$/.test(value) && !value.startsWith('0000-');
|
|
50
|
+
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value) && !value.startsWith('0000-');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function encodeFilters(bindings, state = {}) {
|
|
54
|
+
validateFilterBindings(bindings);
|
|
55
|
+
if (!record(state)) fail('状态必须为对象');
|
|
56
|
+
const result = Object.create(null);
|
|
57
|
+
for (const [name, value] of Object.entries(state)) {
|
|
58
|
+
const binding = bindings.find(item => item.name === name);
|
|
59
|
+
if (!binding) fail(`未知名称 ${name}`);
|
|
60
|
+
const type = kind(binding);
|
|
61
|
+
// Empty UI values mean saved conditions, never an unrestricted query.
|
|
62
|
+
if (value === undefined || value === '') continue;
|
|
63
|
+
if (value === null) {
|
|
64
|
+
if (!binding.allowNull) fail(`${name} 不支持筛选空值`);
|
|
65
|
+
result[name] = ['null'];
|
|
66
|
+
} else if (range(binding)) {
|
|
67
|
+
if (!record(value) || Object.keys(value).some(key => !['start','end'].includes(key))
|
|
68
|
+
|| typeof value.start !== 'string' || typeof value.end !== 'string') fail(`${name} 需要 start/end`);
|
|
69
|
+
const {start,end} = value;
|
|
70
|
+
if (start && !temporal(type,start) || end && !temporal(type,end) || start && end && start > end) fail(`${name} 区间无效或起始晚于结束`);
|
|
71
|
+
if (start) result[name+'_s'] = [start];
|
|
72
|
+
if (end) result[name+'_e'] = [end];
|
|
73
|
+
} else if (type === 'multi-select') {
|
|
74
|
+
if (!Array.isArray(value) || value.some(v => !literal(v) || !binding.options.some(o => o.value === v)) || new Set(value).size !== value.length) fail(`${name} 多选值无效`);
|
|
75
|
+
if (value.length) result[name] = [value.join(',')];
|
|
76
|
+
} else {
|
|
77
|
+
const valid = type === 'text' ? literal(value)
|
|
78
|
+
: type === 'number' ? typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)
|
|
79
|
+
: choices.has(type) ? typeof value === 'string' && binding.options.some(o => o.value === value)
|
|
80
|
+
: temporal(type,value);
|
|
81
|
+
if (!valid) fail(`${name} 值格式不正确`);
|
|
82
|
+
result[name] = [value];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {...result};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function validateRefer(bindings, refer = {}) {
|
|
89
|
+
validateFilterBindings(bindings);
|
|
90
|
+
if (!record(refer)) fail('refer 必须为对象');
|
|
91
|
+
const known = new Set(bindings.flatMap(keys));
|
|
92
|
+
for (const [name,value] of Object.entries(refer)) {
|
|
93
|
+
if (!known.has(name) || !Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string' || !value[0]) fail(`非法请求键或值 ${name}`);
|
|
94
|
+
}
|
|
95
|
+
const state = Object.create(null);
|
|
96
|
+
for (const binding of bindings) {
|
|
97
|
+
if (range(binding)) {
|
|
98
|
+
const start = refer[binding.name+'_s']?.[0] ?? '', end = refer[binding.name+'_e']?.[0] ?? '';
|
|
99
|
+
if (start || end) state[binding.name] = {start,end};
|
|
100
|
+
} else if (Object.hasOwn(refer,binding.name)) {
|
|
101
|
+
const value = refer[binding.name][0];
|
|
102
|
+
state[binding.name] = value === 'null' && nullable.has(kind(binding)) ? null : kind(binding) === 'multi-select' ? value.split(',') : value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
encodeFilters(bindings,state);
|
|
106
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {Cell, Column, Dataset, QueryResult} from './types';
|
|
2
|
+
|
|
3
|
+
// Native preview JSON is deliberately confined to this boundary.
|
|
4
|
+
type Native = Record<string, any>;
|
|
5
|
+
const unavailable = (reason: string, display: string | null = null): Cell => ({value: null, display, state: 'UNAVAILABLE', reason});
|
|
6
|
+
const text = (value: unknown): string | null => typeof value === 'string' ? value : null;
|
|
7
|
+
const column = (id: string, name: string, index: number, type: Column['type']): Column => ({key: `c${index}`, fieldId: id, name, type, encoding: ({STRING: 'STRING', DECIMAL: 'DECIMAL_STRING', DATE: 'EPOCH_MILLIS_STRING', UNKNOWN: null})[type]});
|
|
8
|
+
const warn = (r: QueryResult, message: string) => {if (!r.warnings.includes(message)) r.warnings.push(message);};
|
|
9
|
+
const partial = (r: QueryResult, message: string) => {r.normalization = 'PARTIAL'; warn(r, message);};
|
|
10
|
+
|
|
11
|
+
function value(raw: unknown, display: string | null, authority: unknown, type: Column['type']): Cell {
|
|
12
|
+
if (authority === false) return {value: null, display, state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
|
|
13
|
+
if (authority !== true) return unavailable('AUTHORITY_UNAVAILABLE', display);
|
|
14
|
+
if (raw == null) return display === null ? {value: null, display: null, state: 'NULL', reason: null} : unavailable('DISPLAY_ONLY', display);
|
|
15
|
+
if (type === 'STRING' && typeof raw === 'string') return {value: raw, display, state: 'AVAILABLE', reason: null};
|
|
16
|
+
if (type === 'DATE') {
|
|
17
|
+
const s = String(raw);
|
|
18
|
+
if ((typeof raw === 'string' || typeof raw === 'number' && Number.isSafeInteger(raw)) && /^[+-]?\d+$/.test(s)) {
|
|
19
|
+
const n = BigInt(s);
|
|
20
|
+
if (n >= -9223372036854775808n && n <= 9223372036854775807n) return {value: s, display, state: 'AVAILABLE', reason: null};
|
|
21
|
+
}
|
|
22
|
+
return unavailable('INVALID_TIMESTAMP_VALUE', display);
|
|
23
|
+
}
|
|
24
|
+
if (type === 'DECIMAL' && (typeof raw === 'string' || typeof raw === 'number')) {
|
|
25
|
+
if (typeof raw === 'number' && (!Number.isFinite(raw) || Number.isInteger(raw) && !Number.isSafeInteger(raw))) return unavailable('UNSAFE_NUMERIC_VALUE', display);
|
|
26
|
+
if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(String(raw))) return {value: String(raw), display, state: 'AVAILABLE', reason: null};
|
|
27
|
+
}
|
|
28
|
+
return unavailable(type === 'UNKNOWN' ? 'TYPE_UNAVAILABLE' : 'DISPLAY_ONLY', display);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalize(source: Native): QueryResult {
|
|
32
|
+
const p = source?.previewResult;
|
|
33
|
+
const r: QueryResult = {schemaVersion: '1', resultType: 'UNKNOWN', normalization: 'COMPLETE', datasets: [], warnings: [], range: {mode: 'UNKNOWN', page: null, pageSize: null, pageUnit: null, hasNext: null, hasPrevious: null, total: {value: null, unit: null}}};
|
|
34
|
+
if (p?.resultType === 'DETAIL' && Array.isArray(p.header) && Array.isArray(p.items)) {
|
|
35
|
+
r.resultType = 'DETAIL_TABLE';
|
|
36
|
+
r.range = {mode: 'PAGE', page: 1, pageSize: Number.isSafeInteger(p.size) ? p.size : null, pageUnit: 'ROW', hasNext: typeof p.hasNext === 'boolean' ? p.hasNext : null, hasPrevious: typeof p.hasPrev === 'boolean' ? p.hasPrev : null, total: {value: Number.isSafeInteger(p.row) && p.row >= 0 ? p.row : null, unit: 'DETAIL_ROW'}};
|
|
37
|
+
const dataset: Dataset = {id: 'main', columns: p.header.map((h: Native, i: number) => column(h.dId, h.text, i, detailType(source.widgetBean?.dimensions?.[h.dId]?.type))), rows: [], returnedRowCount: p.items.length};
|
|
38
|
+
for (const row of p.items) {
|
|
39
|
+
if (!Array.isArray(row) || row.length !== dataset.columns.length) partial(r, 'ROW_WIDTH_MISMATCH');
|
|
40
|
+
dataset.rows.push({kind: 'DATA', cells: dataset.columns.map((c, i) => {
|
|
41
|
+
const cell = row?.[i];
|
|
42
|
+
if (!cell || typeof cell !== 'object' || cell.dId && cell.dId !== c.fieldId) {partial(r, 'ROW_FIELD_MISMATCH'); return unavailable('MISSING_CELL');}
|
|
43
|
+
return value(cell.value, text(cell.formatText) ?? text(cell.text), cell.authority, c.type);
|
|
44
|
+
})});
|
|
45
|
+
}
|
|
46
|
+
r.datasets.push(dataset);
|
|
47
|
+
if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
|
|
48
|
+
if (r.range.total.value !== null && dataset.rows.length < r.range.total.value) warn(r, 'RESULT_TRUNCATED');
|
|
49
|
+
} else if (p?.resultType === 'CHART') {
|
|
50
|
+
r.resultType = 'CHART'; r.range.mode = 'UNPAGED';
|
|
51
|
+
const models: Native[] = [];
|
|
52
|
+
for (const geom of p.geoms || []) {
|
|
53
|
+
if (geom?.dataModel) models.push(geom.dataModel); else partial(r, 'GEOMETRY_WITHOUT_INLINE_DATA');
|
|
54
|
+
}
|
|
55
|
+
if (p.shared?.data) models.push(p.shared.data);
|
|
56
|
+
if (p.computedState) partial(r, 'CHART_COMPUTED_STATE_NOT_NORMALIZED');
|
|
57
|
+
for (const model of new Set(models)) {
|
|
58
|
+
if (!Array.isArray(model.fields) || !Array.isArray(model.colData) || !Number.isSafeInteger(model.rowCount) || model.rowCount < 0) {partial(r, 'INVALID_CHART_MODEL'); continue;}
|
|
59
|
+
const dataset: Dataset = {id: `model${r.datasets.length}`, columns: model.fields.map((f: Native, i: number) => column(f.id, f.name, i, f.fieldType === 'MEASURE' ? 'DECIMAL' : f.fieldType === 'DIMENSION' ? 'STRING' : 'UNKNOWN')), rows: [], returnedRowCount: model.rowCount};
|
|
60
|
+
if (model.colData.length !== model.fields.length || model.colData.some((c: unknown[]) => !Array.isArray(c) || c.length !== model.rowCount)) partial(r, 'ROW_WIDTH_MISMATCH');
|
|
61
|
+
for (let i = 0; i < model.rowCount; i++) dataset.rows.push({kind: 'UNKNOWN', cells: dataset.columns.map((c, j) => {
|
|
62
|
+
if (!Array.isArray(model.colData[j]) || i >= model.colData[j].length) return unavailable('MISSING_CELL');
|
|
63
|
+
const raw = model.colData[j][i];
|
|
64
|
+
if (raw === '***') return {value: null, display: '***', state: 'RESTRICTED', reason: 'SOURCE_VALUE_RESTRICTED'};
|
|
65
|
+
if (c.type === 'DECIMAL' && typeof raw === 'string') return unavailable('CHART_VALUE_SEMANTICS_UNKNOWN');
|
|
66
|
+
return value(raw, c.type === 'STRING' ? text(raw) : null, true, c.type);
|
|
67
|
+
})});
|
|
68
|
+
r.datasets.push(dataset);
|
|
69
|
+
}
|
|
70
|
+
if (!r.datasets.length) {r.normalization = 'UNSUPPORTED'; warn(r, 'NO_INLINE_CHART_DATA');}
|
|
71
|
+
} else {r.normalization = 'UNSUPPORTED'; warn(r, 'RESULT_TYPE_UNSUPPORTED');}
|
|
72
|
+
return r;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function detailType(type: number): Column['type'] {
|
|
76
|
+
return type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : [3,13,15,20].includes(type) ? 'DATE' : 'UNKNOWN';
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Cell = {value: string | null; display: string | null; state: 'AVAILABLE' | 'NULL' | 'UNAVAILABLE' | 'RESTRICTED'; reason: string | null};
|
|
2
|
+
export type Column = {key: string; fieldId: string; name: string; type: 'STRING' | 'DECIMAL' | 'DATE' | 'UNKNOWN'; encoding: string | null};
|
|
3
|
+
export type Dataset = {id: string; columns: Column[]; rows: {kind: 'DATA' | 'UNKNOWN'; cells: Cell[]}[]; returnedRowCount: number};
|
|
4
|
+
export type QueryResult = {
|
|
5
|
+
schemaVersion: '1'; resultType: 'DETAIL_TABLE' | 'CHART' | 'UNKNOWN';
|
|
6
|
+
normalization: 'COMPLETE' | 'PARTIAL' | 'UNSUPPORTED'; datasets: Dataset[]; warnings: string[];
|
|
7
|
+
range: {mode: 'PAGE' | 'UNPAGED' | 'UNKNOWN'; page: number | null; pageSize: number | null; pageUnit: string | null; hasNext: boolean | null; hasPrevious: boolean | null; total: {value: number | null; unit: string | null}};
|
|
8
|
+
};
|
|
9
|
+
export type WidgetBinding = {entryId: string; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string};
|
|
10
|
+
export type FilterBinding = {name: string; label: string} & (
|
|
11
|
+
{type: 'date' | 'date-range' | 'year' | 'month' | 'month-range' | 'quarter'; options?: never; allowNull?: never}
|
|
12
|
+
| {type: 'text' | 'number'; options?: never; allowNull?: boolean}
|
|
13
|
+
| {type?: 'select' | 'multi-select'; options: {label: string; value: string}[]; allowNull?: boolean}
|
|
14
|
+
| {type: 'time-label'; options: {label: string; value: string}[]; allowNull?: never}
|
|
15
|
+
);
|
|
16
|
+
export type FilterValue = string | string[] | {start:string;end:string} | null | undefined;
|
|
17
|
+
export type FilterState = Record<string,FilterValue>;
|
|
18
|
+
export type AppConfig = {schemaVersion: '1'; title: string; basePath: string; layout: 'overview' | 'workbench'; dashboardId: string; maxRows: number; pageSize: number; widgets: WidgetBinding[]; filters: FilterBinding[]};
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
:root{font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;color:#17233b;background:#f3f6fa;font-synthesis:none;line-height:1.5;font-size:14px}
|
|
2
|
+
*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,select,input{border:1px solid #cbd5e1;border-radius:6px;background:white;padding:8px 12px;color:inherit}button{cursor:pointer}button:hover{border-color:#2563eb}button:disabled{opacity:.4;cursor:default}a{color:#2563eb}main{max-width:1500px;margin:auto;padding:32px}header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}h1{font-size:28px;margin:5px 0}h2{font-size:17px;margin:0 0 12px}.eyebrow{color:#2563eb;font-weight:700;font-size:11px;letter-spacing:2px}.muted,footer{color:#64748b}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.panel{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:24px;min-width:0}.wide{grid-column:1/-1}.chart{height:300px}.chart-sort{display:flex;align-items:center;justify-content:flex-end;gap:8px;color:#64748b}.chart-sort select{font-size:12px}.table-scroll{overflow:auto;max-height:580px}table{border-collapse:collapse;width:100%;font-size:13px}td,th{white-space:nowrap;padding:11px 14px;text-align:left;border-bottom:1px solid #edf0f5}th{background:#f8fafc;color:#475569;font-weight:600;position:sticky;top:0}tbody tr:hover{background:#f8fafc}.table-tools{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:18px 0}.table-tools input{margin-left:10px}.pager{display:flex;gap:16px;align-items:center;justify-content:flex-end;margin-top:18px}.notice{background:#fff7e5;border-left:3px solid #d89b21;padding:10px 14px;color:#76550d;font-size:12px}.notice small{display:block;margin-top:4px;overflow-wrap:anywhere}.live{color:#0c836d;font-size:12px}.state{margin:48px auto;padding:32px;background:white;border:1px solid #e2e8f0;border-radius:10px;max-width:900px}.error{border-color:#efb7b7}.error button{margin-left:20px}.empty{text-align:center;padding:40px;color:#64748b}.kpi{font-size:36px;font-weight:700;color:#2563eb}footer{padding:24px 0;font-size:12px}details{margin-top:12px;color:#64748b}summary{cursor:pointer}.workbench{display:grid;grid-template-columns:190px minmax(0,1fr)}aside{background:#14233c;color:#fff;padding:32px 22px;min-height:100vh}aside strong{font-size:24px}aside a{display:block;margin-top:35px;color:#c4d4f0;text-decoration:none}.workbench main{width:100%}@media(max-width:800px){main{padding:16px}.grid{grid-template-columns:1fr}.workbench{display:block}aside{display:none}.table-tools{align-items:flex-start;flex-direction:column}h1{font-size:24px}}
|
|
3
|
+
|
|
4
|
+
.filter-card{background:#fff;border:1px solid #e3eaf3;border-radius:16px;padding:24px 28px 20px;margin-bottom:22px;box-shadow:0 4px 18px #25385804}
|
|
5
|
+
.filter-heading{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:25px}.filter-heading-title{display:flex;align-items:center;gap:12px}.filter-symbol{flex-shrink:0;display:grid;place-items:center;width:40px;height:40px;background:#eff5ff;color:#2563eb;border-radius:12px}.filter-symbol svg{width:24px;height:24px}.filter-heading h2{margin:0 0 3px;font-size:16px;font-weight:650;color:#1e3150}.filter-heading p{margin:0;color:#8492a6;font-size:12px}.filter-status{background:#eef9f5;color:#168367;font-size:11px;font-weight:500;padding:5px 10px;border-radius:20px;white-space:nowrap}.filter-status.pending{background:#fff6e8;color:#b47519}
|
|
6
|
+
.filter-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:30px}.filter-field{min-width:0}.filter-label-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:9px}.filter-label-row label{font-size:13px;font-weight:600;color:#344768}.filter-label-row label span{margin-left:10px;font-size:11px;font-weight:400;color:#94a0b2}.field-reset{border:0;background:transparent;color:#7f8ca1;padding:2px 0;font-size:12px;border-radius:0}.field-reset:hover{color:#2563eb}.filter-select,.filter-date-range{width:100%}.filter-date-range{overflow:hidden}.field-hint{min-height:24px;margin-top:9px;font-size:11px;color:#8b98aa;display:flex;align-items:center}.field-hint .ant-checkbox-label{font-size:11px;color:#8391a6}.filter-card .ant-picker-input>input{border:0;padding:0;background:transparent;box-shadow:none;border-radius:0}.filter-card .ant-select-input{border:0;padding:0;background:transparent}.filter-card .ant-select-selection-item{font-size:13px}.filter-card .ant-select-selection-placeholder,.filter-card .ant-picker input::placeholder{color:#a0acbd;font-size:13px}.filter-card .ant-picker-range-separator{color:#a0acbd}.filter-card .ant-btn{font-size:13px;font-weight:500}.filter-card .ant-btn-primary{box-shadow:0 3px 9px #2563eb20;min-width:112px}
|
|
7
|
+
.filter-bottom{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:15px;padding-top:18px;border-top:1px solid #f0f3f8}.filter-bottom p{margin:0;color:#94a0b2;font-size:12px}.filter-bottom p.unsaved-note{color:#b47519}.filter-buttons{display:flex;gap:10px}.filter-alert{margin-top:14px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
|
8
|
+
@media(max-width:760px){.filter-card{padding:20px 18px}.filter-fields{grid-template-columns:1fr;gap:17px}.filter-heading{align-items:flex-start}.filter-heading p{max-width:190px;line-height:1.6}.filter-status{font-size:10px}.filter-bottom{align-items:stretch;flex-direction:column;gap:13px}.filter-buttons{justify-content:flex-end}.filter-date-range{min-width:0}.filter-label-row label span{font-size:10px}.ant-picker-dropdown .ant-picker-panels{flex-direction:column}.ant-picker-dropdown .ant-picker-panel-container{max-height:min(60vh,360px);overflow-y:auto}}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
|
|
5
|
+
test('picker input and month arithmetic preserve the full calendar contract',async()=>{
|
|
6
|
+
const server=await createServer({configFile:false,cacheDir:'temp/calendar-tests',server:{middlewareMode:true,hmr:false}});
|
|
7
|
+
try {
|
|
8
|
+
const {calendarValue,calendarConfig:config}=await server.ssrLoadModule('/src/components/calendar.ts');
|
|
9
|
+
for(const value of ['0001-01-01','0099-02-28','0100-03-01','2000-02-29','2024-02-29','9999-12-31']) {
|
|
10
|
+
const parsed=config.locale.parse('zh_CN',value,['YYYY-MM-DD']);
|
|
11
|
+
assert.ok(parsed,value);
|
|
12
|
+
assert.equal(config.locale.format('zh_CN',parsed,'YYYY-MM-DD'),value);
|
|
13
|
+
assert.equal(config.locale.format('zh_CN',calendarValue(value),'YYYY-MM-DD'),value);
|
|
14
|
+
}
|
|
15
|
+
for(const value of ['0001-02','0099-12','2024-02','9999-12']) {
|
|
16
|
+
const parsed=config.locale.parse('zh_CN',value,['YYYY-MM']);
|
|
17
|
+
assert.equal(config.locale.format('zh_CN',parsed,'YYYY-MM'),value);
|
|
18
|
+
assert.equal(config.locale.format('zh_CN',calendarValue(value),'YYYY-MM'),value);
|
|
19
|
+
}
|
|
20
|
+
for(const value of ['0000-01-01','1900-02-29','2023-02-29','2024-13-01','2024-02-30','']) assert.equal(calendarValue(value),null,value);
|
|
21
|
+
assert.equal(config.locale.parse('zh_CN','2023-02-29',['YYYY-MM-DD']),null);
|
|
22
|
+
assert.equal(config.locale.format('zh_CN',config.getEndDate(calendarValue('0001-02')),'YYYY-MM-DD'),'0001-02-28');
|
|
23
|
+
assert.equal(config.locale.format('zh_CN',config.addMonth(calendarValue('0099-12'),1),'YYYY-MM'),'0100-01');
|
|
24
|
+
} finally {await server.close();}
|
|
25
|
+
});
|