@finebi/finebi-cli 1.6.0 → 1.6.1
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 +8 -4
- package/package.json +1 -1
- package/skills/finebi-cli-agent/references/command-workflows.md +1 -1
- package/skills/finebi-cli-agent/references/web-app.md +9 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +8 -49
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +2 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +8 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +8 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +67 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +38 -21
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +43 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +3 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +2 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +45 -20
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +24 -15
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +12 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +16 -10
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +23 -12
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +15 -10
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +1 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +44 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +12 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +2 -0
- package/src/app.js +8 -49
- package/src/help.js +2 -1
- package/templates/web-app/AGENTS.md +8 -5
- package/templates/web-app/README.md +8 -2
- package/templates/web-app/docs/acceptance.md +67 -0
- package/templates/web-app/docs/auth-and-deploy.md +2 -2
- package/templates/web-app/docs/data-contract.md +38 -21
- package/templates/web-app/docs/design.md +43 -2
- package/templates/web-app/docs/filters.md +4 -0
- package/templates/web-app/examples/cockpit.config.json +22 -0
- package/templates/web-app/examples/retail.config.json +185 -0
- package/templates/web-app/package-lock.json +2 -2
- package/templates/web-app/package.json +3 -2
- package/templates/web-app/scripts/dev-proxy.mjs +2 -1
- package/templates/web-app/src/App.tsx +45 -20
- package/templates/web-app/src/cockpit.css +3 -0
- package/templates/web-app/src/components/Chart.tsx +24 -15
- package/templates/web-app/src/components/DataTable.tsx +12 -5
- package/templates/web-app/src/components/FilterBar.tsx +16 -10
- package/templates/web-app/src/components/Insights.tsx +17 -0
- package/templates/web-app/src/components/MonthPicker.tsx +32 -0
- package/templates/web-app/src/components/OptionFilter.tsx +7 -0
- package/templates/web-app/src/components/RangeFilter.tsx +9 -0
- package/templates/web-app/src/components/Widget.tsx +23 -12
- package/templates/web-app/src/components/calendar-locales.ts +3 -0
- package/templates/web-app/src/components/charts/Bar.tsx +6 -0
- package/templates/web-app/src/components/charts/Line.tsx +6 -0
- package/templates/web-app/src/components/charts/Pie.tsx +6 -0
- package/templates/web-app/src/components/month-picker.css +1 -0
- package/templates/web-app/src/finebi/client.ts +15 -10
- package/templates/web-app/src/finebi/config.d.ts +1 -0
- package/templates/web-app/src/finebi/config.js +44 -0
- package/templates/web-app/src/finebi/types.ts +2 -2
- package/templates/web-app/src/retail.css +13 -0
- package/templates/web-app/src/services/presentation.ts +36 -0
- package/templates/web-app/src/services/table-data.ts +47 -0
- package/templates/web-app/src/services/widget-queries.ts +18 -0
- package/templates/web-app/tests/browser/fixtures.mjs +28 -0
- package/templates/web-app/tests/browser/index.html +16 -0
- package/templates/web-app/tests/browser/main.tsx +149 -0
- package/templates/web-app/tests/browser/test-page.css +4 -0
- package/templates/web-app/tests/browser/vite.config.mjs +13 -0
- package/templates/web-app/tests/bundle.test.mjs +19 -0
- package/templates/web-app/tests/cockpit.test.mjs +73 -0
- package/templates/web-app/tests/proxy.test.mjs +12 -0
- package/templates/web-app/tests/retail.test.mjs +31 -0
- package/templates/web-app/tests/runtime.test.mjs +1 -1
- package/templates/web-app/vite.config.ts +2 -0
|
@@ -1,28 +1,39 @@
|
|
|
1
|
-
import {Component, type ReactNode} from 'react';
|
|
1
|
+
import {Component, lazy, Suspense, type ReactNode} from 'react';
|
|
2
2
|
import type {QueryResult, WidgetBinding} from '../finebi/types';
|
|
3
3
|
import {displayCell, selectDataset} from '../services/view-data';
|
|
4
|
-
import {Chart} from './Chart';
|
|
5
4
|
import {DataTable} from './DataTable';
|
|
6
|
-
|
|
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}> {
|
|
7
9
|
state = {error: ''};
|
|
8
10
|
static getDerivedStateFromError(error: Error) {return {error: error.message};}
|
|
9
|
-
render() {return this.state.error ? <
|
|
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;}
|
|
10
12
|
}
|
|
11
13
|
function Content({result, binding, pageSize}: {result: QueryResult; binding: WidgetBinding; pageSize: number}) {
|
|
12
14
|
const data = selectDataset(result, binding.datasetId);
|
|
13
15
|
if (!data.rows.length) return <p className="empty">当前查询没有数据</p>;
|
|
14
|
-
if (binding.type === 'table') return <DataTable dataset={data} pageSize={pageSize}/>;
|
|
16
|
+
if (binding.type === 'table') return <DataTable dataset={data} pageSize={pageSize} binding={binding}/>;
|
|
15
17
|
if (binding.type === 'kpi') {
|
|
16
18
|
const index = data.columns.findIndex(c => c.fieldId === binding.valueFieldId);
|
|
17
19
|
if (data.rows.length !== 1 || index < 0) throw new Error('KPI 需要单行指标结果,不能自动累加图表行');
|
|
18
|
-
return <div className="kpi"
|
|
20
|
+
return <div className="kpi" title={displayCell(data.rows[0].cells[index])}>{formatMetric(data.rows[0].cells[index],binding.valueFormat)}</div>;
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
const Chart=charts[binding.type];
|
|
23
|
+
return <Suspense fallback={<p role="status">正在加载图表…</p>}><Chart dataset={data} binding={binding}/></Suspense>;
|
|
21
24
|
}
|
|
22
|
-
export function Widget({
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.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>}
|
|
36
|
+
<Boundary key={state.at} onRetry={onRetry}><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
|
|
37
|
+
</>}
|
|
27
38
|
</section>;
|
|
28
39
|
}
|
|
@@ -0,0 +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,24 +1,23 @@
|
|
|
1
1
|
import {normalize} from './normalize';
|
|
2
|
-
import {encodeFilters
|
|
3
|
-
import
|
|
2
|
+
import {encodeFilters} from './filters.js';
|
|
3
|
+
import {configErrors} from './config.js';
|
|
4
|
+
import type {AppConfig, QueryResult, FilterState, WidgetBinding} from './types';
|
|
4
5
|
export class FineBIError extends Error {
|
|
5
6
|
constructor(public code: string, message: string) {super(message); this.name = 'FineBIError';}
|
|
6
7
|
}
|
|
7
8
|
export function validateConfig(config: AppConfig) {
|
|
8
|
-
|
|
9
|
-
if
|
|
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);
|
|
9
|
+
const errors=configErrors(config);
|
|
10
|
+
if(errors.length)throw new Error(errors.join(' '));
|
|
13
11
|
}
|
|
14
|
-
export async function queryWidgets(config: AppConfig, filters: FilterState = {}, signal?: AbortSignal, fetchImpl: typeof fetch = fetch): Promise<Record<string, QueryResult>> {
|
|
12
|
+
export async function queryWidgets(config: AppConfig, filters: FilterState = {}, signal?: AbortSignal, fetchImpl: typeof fetch = fetch, widgetIds?:string[]): Promise<Record<string, QueryResult>> {
|
|
15
13
|
validateConfig(config);
|
|
16
14
|
const refer = encodeFilters(config.filters,filters);
|
|
17
|
-
const ids = [...new Set(config.widgets.map(w => w.entryId))];
|
|
15
|
+
const ids = [...new Set(widgetIds ?? config.widgets.map(w => w.entryId))];
|
|
16
|
+
if(!ids.length||ids.some(id=>!config.widgets.some(w=>w.entryId===id)))throw new Error('组件不在配置范围内');
|
|
18
17
|
const response = await fetchImpl(`${config.basePath.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
|
|
19
18
|
method: 'POST', credentials: 'include', signal, redirect: 'manual',
|
|
20
19
|
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
|
|
21
|
-
body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer}),
|
|
20
|
+
body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer,...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{})}),
|
|
22
21
|
});
|
|
23
22
|
if (response.status === 401 || response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
|
|
24
23
|
if (response.status === 403) throw new FineBIError('PERMISSION_DENIED', '当前用户没有访问权限');
|
|
@@ -35,3 +34,9 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
|
|
|
35
34
|
}
|
|
36
35
|
return results;
|
|
37
36
|
}
|
|
37
|
+
export async function queryWidget(config:AppConfig,binding:WidgetBinding,filters:FilterState={},signal?:AbortSignal,fetchImpl:typeof fetch=fetch):Promise<QueryResult> {
|
|
38
|
+
// Validate all draft names before projecting the verified component scope.
|
|
39
|
+
encodeFilters(config.filters,filters);
|
|
40
|
+
const scoped=binding.filterNames ? Object.fromEntries(Object.entries(filters).filter(([name])=>binding.filterNames!.includes(name))) : filters;
|
|
41
|
+
return (await queryWidgets(config,scoped,signal,fetchImpl,[binding.entryId]))[binding.entryId];
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function configErrors(config: unknown): string[];
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
export function configErrors(config) {
|
|
5
|
+
if(!record(config))return ['configuration must be a JSON object.'];
|
|
6
|
+
const errors=[];
|
|
7
|
+
const allowed=['schemaVersion','title','basePath','layout','dashboardId','directoryEntryId','maxRows','pageSize','widgets','filters','templateVersion','description','insights','dataNotes'];
|
|
8
|
+
for(const key of Object.keys(config))if(!allowed.includes(key))errors.push(`unsupported config field: ${key}.`);
|
|
9
|
+
if(config.schemaVersion!=='1')errors.push('schemaVersion must be "1".');
|
|
10
|
+
if(!nonempty(config.title))errors.push('title is required.');
|
|
11
|
+
if(!nonempty(config.basePath)||!/^\/(?!\/)[\w/-]+$/.test(config.basePath))errors.push('basePath must be a same-origin path.');
|
|
12
|
+
if(!['overview','workbench','cockpit','retail'].includes(config.layout))errors.push('layout must be overview, workbench, cockpit or retail.');
|
|
13
|
+
if(!nonempty(config.dashboardId))errors.push('project is not bound: dashboardId must be configured.');
|
|
14
|
+
if('directoryEntryId' in config&&!nonempty(config.directoryEntryId))errors.push('directoryEntryId must be a non-empty string.');
|
|
15
|
+
if(!Number.isSafeInteger(config.maxRows)||config.maxRows<1||config.maxRows>10000)errors.push('maxRows must be an integer from 1 to 10000.');
|
|
16
|
+
if(!Number.isSafeInteger(config.pageSize)||config.pageSize<1||config.pageSize>100)errors.push('pageSize must be an integer from 1 to 100.');
|
|
17
|
+
if('description' in config&&!nonempty(config.description))errors.push('description must be a non-empty string.');
|
|
18
|
+
if('insights' in config&&typeof config.insights!=='boolean')errors.push('insights must be boolean.');
|
|
19
|
+
if('dataNotes' in config&&(!Array.isArray(config.dataNotes)||config.dataNotes.some(v=>!nonempty(v))))errors.push('dataNotes must be text array.');
|
|
20
|
+
try{validateFilterBindings(config.filters);}catch(error){errors.push(error.message);}
|
|
21
|
+
if(!Array.isArray(config.widgets)||!config.widgets.length)return [...errors,'widgets must be a non-empty array.'];
|
|
22
|
+
for(const [i,w] of config.widgets.entries()) {
|
|
23
|
+
if(!record(w)){errors.push(`widgets[${i}] must be an object.`);continue;}
|
|
24
|
+
const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone'];
|
|
25
|
+
for(const field of Object.keys(w))if(!fields.includes(field))errors.push(`widgets[${i}].${field} is unsupported.`);
|
|
26
|
+
for(const field of ['entryId','name'])if(!nonempty(w[field]))errors.push(`widgets[${i}].${field} is required.`);
|
|
27
|
+
if(!['table','kpi','bar','line','pie'].includes(w.type))errors.push(`widgets[${i}].type is unsupported.`);
|
|
28
|
+
if('valueFormat' in w&&(w.type!=='kpi'||!['number','percent'].includes(w.valueFormat)))errors.push(`widgets[${i}].valueFormat requires a KPI and number or percent.`);
|
|
29
|
+
if('categoryFormat' in w||'categoryTimeZone' in w) {
|
|
30
|
+
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.`);
|
|
31
|
+
else try{new Intl.DateTimeFormat('en',{timeZone:w.categoryTimeZone});}catch{errors.push(`widgets[${i}].categoryTimeZone is invalid.`);}
|
|
32
|
+
}
|
|
33
|
+
for(const field of ['datasetId','categoryFieldId','valueFieldId','description'])if(field in w&&!nonempty(w[field]))errors.push(`widgets[${i}].${field} must be non-empty.`);
|
|
34
|
+
if(w.type!=='table'&&!nonempty(w.valueFieldId)&&!w.series)errors.push(`widgets[${i}].valueFieldId is required.`);
|
|
35
|
+
if(!['table','kpi'].includes(w.type)&&!nonempty(w.categoryFieldId))errors.push(`widgets[${i}].categoryFieldId is required.`);
|
|
36
|
+
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.`);
|
|
37
|
+
for(const field of ['filterNames','tableFieldIds','localFilterFieldIds'])if(field in w) {
|
|
38
|
+
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.`);
|
|
39
|
+
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.`);
|
|
40
|
+
else if(field!=='filterNames'&&(w.type!=='table'||!w[field].length))errors.push(`widgets[${i}].${field} is only for tables and must be non-empty.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
@@ -6,7 +6,7 @@ export type QueryResult = {
|
|
|
6
6
|
normalization: 'COMPLETE' | 'PARTIAL' | 'UNSUPPORTED'; datasets: Dataset[]; warnings: string[];
|
|
7
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
8
|
};
|
|
9
|
-
export type WidgetBinding = {entryId: string; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string};
|
|
9
|
+
export type WidgetBinding = {entryId: string; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string; series?: {fieldId:string;label:string}[]; description?:string; filterNames?:string[]; tableFieldIds?:string[]; localFilterFieldIds?:string[]; valueFormat?:'number'|'percent'; categoryFormat?:'epoch-month'; categoryTimeZone?:string};
|
|
10
10
|
export type FilterBinding = {name: string; label: string} & (
|
|
11
11
|
{type: 'date' | 'date-range' | 'year' | 'month' | 'month-range' | 'quarter'; options?: never; allowNull?: never}
|
|
12
12
|
| {type: 'text' | 'number'; options?: never; allowNull?: boolean}
|
|
@@ -15,4 +15,4 @@ export type FilterBinding = {name: string; label: string} & (
|
|
|
15
15
|
);
|
|
16
16
|
export type FilterValue = string | string[] | {start:string;end:string} | null | undefined;
|
|
17
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[]};
|
|
18
|
+
export type AppConfig = {schemaVersion: '1'; title: string; description?:string; basePath: string; layout: 'overview' | 'workbench' | 'cockpit' | 'retail'; dashboardId: string; directoryEntryId?:string; insights?:boolean; dataNotes?:string[]; maxRows: number; pageSize: number; widgets: WidgetBinding[]; filters: FilterBinding[]};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.retail{--chart-1:#3158d5;--chart-2:#e18047;--chart-3:#82a7a0;--chart-4:#9b8fc6;--chart-5:#d2b977;background:#f5f3ee;color:#24272d;min-height:100vh;font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
|
2
|
+
.retail main{max-width:1480px;padding:48px 48px 24px}.retail a{color:#3158d5}.retail button,.retail select,.retail input{border-radius:5px}.retail :focus-visible{outline:3px solid #e18047;outline-offset:3px}
|
|
3
|
+
.retail-nav{position:sticky;top:0;z-index:30}.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:100px}
|
|
4
|
+
.retail-nav{height:78px;padding:0 48px;border-bottom:1px solid #dcd9d1;display:flex;align-items:center;justify-content:space-between;gap:30px;background:#faf9f6}.retail-nav a{text-decoration:none;color:#40434b}.retail-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:17px;white-space:nowrap}.retail-brand b{display:grid;place-items:center;background:#24272d;color:#faf9f6;font-family:Georgia,serif;font-size:30px;width:39px;height:39px;border-radius:4px}.retail-brand small{font-size:12px;font-weight:400;border-left:1px solid #bbb7ad;margin-left:10px;padding-left:12px}.retail-nav>div{display:flex;gap:36px;font-size:13px}.retail-nav>div a:first-child{color:#3158d5;font-weight:650}.retail-live{font-size:12px;white-space:nowrap}.retail-live:before{content:"";display:inline-block;width:6px;height:6px;background:#3158d5;border-radius:50%;margin-right:8px}
|
|
5
|
+
.retail header{align-items:flex-end;margin-bottom:30px;gap:30px}.retail .eyebrow{font-size:10px;letter-spacing:2.5px;color:#6d706e}.retail h1{font-size:36px;letter-spacing:-1.2px;font-weight:650;margin:12px 0}.retail header .muted{font-size:13px;max-width:750px;line-height:1.9}.retail .muted{color:#75776f}.retail .header-actions{display:flex;align-items:center;gap:20px;white-space:nowrap;font-size:12px}.retail .header-actions button{background:#24272d;color:#fff;border-color:#24272d;padding:11px 18px}.retail .header-actions a{color:#656863;text-decoration:none}
|
|
6
|
+
.retail .filter-card{border-radius:8px;border-color:#dedbd3;background:#faf9f6;box-shadow:none;padding:20px 24px;margin-bottom:8px}.retail .filter-heading{margin-bottom:14px}.retail .filter-heading p{display:none}.retail .filter-symbol{height:28px;width:28px;background:#eaeefa;border-radius:5px;color:#3158d5}.retail .filter-heading h2{font-size:13px;color:#424740}.retail .filter-fields{gap:14px}.retail .filter-fields:has(>.filter-field:only-child){max-width:760px}.retail .filter-label-row{margin-bottom:5px}.retail .filter-label-row label{color:#575d65}.retail .filter-bottom{margin-top:9px;padding-top:12px;border-color:#e3e0d8}.retail .filter-scope{font-size:11px;color:#797d7e;line-height:1.8;margin:4px 0}.retail .field-hint{min-height:0;margin-top:6px}.retail .filter-status{background:#e9edf9;color:#3158d5}.retail .filter-status.pending{background:#fff0db;color:#855316}.retail .query-status{font-size:11px;color:#75776f;text-align:right;margin:12px 0 28px}
|
|
7
|
+
.retail .section-heading{display:flex;align-items:baseline;gap:16px;margin:35px 0 18px}.retail .section-heading>span{font:10px "Segoe UI",sans-serif;letter-spacing:1.5px;color:#85877d}.retail .section-heading h2{font-size:18px;letter-spacing:-.3px;margin:0}.retail .section-heading p{font-size:11px;color:#85877d;margin:0 0 0 auto}
|
|
8
|
+
.retail .kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:0;border:1px solid #d6d3ca;border-radius:8px;overflow:hidden;background:#fffefb}.retail .kpi-grid .panel{border:0;border-right:1px solid #dedbd3;border-radius:0;background:transparent;padding:23px 25px 18px;display:flex;flex-direction:column;position:relative}.retail .kpi-grid .panel:last-child{border-right:0}.retail .kpi-grid .panel:first-child{background:#eaeefb}.retail .kpi-grid .panel-heading{display:block}.retail .kpi-grid h2{font-size:13px;font-weight:550;margin-bottom:12px}.retail .kpi-grid .component-status{font-size:10px;color:#878b90}.retail .kpi{font-size:clamp(25px,2.45vw,39px);font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-1.5px;color:#252a34;order:1;margin:10px 0 6px}.retail .kpi-grid .panel:first-child .kpi{color:#3158d5}.retail .kpi-grid .muted{order:2;font-size:11px;margin:2px 0;min-height:32px}.retail .kpi-grid .query-details{order:3}.retail .kpi-grid .notice{order:4}
|
|
9
|
+
.retail .grid{gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}.retail .grid .panel{border:1px solid #dedbd3;border-radius:8px;padding:26px;background:#fffefb;box-shadow:none}.retail .panel-heading{display:flex;justify-content:space-between;align-items:baseline;gap:14px}.retail .panel-heading h2{font-size:16px;font-weight:600;letter-spacing:-.3px;margin-bottom:6px}.retail .component-status{color:#898d88;font-size:10px;white-space:nowrap}.retail .component-status.failed{color:#a74632}.retail .grid .muted{font-size:11px;margin:6px 0 10px}.retail .query-details{font-size:10px;color:#91938c;margin:8px 0}.retail .query-details summary{display:inline-block}.retail .notice{font-size:11px;background:#f9f2e4;border-color:#d9ae66;padding:8px 11px;border-radius:2px;color:#857044}.retail .notice details{font-size:10px;margin:2px 0;color:inherit}.retail .series-controls{display:flex;gap:18px;flex-wrap:wrap;font-size:12px;margin:16px 0}.retail .series-controls input{accent-color:#3158d5;margin-right:6px}.retail .chart-sort{font-size:10px;margin:10px 0}.retail .chart-sort select{border-color:#e4e2da;background:#fffefb;font-size:10px;padding:4px 8px}.retail .chart{height:310px}.retail details{font-size:11px}.retail .data-notes{border-top:1px solid #d6d3ca;padding-top:22px;margin-top:30px;line-height:1.9;color:#737870}.retail .data-notes>summary{font-size:13px;color:#404740;font-weight:600}.retail th{background:#f3f2ec;color:#525954;font-size:11px}.retail td{border-color:#ebe9e1;font-size:12px}.retail tbody tr:hover{background:#f6f7fc}.retail .table-tools{font-size:12px;flex-wrap:wrap}.retail .table-tools input{max-width:100%}.retail footer{font-size:10px;color:#8b8d85;text-align:center;letter-spacing:.5px}
|
|
10
|
+
@media(max-width:1000px){.retail main{padding:30px 24px}.retail-nav{padding:0 24px}.retail .kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.retail .kpi-grid .panel{border-bottom:1px solid #dedbd3}.retail .kpi-grid .panel:nth-child(2n){border-right:0}.retail .kpi-grid .panel:nth-last-child(-n+2){border-bottom:0}.retail header{align-items:flex-start}.retail h1{font-size:29px}.retail .header-actions{flex-direction:column;gap:10px;align-items:flex-end}}
|
|
11
|
+
@media(max-width:700px){.retail main{padding:24px 16px}.retail-nav{height:auto;min-height:68px;padding:16px;flex-wrap:wrap;gap:16px}.retail-nav>div{order:3;width:100%;justify-content:space-between;gap:12px;font-size:12px}.retail .retail-brand{font-size:14px}.retail h1{font-size:26px}.retail header{display:block}.retail .header-actions{flex-direction:row;justify-content:flex-end;margin-top:16px}.retail .grid{grid-template-columns:1fr}.retail .grid .panel{padding:20px 16px}.retail .kpi-grid .panel{padding:18px 15px}.retail .kpi{font-size:26px;letter-spacing:-1px}.retail .section-heading{gap:10px;flex-wrap:wrap;margin-top:28px}.retail .section-heading p{width:100%;margin:0}.retail .section-heading>span{font-size:9px}.retail .panel-heading{display:block}.retail .chart{height:285px}.retail .filter-card{padding:18px 16px}.retail .filter-bottom{align-items:stretch;flex-direction:column}.retail .filter-buttons{justify-content:flex-end}.retail .filter-buttons .ant-btn{font-size:12px}.retail .pager{gap:8px;flex-wrap:wrap;font-size:11px}}
|
|
12
|
+
@media(min-width:900px){.retail .filter-card{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:32px;align-items:start}.retail .filter-heading{grid-column:1/-1;margin-bottom:12px}.retail .filter-fields{width:min(100%,640px)}.retail .filter-bottom{border:0;padding:26px 0 0;margin:0;flex-direction:column;align-items:flex-end;gap:8px}.retail .filter-bottom>p{order:2;font-size:10px}.retail .filter-fields:has(>.filter-field:only-child){max-width:640px}}
|
|
13
|
+
@media(max-width:700px){.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:128px}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type {Cell,WidgetBinding} from '../finebi/types';
|
|
2
|
+
import {displayCell} from './view-data';
|
|
3
|
+
|
|
4
|
+
// Decimal strings stay strings/integers: formatting a large amount must not lose cents.
|
|
5
|
+
export function formatMetric(cell:Cell,format?:WidgetBinding['valueFormat']):string {
|
|
6
|
+
if(!format||cell.state!=='AVAILABLE'||cell.value===null)return displayCell(cell);
|
|
7
|
+
const match=/^(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(cell.value);
|
|
8
|
+
if(!match)return displayCell(cell);
|
|
9
|
+
const exponent=Number(match[4]??0),fraction=match[3]??'';
|
|
10
|
+
if(Math.abs(exponent)>100)return displayCell(cell);
|
|
11
|
+
let digits=(match[2]+fraction).replace(/^0+(?=\d)/,'');
|
|
12
|
+
let scale=fraction.length-exponent;
|
|
13
|
+
if(format==='percent') {
|
|
14
|
+
scale-=2;
|
|
15
|
+
const shift=2-scale;
|
|
16
|
+
let amount=BigInt(digits);
|
|
17
|
+
if(shift>=0)amount*=10n**BigInt(shift);
|
|
18
|
+
else {const divisor=10n**BigInt(-shift);amount=(amount+divisor/2n)/divisor;}
|
|
19
|
+
digits=amount.toString();scale=2;
|
|
20
|
+
}
|
|
21
|
+
if(scale<0){digits+='0'.repeat(-scale);scale=0;}
|
|
22
|
+
digits=digits.padStart(scale+1,'0');
|
|
23
|
+
const whole=(scale?digits.slice(0,-scale):digits).replace(/\B(?=(\d{3})+(?!\d))/g,',');
|
|
24
|
+
const tail=scale?digits.slice(-scale):'';
|
|
25
|
+
const decimal=format==='percent'?tail:tail.replace(/0+$/,'');
|
|
26
|
+
const sign=match[1]&&/[1-9]/.test(digits)?'-':'';
|
|
27
|
+
return `${sign}${whole}${decimal?'.'+decimal:''}${format==='percent'?'%':''}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatCategory(cell:Cell,binding:Pick<WidgetBinding,'categoryFormat'|'categoryTimeZone'>):string {
|
|
31
|
+
if(binding.categoryFormat!=='epoch-month'||!binding.categoryTimeZone||cell.state!=='AVAILABLE'||!cell.value||! /^-?\d+$/.test(cell.value))return displayCell(cell);
|
|
32
|
+
const timestamp=Number(cell.value);
|
|
33
|
+
if(!Number.isSafeInteger(timestamp)||!Number.isFinite(new Date(timestamp).getTime()))return displayCell(cell);
|
|
34
|
+
const parts=new Intl.DateTimeFormat('en',{year:'numeric',month:'2-digit',timeZone:binding.categoryTimeZone}).formatToParts(new Date(timestamp));
|
|
35
|
+
return `${parts.find(p=>p.type==='year')!.value}-${parts.find(p=>p.type==='month')!.value}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {Cell,Dataset,WidgetBinding} from '../finebi/types';
|
|
2
|
+
import {displayCell} from './view-data';
|
|
3
|
+
function decimal(value:string) {
|
|
4
|
+
const match=/^([+-]?)(\d*\.?\d+|\d+\.)(?:e([+-]?\d+))?$/i.exec(value);
|
|
5
|
+
if(!match)throw new Error('无效十进制值');
|
|
6
|
+
const [whole,fraction='']=match[2].split('.');
|
|
7
|
+
const digits=(whole+fraction).replace(/^0+/,'').replace(/0+$/,'');
|
|
8
|
+
const significant=(whole+fraction).replace(/^0+/,'');
|
|
9
|
+
return {sign:!digits?0:match[1]==='-'?-1:1,digits,order:BigInt(significant.length)+BigInt(match[3]||0)-BigInt(fraction.length)};
|
|
10
|
+
}
|
|
11
|
+
export function compareDecimal(a:string,b:string) {
|
|
12
|
+
const x=decimal(a),y=decimal(b);
|
|
13
|
+
if(x.sign!==y.sign)return x.sign<y.sign?-1:1;
|
|
14
|
+
if(x.sign===0)return 0;
|
|
15
|
+
const length=Math.max(x.digits.length,y.digits.length);
|
|
16
|
+
const left=x.digits.padEnd(length,'0'),right=y.digits.padEnd(length,'0');
|
|
17
|
+
const order=x.order!==y.order?(x.order<y.order?-1:1):left===right?0:left<right?-1:1;
|
|
18
|
+
return order===0?0:order*x.sign;
|
|
19
|
+
}
|
|
20
|
+
export function tableDataset(dataset:Dataset,binding:WidgetBinding):Dataset {
|
|
21
|
+
if(!binding.tableFieldIds)return dataset;
|
|
22
|
+
const indices=binding.tableFieldIds.map(id=>fieldIndex(dataset,id));
|
|
23
|
+
return {...dataset,columns:indices.map(i=>dataset.columns[i]),rows:dataset.rows.map(row=>({...row,cells:indices.map(i=>row.cells[i])}))};
|
|
24
|
+
}
|
|
25
|
+
export function fieldIndex(dataset:Dataset,id:string) {
|
|
26
|
+
const index=dataset.columns.findIndex(c=>c.fieldId===id);
|
|
27
|
+
if(index<0)throw new Error(`结果字段绑定缺失:${id}`);
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
type Options={search:string;sort?:{fieldId:string;direction:'asc'|'desc'};filters:Record<string,string>};
|
|
31
|
+
export function tableRows(dataset:Dataset,{search,sort,filters}:Options) {
|
|
32
|
+
const columns=Object.entries(filters).map(([id,value])=>({index:fieldIndex(dataset,id),value}));
|
|
33
|
+
const rows=dataset.rows.filter(row=>row.cells.some(c=>displayCell(c).toLocaleLowerCase().includes(search.toLocaleLowerCase()))&&columns.every(({index,value})=>!value||displayCell(row.cells[index])===value));
|
|
34
|
+
if(!sort)return rows;
|
|
35
|
+
const index=fieldIndex(dataset,sort.fieldId),numeric=dataset.columns[index].type==='DECIMAL';
|
|
36
|
+
return [...rows].sort((a,b)=>{
|
|
37
|
+
const x=a.cells[index],y=b.cells[index];
|
|
38
|
+
const valid=(c:Cell)=>c.state==='AVAILABLE'&&c.value!==null;
|
|
39
|
+
if(!valid(x)||!valid(y))return valid(x)?-1:valid(y)?1:0;
|
|
40
|
+
const order=numeric?compareDecimal(x.value!,y.value!):displayCell(x).localeCompare(displayCell(y),'zh-CN');
|
|
41
|
+
return sort.direction==='asc'?order:-order;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function tableCsv(dataset:Dataset,rows:Dataset['rows']) {
|
|
45
|
+
const escape=(value:string)=>`"${(/^[\s]*[=+\-@]/.test(value)?"'"+value:value).replace(/"/g,'""')}"`;
|
|
46
|
+
return '\uFEFF'+[[...dataset.columns.map(c=>c.name),'行类型'],...rows.map(row=>[...row.cells.map(displayCell),row.kind])].map(row=>row.map(escape).join(',')).join('\r\n');
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {queryWidget} from '../finebi/client';
|
|
2
|
+
import type {AppConfig,FilterState,QueryResult,WidgetBinding} from '../finebi/types';
|
|
3
|
+
export type WidgetState={loading:boolean;result?:QueryResult;error?:Error;at?:string};
|
|
4
|
+
export const widgetKey=(binding:WidgetBinding,index:number)=>`${index}:${binding.entryId}`;
|
|
5
|
+
export async function loadWidgets(config:AppConfig,filters:FilterState,signal:AbortSignal,onState:(id:string,state:WidgetState)=>void,fetchImpl:typeof fetch=fetch) {
|
|
6
|
+
const queue=config.widgets.map((binding,index)=>({binding,key:widgetKey(binding,index)}));
|
|
7
|
+
const worker=async()=>{
|
|
8
|
+
while(queue.length&&!signal.aborted) {
|
|
9
|
+
const {binding,key}=queue.shift()!;
|
|
10
|
+
onState(key,{loading:true});
|
|
11
|
+
try {
|
|
12
|
+
const result=await queryWidget(config,binding,filters,signal,fetchImpl);
|
|
13
|
+
if(!signal.aborted)onState(key,{loading:false,result,at:new Date().toLocaleTimeString()});
|
|
14
|
+
} catch(error) {if(!signal.aborted)onState(key,{loading:false,error:error instanceof Error?error:new Error('查询失败')});}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
await Promise.all(Array.from({length:Math.min(3,queue.length)},worker));
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Synthetic native responses, confined to the browser acceptance page.
|
|
2
|
+
export function fixtureConfig(layout, chartCount = 8) {
|
|
3
|
+
return {schemaVersion: '1', title: '合成门店经营验证', basePath: '/fixture-decision', layout,
|
|
4
|
+
dashboardId: 'fixture-dashboard', directoryEntryId: 'fixture-directory', maxRows: 1000, pageSize: 8,
|
|
5
|
+
filters: [{name: '区域', label: '区域', type: 'text'}], widgets: [
|
|
6
|
+
...Array.from({length: 4}, (_, i) => ({entryId: `kpi${i}`, name: i === 3 ? '独立范围指标' : `合成指标 ${i + 1}`,
|
|
7
|
+
type: 'kpi', valueFieldId: 'amount', valueFormat: 'number', filterNames: i === 3 ? [] : ['区域']})),
|
|
8
|
+
...Array.from({length: chartCount}, (_, i) => ({entryId: `chart${i}`, name: `${['趋势','对比','构成'][i % 3]} ${i + 1} · 较长标题用于检查文字换行和布局边界`,
|
|
9
|
+
type: ['line','bar','pie'][i % 3], categoryFieldId: 'region', valueFieldId: 'amount', filterNames: ['区域']})),
|
|
10
|
+
{entryId: 'table', name: '合成明细', type: 'table', filterNames: ['区域']},
|
|
11
|
+
]};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nativeFixture(id, refer, scenario) {
|
|
15
|
+
const filtered = refer['区域']?.[0] === '华东';
|
|
16
|
+
const kpi = id.startsWith('kpi');
|
|
17
|
+
const headers = kpi ? [{dId: 'amount', text: '金额'}] : [{dId: 'region', text: '区域'}, {dId: 'amount', text: '金额'}];
|
|
18
|
+
const cell = (value, authority = true) => ({value, authority});
|
|
19
|
+
let rows = kpi ? [[cell(filtered ? '120' : '260')]]
|
|
20
|
+
: (filtered ? [['华东','120']] : [['华东','120'],['华北','80'],['华南','60']]).map(([region, amount]) => [cell(region), cell(amount)]);
|
|
21
|
+
if (!filtered && id === 'chart0') rows = [[cell('华东'),cell('120')],[cell('华北'),cell(null)],[cell('华南'),cell('-20')]];
|
|
22
|
+
if (!filtered && id === 'table') rows[1][1] = cell('hidden', false);
|
|
23
|
+
if (scenario === 'empty') rows = [];
|
|
24
|
+
const partial = scenario === 'partial' && id === 'chart0';
|
|
25
|
+
if (partial) rows[1] = [cell('华北')];
|
|
26
|
+
return {previewResult: {resultType: 'DETAIL', header: headers, items: rows, row: partial ? 30 : rows.length,
|
|
27
|
+
size: 1000, hasNext: partial}, widgetBean: {dimensions: {region: {type: 1}, amount: {type: 2}}}};
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>FineBI 模板浏览器验收</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<section id="browser-checks" aria-label="浏览器验收">
|
|
6
|
+
<h1>模板浏览器验收 · 合成数据</h1>
|
|
7
|
+
<p>复用实际页面组件,不连接 FineBI。调整浏览器视口后重新运行;真实身份、业务口径与视觉质量需另外核对。</p>
|
|
8
|
+
<label>测试布局 <select id="test-layout"><option>overview</option><option>workbench</option><option>cockpit</option><option selected>retail</option></select></label>
|
|
9
|
+
<button id="test-run">运行浏览器检查</button>
|
|
10
|
+
<strong id="test-result" role="status">未运行</strong>
|
|
11
|
+
<ol id="test-checks"></ol>
|
|
12
|
+
</section>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
<script type="module" src="./main.tsx"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {createElement} from 'react';
|
|
2
|
+
import {createRoot} from 'react-dom/client';
|
|
3
|
+
import {flushSync} from 'react-dom';
|
|
4
|
+
import App from '../../src/App';
|
|
5
|
+
import '../../src/styles.css';
|
|
6
|
+
import {fixtureConfig, nativeFixture} from './fixtures.mjs';
|
|
7
|
+
import './test-page.css';
|
|
8
|
+
|
|
9
|
+
const root = createRoot(document.getElementById('root')!);
|
|
10
|
+
const result = document.getElementById('test-result')!;
|
|
11
|
+
const checks = document.getElementById('test-checks')!;
|
|
12
|
+
const runButton = document.getElementById('test-run') as HTMLButtonElement;
|
|
13
|
+
const layoutSelect = document.getElementById('test-layout') as HTMLSelectElement;
|
|
14
|
+
let config = fixtureConfig(layoutSelect.value), scenario = 'normal', generation = 0, failOnce = false;
|
|
15
|
+
let requests: any[] = [];
|
|
16
|
+
const runtimeErrors: string[] = [];
|
|
17
|
+
for (const level of ['error', 'warn'] as const) {
|
|
18
|
+
const original = console[level].bind(console);
|
|
19
|
+
console[level] = (...args) => {runtimeErrors.push(args.map(String).join(' ')); original(...args);};
|
|
20
|
+
}
|
|
21
|
+
window.addEventListener('error', event => runtimeErrors.push(event.message));
|
|
22
|
+
window.addEventListener('unhandledrejection', event => runtimeErrors.push(String(event.reason)));
|
|
23
|
+
|
|
24
|
+
// This page is a test-only network boundary. Unexpected app requests fail closed.
|
|
25
|
+
window.fetch = async (input, init = {}) => {
|
|
26
|
+
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
|
27
|
+
if (url.origin !== location.origin) throw new Error('浏览器夹具禁止外部请求');
|
|
28
|
+
if (url.pathname.endsWith('/app-config.json')) return Response.json(config);
|
|
29
|
+
if (url.pathname !== '/fixture-decision/v5/api/dashboard/report/fixture-dashboard/widgets/query') throw new Error(`未配置的夹具请求:${url.pathname}`);
|
|
30
|
+
const body = JSON.parse(String(init.body));
|
|
31
|
+
requests.push(body);
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
33
|
+
if (init.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
34
|
+
const id = body.widgetIds[0];
|
|
35
|
+
if (failOnce && id === 'chart1') {failOnce = false; return Response.json({}, {status: 403});}
|
|
36
|
+
return Response.json({success: true, data: {[id]: nativeFixture(id, body.refer, scenario)}});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function assert(condition: unknown, message: string): asserts condition {if (!condition) throw new Error(message);}
|
|
40
|
+
async function wait(check: () => boolean, label: string) {
|
|
41
|
+
const deadline = performance.now() + 15000;
|
|
42
|
+
while (!check()) {
|
|
43
|
+
assert(performance.now() < deadline, `超时:${label}`);
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function pass(message: string) {const li = document.createElement('li'); li.textContent = `PASS · ${message}`; checks.append(li);}
|
|
48
|
+
const panels = () => Array.from(document.querySelectorAll<HTMLElement>('#root .panel'));
|
|
49
|
+
async function settled() {
|
|
50
|
+
await wait(() => panels().length === config.widgets.length && panels().every(panel => /更新于|查询失败/.test(panel.querySelector('.component-status')?.textContent ?? '')), '组件查询结束');
|
|
51
|
+
}
|
|
52
|
+
async function mount(state = 'normal', chartCount = 8) {
|
|
53
|
+
scenario = state; config = fixtureConfig(layoutSelect.value, chartCount); requests = []; failOnce = state === 'failure';
|
|
54
|
+
flushSync(() => root.render(createElement(App, {key: ++generation})));
|
|
55
|
+
await settled();
|
|
56
|
+
}
|
|
57
|
+
function assertRequests(filtered: boolean) {
|
|
58
|
+
assert(requests.length === config.widgets.length, '每个展示组件应恰好请求一次');
|
|
59
|
+
for (const request of requests) {
|
|
60
|
+
assert(request.reportEntryId === 'fixture-directory', '目录入口丢失');
|
|
61
|
+
const expected = filtered && request.widgetIds[0] !== 'kpi3' ? {区域: ['华东']} : {};
|
|
62
|
+
assert(JSON.stringify(request.refer) === JSON.stringify(expected), '筛选编码或组件作用范围错误');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function painted(count: number) {
|
|
66
|
+
await wait(() => {
|
|
67
|
+
const canvases = Array.from(document.querySelectorAll<HTMLCanvasElement>('#root .chart canvas'));
|
|
68
|
+
return canvases.length === count && canvases.every(canvas => {
|
|
69
|
+
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
70
|
+
let colored = 0;
|
|
71
|
+
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] && ++colored > 100) return true;
|
|
72
|
+
return false;
|
|
73
|
+
});
|
|
74
|
+
}, '柱/线/饼图实际绘制');
|
|
75
|
+
assert(runtimeErrors.length === 0, runtimeErrors.join('\n'));
|
|
76
|
+
}
|
|
77
|
+
function geometry(even: boolean) {
|
|
78
|
+
assert(document.documentElement.scrollWidth <= innerWidth + 1, '页面存在横向溢出');
|
|
79
|
+
const cards = panels().map(panel => panel.getBoundingClientRect());
|
|
80
|
+
for (let i = 0; i < cards.length; i++) {
|
|
81
|
+
const a = cards[i];
|
|
82
|
+
assert(a.width > 0 && a.left >= -1 && a.right <= innerWidth + 1, '卡片超出视口');
|
|
83
|
+
for (const b of cards.slice(i + 1)) assert(Math.min(a.right,b.right) - Math.max(a.left,b.left) <= 1 || Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) <= 1, '卡片重叠');
|
|
84
|
+
}
|
|
85
|
+
const grid = document.querySelector<HTMLElement>('#root .grid')!;
|
|
86
|
+
const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').length;
|
|
87
|
+
const regular = Array.from(grid.querySelectorAll<HTMLElement>(':scope > .panel:not(.wide)')).map(el => el.getBoundingClientRect());
|
|
88
|
+
if (even && columns === 2 && regular.length % 2 === 0) for (let i = 0; i < regular.length; i += 2) assert(Math.abs(regular[i].top - regular[i + 1].top) < 2, '偶数组件双列排列出现孤立卡片');
|
|
89
|
+
}
|
|
90
|
+
function button(text: string) {
|
|
91
|
+
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('#root button')).find(el => el.textContent?.trim() === text);
|
|
92
|
+
assert(found, `找不到按钮:${text}`); return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function retailNavigation() {
|
|
96
|
+
if (layoutSelect.value !== 'retail') return;
|
|
97
|
+
const nav = document.querySelector<HTMLElement>('#root .retail-nav')!;
|
|
98
|
+
for (const id of ['components', 'data-notes', 'dashboard']) {
|
|
99
|
+
nav.querySelector<HTMLAnchorElement>(`a[href="#${id}"]`)!.click();
|
|
100
|
+
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
101
|
+
const bar = nav.getBoundingClientRect(), target = document.getElementById(id)!.getBoundingClientRect();
|
|
102
|
+
assert(bar.top >= -1 && bar.bottom < innerHeight, '跳转后导航离开视口');
|
|
103
|
+
assert(target.top >= bar.bottom - 1 && target.top < innerHeight, '锚点标题被导航遮住或离开视口');
|
|
104
|
+
}
|
|
105
|
+
assert(document.querySelector('#components h2')?.textContent === '销售表现', '销售分析未定位到区域标题');
|
|
106
|
+
window.scrollTo(0, 0);
|
|
107
|
+
pass('页内导航持续可见,三个锚点标题可见且不被遮挡');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function run() {
|
|
111
|
+
runButton.disabled = true; layoutSelect.disabled = true; checks.replaceChildren(); result.textContent = '运行中';
|
|
112
|
+
const started = performance.now();
|
|
113
|
+
try {
|
|
114
|
+
assert(runtimeErrors.length === 0, '已有运行错误,请修复并刷新测试页:' + runtimeErrors.join('\n'));
|
|
115
|
+
await mount(); await painted(8); assertRequests(false); geometry(true);
|
|
116
|
+
assert(document.querySelector('#root .widget-table')?.textContent?.includes('受限'), '受限值未标记');
|
|
117
|
+
pass('八张图实际绘制,长标题/边界/偶数排列、目录入口和受限值');
|
|
118
|
+
const input = document.querySelector<HTMLInputElement>('#root input[aria-label="区域"]');
|
|
119
|
+
assert(input, '文本筛选尚未加载');
|
|
120
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, '华东');
|
|
121
|
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
|
122
|
+
await wait(() => document.querySelector('.filter-status')?.textContent === '待应用', '筛选草稿');
|
|
123
|
+
assert(requests.length === config.widgets.length, '编辑草稿触发了查询');
|
|
124
|
+
requests = []; button('应用筛选').click();
|
|
125
|
+
await wait(() => requests.length === config.widgets.length, '应用发起查询'); await settled();
|
|
126
|
+
assertRequests(true);
|
|
127
|
+
assert(document.querySelector('#root .kpi')?.textContent === '120', '应用条件后指标未变化');
|
|
128
|
+
pass('草稿不查询,应用后按真实编码及组件范围请求,显示值变化');
|
|
129
|
+
requests = []; button('恢复全部默认').click();
|
|
130
|
+
await wait(() => requests.length === config.widgets.length, '恢复默认查询'); await settled();
|
|
131
|
+
assertRequests(false); assert(document.querySelector('#root .kpi')?.textContent === '260', '恢复默认后指标未恢复');
|
|
132
|
+
pass('恢复默认省略覆盖,显示值恢复');
|
|
133
|
+
await mount('normal', 7); await painted(7); geometry(false); pass('七张图、奇数组件无溢出与重叠');
|
|
134
|
+
await mount('empty'); assert(document.querySelectorAll('#root .empty').length === config.widgets.length, '空结果未独立显示');
|
|
135
|
+
assert(!document.querySelector('#root canvas'), '空结果保留了旧图'); pass('空结果不保留旧图');
|
|
136
|
+
await mount('partial'); await painted(8);
|
|
137
|
+
assert(document.querySelector('#root .notice')?.textContent?.includes('首屏'), '截断警告缺失');
|
|
138
|
+
assert(document.querySelector('#root .notice')?.textContent?.includes('PARTIAL'), '部分适配标记缺失');
|
|
139
|
+
pass('部分适配与首屏范围警告保持可见');
|
|
140
|
+
await mount('failure'); assert(document.querySelectorAll('#root .component-error').length === 1, '单组件失败未隔离');
|
|
141
|
+
assert(document.querySelector('#root .component-error')?.textContent?.includes('PERMISSION_DENIED'), '权限失败语义错误');
|
|
142
|
+
requests = []; button('重试组件').click(); await wait(() => requests.length === 1, '组件重试'); await settled(); await painted(8);
|
|
143
|
+
assert(requests.length === 1 && requests[0].widgetIds[0] === 'chart1', '重试影响了其他组件'); pass('权限失败独立显示,单项重试只请求失败组件');
|
|
144
|
+
await mount(); await painted(8); geometry(true); await retailNavigation();
|
|
145
|
+
result.textContent = `PASS · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${checks.children.length} 项 · ${((performance.now()-started)/1000).toFixed(1)}s`;
|
|
146
|
+
} catch (error) {result.textContent = `FAIL · ${layoutSelect.value} · ${innerWidth}×${innerHeight} · ${String(error)}`;}
|
|
147
|
+
finally {runButton.disabled = false; layoutSelect.disabled = false;}
|
|
148
|
+
}
|
|
149
|
+
runButton.addEventListener('click', run);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#browser-checks{padding:20px 24px;border-bottom:2px solid #adb8c5;background:#fff;color:#243042;overflow-wrap:anywhere}
|
|
2
|
+
#browser-checks h1{font-size:19px;margin:0 0 8px}#browser-checks p,#browser-checks li{font-size:12px;line-height:1.7}
|
|
3
|
+
#browser-checks label{display:inline-flex;align-items:center;gap:8px;margin:8px 12px 8px 0}#test-result{display:block;margin:12px 0;font-size:14px}
|
|
4
|
+
#test-checks{padding-left:22px;margin:0}#test-run{background:#243042;color:white}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {fileURLToPath} from 'node:url';
|
|
2
|
+
import {defineConfig, normalizePath} from 'vite';
|
|
3
|
+
|
|
4
|
+
const project = fileURLToPath(new URL('../../', import.meta.url));
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL('./', import.meta.url)),
|
|
7
|
+
publicDir: false,
|
|
8
|
+
envDir: false,
|
|
9
|
+
cacheDir: fileURLToPath(new URL('../../temp/vite-browser', import.meta.url)),
|
|
10
|
+
resolve: {alias: [{find: /^date-fns\/locale$/, replacement: fileURLToPath(new URL('../../src/components/calendar-locales.ts', import.meta.url))}]},
|
|
11
|
+
server: {host: '127.0.0.1', port: 4188, strictPort: true, hmr: false,
|
|
12
|
+
fs: {allow: [project], deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', normalizePath(project) + 'temp/**']}},
|
|
13
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {build} from 'vite';
|
|
4
|
+
|
|
5
|
+
test('production entry defers charts and optional controls and only bundles supported date locales',async()=>{
|
|
6
|
+
const result=await build({logLevel:'silent',build:{write:false}});
|
|
7
|
+
const chunks=result.output.filter(item=>item.type==='chunk');
|
|
8
|
+
const initial=new Set();
|
|
9
|
+
const visit=chunk=>{if(initial.has(chunk))return;initial.add(chunk);for(const name of chunk.imports){const imported=chunks.find(c=>c.fileName===name);if(imported)visit(imported);}};
|
|
10
|
+
chunks.filter(c=>c.isEntry).forEach(visit);
|
|
11
|
+
const initialModules=[...initial].flatMap(c=>Object.keys(c.modules));
|
|
12
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(echarts|zrender)\//.test(id)),'ECharts must not be an entry dependency');
|
|
13
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(antd|@rc-component\/picker)\//.test(id)),'optional filter UI must not be an entry dependency');
|
|
14
|
+
const modules=chunks.flatMap(c=>Object.keys(c.modules));
|
|
15
|
+
assert.ok(!modules.some(id=>id.includes('/tests/browser/')),'browser fixtures and their fetch replacement must not enter production');
|
|
16
|
+
const locales=new Set(modules.flatMap(id=>{const match=id.match(/date-fns\/locale\/([^/]+)/);return match?[match[1]]:[];}));
|
|
17
|
+
assert.deepEqual([...locales].filter(name=>!['en-US','zh-CN','_lib','en-US.js','zh-CN.js'].includes(name)),[],'do not bundle unused calendar languages');
|
|
18
|
+
for(const type of ['Bar','Line','Pie'])assert.ok(chunks.some(c=>c.isDynamicEntry&&Object.keys(c.modules).some(id=>id.endsWith(`/charts/${type}.tsx`))),`${type} has an independent lazy entry`);
|
|
19
|
+
});
|