@finebi/finebi-cli 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/skills/finebi-cli-agent/SKILL.md +43 -123
- package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
- package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
- package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
- package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
- package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
- package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
- package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
- package/skills/finebi-cli-agent/references/web-app.md +4 -2
- package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +18 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +8 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +14 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +8 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +1 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +2 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +15 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +159 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +8 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
- package/src/app.js +18 -6
- package/src/browser-auth.js +3 -4
- package/src/config.js +1 -1
- package/src/error-catalog.js +2 -2
- package/src/help.js +8 -5
- package/templates/web-app/AGENTS.md +18 -5
- package/templates/web-app/README.md +12 -5
- package/templates/web-app/docs/acceptance.md +14 -1
- package/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/templates/web-app/docs/data-contract.md +66 -38
- package/templates/web-app/docs/filters.md +2 -2
- package/templates/web-app/gitignore.template +6 -0
- package/templates/web-app/scripts/browser-session.mjs +59 -0
- package/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/templates/web-app/scripts/dev-session.mjs +57 -0
- package/templates/web-app/scripts/session-file.mjs +45 -0
- package/templates/web-app/src/App.tsx +8 -1
- package/templates/web-app/src/components/DataTable.tsx +1 -1
- package/templates/web-app/src/components/Widget.tsx +1 -0
- package/templates/web-app/src/finebi/client.ts +20 -4
- package/templates/web-app/src/finebi/config.d.ts +2 -0
- package/templates/web-app/src/finebi/config.js +15 -1
- package/templates/web-app/src/finebi/normalize.ts +159 -1
- package/templates/web-app/src/finebi/types.ts +8 -6
- package/templates/web-app/src/retail.css +1 -1
- package/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/templates/web-app/tests/proxy.test.mjs +20 -5
- package/templates/web-app/tests/runtime.test.mjs +7 -0
- package/templates/web-app/tests/session.test.mjs +91 -0
- package/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/templates/web-app/vite.config.ts +9 -4
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after, test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
import {createElement} from 'react';
|
|
5
|
+
import {renderToStaticMarkup} from 'react-dom/server';
|
|
6
|
+
const server = await createServer({configFile: false, cacheDir: '../../../temp/vite-table-tests', server: {middlewareMode: true, hmr: false}});
|
|
7
|
+
after(() => server.close());
|
|
8
|
+
const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
|
|
9
|
+
const {DataTable} = await server.ssrLoadModule('/src/components/DataTable.tsx');
|
|
10
|
+
const {tableCsv} = await server.ssrLoadModule('/src/services/table-data.ts');
|
|
11
|
+
const metric = (text, rowSumInfo = {totalSum: false, innerSum: false}, extra = {}) => ({dId: 'amount', text, authority: true, rowSumInfo, ...extra});
|
|
12
|
+
const group = items => ({previewResult: {resultType: 'BIGROUP', header: [{dId: 'region', text: '地区'}, {dId: 'city', text: '城市'}, {dId: 'amount', text: '金额'}], items, hasNext: true, hasPrev: false}, widgetBean: {dimensions: {region: {type: 1}, city: {type: 1}}, settings: {tableStyle: {rowCounts: 10}}}});
|
|
13
|
+
const detailInfo = {totalSum: false, innerSum: false};
|
|
14
|
+
const totalInfo = {totalSum: true, innerSum: false};
|
|
15
|
+
const node = (dId, text, children = null, values = null) => ({dId, text, formatText: text, authority: true, children, values});
|
|
16
|
+
const crossMetric = (text, colSumInfo = detailInfo, rowSumInfo = detailInfo, dId = 'amount') => ({dId, text, authority: true, colSumInfo, rowSumInfo});
|
|
17
|
+
const cross = (top, left) => ({previewResult: {resultType: 'BICROSS', top: {header: [{dId: 'year', text: '年'}], items: [node(null, null, top)]}, left: {header: [{dId: 'region', text: '地区'}], items: [node(null, null, left)]}, hasXNext: true, hasXPrev: false, hasYNext: false, hasYPrev: false}});
|
|
18
|
+
|
|
19
|
+
test('cross display removes only repeated metric captions and DATA summary carriers', () => {
|
|
20
|
+
const source = cross([node('region', '金额', [node('target-name', '金额', null, [node('amount', '金额')])])], [
|
|
21
|
+
node('region', '合计', [node('__summary_dim_id__', '合计', null, [crossMetric('1')])]),
|
|
22
|
+
node('__summary_dim_id__', '合计', null, [crossMetric('2', detailInfo, totalInfo)]),
|
|
23
|
+
node('__summary_dim_id__', '合计', null, [crossMetric('3', detailInfo, {totalSum: false, innerSum: true})]),
|
|
24
|
+
node('__summary_dim_id__', '合计', null, [{...crossMetric('4'), rowSumInfo: null}]),
|
|
25
|
+
]);
|
|
26
|
+
const data = normalize(source).datasets[0];
|
|
27
|
+
assert.equal(data.columns[1].name, '金额 / 金额', 'real repeated grouping labels must remain');
|
|
28
|
+
assert.equal(data.columns[1].path.length, 3, 'source path metadata must remain intact');
|
|
29
|
+
assert.equal(data.rows[0].cells[0].display, '合计', 'business dimension named 合计 must remain');
|
|
30
|
+
assert.equal(data.rows[0].path.length, 2);
|
|
31
|
+
assert.deepEqual(data.rows.slice(1).map(row => [row.kind, row.cells[0].display]), [['GRAND_TOTAL', '合计'], ['SUBTOTAL', '合计'], ['UNKNOWN', '合计']]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('cross metric rows deduplicate only the verified trailing metric caption', () => {
|
|
35
|
+
const rows = [
|
|
36
|
+
node('region', '金额', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('7')])])]),
|
|
37
|
+
node('region', '金额', [node('other-dimension', '金额', null, [crossMetric('8')])]),
|
|
38
|
+
node('target-name', '金额', [node('amount', '别名', null, [crossMetric('9')])]),
|
|
39
|
+
node('target-name', '金额', [node('amount', '金额', null, [crossMetric('10', detailInfo, detailInfo, 'count')])]),
|
|
40
|
+
];
|
|
41
|
+
const data = normalize(cross([node('year', '2024', null, [{}])], rows)).datasets[0];
|
|
42
|
+
assert.deepEqual(data.rows.map(r => r.cells[0].display), ['金额 / 金额', '金额 / 金额', '金额 / 别名', '金额 / 金额']);
|
|
43
|
+
assert.equal(data.rows[0].path.length, 3);
|
|
44
|
+
assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
|
|
45
|
+
const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
|
|
46
|
+
assert.doesNotMatch(html, /金额 \/ 金额 \/ 金额/);
|
|
47
|
+
assert.doesNotMatch(tableCsv(data, data.rows), /金额 \/ 金额 \/ 金额/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('cross real null renders as dash while placeholders, missing payload and permissions stay distinct', () => {
|
|
51
|
+
const empty = {...crossMetric(null), value: null, formatText: null};
|
|
52
|
+
const cells = [empty, {...empty, dId: null}, {...empty, authority: false}, {...empty, authority: undefined}, {dId: 'amount', authority: true}, {...empty, formatText: '仅展示'}];
|
|
53
|
+
const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
|
|
54
|
+
assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.state), ['NULL', 'UNAVAILABLE', 'RESTRICTED', 'UNAVAILABLE', 'UNAVAILABLE', 'UNAVAILABLE']);
|
|
55
|
+
assert.equal(data.rows[0].cells[1].reason, null);
|
|
56
|
+
const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
|
|
57
|
+
assert.match(html, /<td>—<\/td><td>不可用<\/td><td>受限<\/td>/);
|
|
58
|
+
assert.match(tableCsv(data, data.rows), /"—","不可用","受限"/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('cross placeholders without valid metric ids never become available numeric values', () => {
|
|
62
|
+
const cells = [null, undefined, '', ' ', 12].map(dId => ({...crossMetric('12'), dId, value: 12}));
|
|
63
|
+
const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
|
|
64
|
+
for (const cell of data.rows[0].cells.slice(1)) {
|
|
65
|
+
assert.equal(cell.state, 'UNAVAILABLE');
|
|
66
|
+
assert.equal(cell.value, null);
|
|
67
|
+
assert.equal(cell.sourceFieldId, null);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('cross table aligns repeated metric ids by column slots and preserves both axes and totals', () => {
|
|
72
|
+
for (const totalFirst of [false, true]) {
|
|
73
|
+
const columns = [node('year', '2024', null, [node('amount', '金额')]), node('year', '2025', null, [node('amount', '金额')])];
|
|
74
|
+
const summary = node('__summary_dim_id__', '合计', [node('amount', '金额', null, [{}])]);
|
|
75
|
+
columns.splice(totalFirst ? 0 : columns.length, 0, summary);
|
|
76
|
+
const values = [crossMetric('9007199254740993.12'), crossMetric('2.50')];
|
|
77
|
+
values.splice(totalFirst ? 0 : values.length, 0, crossMetric('9007199254740995.62', totalInfo));
|
|
78
|
+
const source = cross(columns, [node('region', '东部', [node('__summary_dim_id__', null, null, values)]), node('__summary_dim_id__', '合计', [node('amount', '金额', null, values.map(v => ({...v, rowSumInfo: totalInfo})))])]);
|
|
79
|
+
const result = normalize(source), data = result.datasets[0];
|
|
80
|
+
assert.equal(result.resultType, 'CROSS_TABLE'); assert.equal(result.normalization, 'COMPLETE');
|
|
81
|
+
assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.value), values.map(v => v.text));
|
|
82
|
+
assert.equal(new Set(data.columns.map(c => c.fieldId)).size, 4);
|
|
83
|
+
assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
|
|
84
|
+
assert.deepEqual(data.rows.map(r => r.kind), ['DATA', 'GRAND_TOTAL']);
|
|
85
|
+
assert.equal(data.columns[totalFirst ? 1 : 3].kind, 'GRAND_TOTAL');
|
|
86
|
+
assert.equal(result.range.mode, 'INITIAL_VIEW'); assert.equal(result.range.page, null);
|
|
87
|
+
assert.deepEqual(result.range.axes, {horizontal: {hasNext: true, hasPrevious: false}, vertical: {hasNext: false, hasPrevious: false}});
|
|
88
|
+
assert.equal(result.range.hasNext, true); assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
|
|
89
|
+
const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
|
|
90
|
+
assert.match(html, /2024/); assert.match(html, /2025/); assert.match(html, /总计/);
|
|
91
|
+
assert.match(tableCsv(data, data.rows), /9007199254740993.12/);
|
|
92
|
+
assert.equal(source.previewResult.top.items[0].children.length, 3);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('cross table supports metrics on rows and multi-level columns without id joins', () => {
|
|
97
|
+
const source = cross([node('year', '2024', [node('quarter', 'Q1', null, []), node('quarter', 'Q2', null, [])])], [
|
|
98
|
+
node('region', '东部', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('12'), crossMetric('18')])]), node('target-name', '数量', [node('count', '数量', null, [crossMetric('3', detailInfo, detailInfo, 'count'), crossMetric('4', detailInfo, detailInfo, 'count')])])]),
|
|
99
|
+
]);
|
|
100
|
+
const data = normalize(source).datasets[0];
|
|
101
|
+
assert.equal(data.columns.length, 3); assert.equal(data.rows.length, 2);
|
|
102
|
+
assert.match(data.columns[1].name, /2024.*Q1/);
|
|
103
|
+
assert.match(data.rows[1].cells[0].display, /东部.*数量/);
|
|
104
|
+
assert.deepEqual(data.rows[1].cells.slice(1).map(c => c.sourceFieldId), ['count', 'count']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('cross table never exposes restricted axis labels and retains missing/ambiguous value states', () => {
|
|
108
|
+
const secret = node('year', 'secret column', null, [{}]); secret.authority = false;
|
|
109
|
+
const row = node('region', 'secret row', null, [crossMetric('123')]); row.authority = false;
|
|
110
|
+
const result = normalize(cross([secret], [row]));
|
|
111
|
+
assert.doesNotMatch(JSON.stringify(result), /secret column|secret row/);
|
|
112
|
+
assert.equal(result.datasets[0].rows[0].cells[0].state, 'RESTRICTED');
|
|
113
|
+
assert.equal(result.datasets[0].rows[0].cells[1].state, 'RESTRICTED');
|
|
114
|
+
const source = cross([node('year', 'A', null, [{}, {}])], [node('region', 'X', null, [{...crossMetric('1万'), formatText: '1万'}])]);
|
|
115
|
+
const incomplete = normalize(source);
|
|
116
|
+
assert.equal(incomplete.normalization, 'PARTIAL');
|
|
117
|
+
assert.equal(incomplete.datasets[0].rows[0].cells[1].state, 'UNAVAILABLE');
|
|
118
|
+
assert.equal(incomplete.datasets[0].rows[0].cells[2].reason, 'MISSING_CELL');
|
|
119
|
+
source.previewResult.left.items[0].children[0].values.push(crossMetric('2'), crossMetric('3'));
|
|
120
|
+
assert.equal(normalize(source).normalization, 'UNSUPPORTED', 'extra values must not be silently dropped');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('cross table distinguishes empty views, missing axes and unsupported mixed node topology', () => {
|
|
124
|
+
assert.equal(normalize(cross([], [])).normalization, 'COMPLETE');
|
|
125
|
+
assert.equal(normalize(cross([], [])).datasets[0].returnedRowCount, 0);
|
|
126
|
+
assert.equal(normalize({previewResult: {resultType: 'BICROSS', left: {header: [], items: []}}}).normalization, 'UNSUPPORTED');
|
|
127
|
+
assert.equal(normalize(cross([node('x', 'A', [node('x', 'B')], [{}])], [])).normalization, 'UNSUPPORTED');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('cross table handles no column dimensions and preserves metric-name intersection placeholders', () => {
|
|
131
|
+
const source = cross([], [node('target', '金额', null, [crossMetric('7'), {dId: null, text: null, value: null, authority: true}])]);
|
|
132
|
+
source.previewResult.top = {header: [], items: []};
|
|
133
|
+
const result = normalize(source);
|
|
134
|
+
assert.equal(result.normalization, 'COMPLETE');
|
|
135
|
+
assert.deepEqual(result.datasets[0].rows[0].cells.slice(1).map(c => c.state), ['AVAILABLE', 'UNAVAILABLE']);
|
|
136
|
+
assert.equal(result.datasets[0].rows[0].cells[2].value, null);
|
|
137
|
+
const swapped = cross([node('year', '2024', null, [node('amount', '金额')])], [node('region', '东部', null, [crossMetric('1', detailInfo, detailInfo, 'count')])]);
|
|
138
|
+
assert.equal(normalize(swapped).normalization, 'PARTIAL');
|
|
139
|
+
assert.equal(normalize(swapped).datasets[0].rows[0].cells[1].reason, 'AMBIGUOUS_FIELD');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('group preserves parent keys, totals, metric precision and the first group page', () => {
|
|
143
|
+
const source = group([{dId: 'region', text: '东部', authority: true, values: [metric('9007199254740993.12', {totalSum: false, innerSum: true})], children: [
|
|
144
|
+
{dId: 'city', text: '甲城', authority: true, values: [metric('12.50', undefined, {formatText: '12.50元'})]},
|
|
145
|
+
]}, {values: [metric('9007199254741005.62', {totalSum: true, innerSum: false})]}]);
|
|
146
|
+
const result = normalize(source), data = result.datasets[0];
|
|
147
|
+
assert.equal(result.resultType, 'GROUP_TABLE');
|
|
148
|
+
assert.equal(result.normalization, 'COMPLETE');
|
|
149
|
+
assert.deepEqual(data.rows.map(r => r.kind), ['SUBTOTAL', 'DATA', 'GRAND_TOTAL']);
|
|
150
|
+
assert.deepEqual(data.rows[1].cells.map(c => c.value), ['东部', '甲城', '12.50']);
|
|
151
|
+
assert.equal(data.rows[0].cells[2].value, '9007199254740993.12');
|
|
152
|
+
assert.equal(data.returnedRowCount, 3);
|
|
153
|
+
assert.deepEqual(result.range, {mode: 'PAGE', page: 1, pageSize: 10, pageUnit: 'GROUP', hasNext: true, hasPrevious: false, total: {value: null, unit: 'GROUP'}});
|
|
154
|
+
assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
|
|
155
|
+
const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
|
|
156
|
+
assert.match(html, /小计/); assert.match(html, /总计/);
|
|
157
|
+
assert.match(tableCsv(data, data.rows), /GRAND_TOTAL/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('group keeps restricted and ambiguous cells unavailable and does not infer numeric group keys', () => {
|
|
161
|
+
const source = group([{dId: 'region', text: '42', authority: true, values: [metric('12万', undefined, {formatText: '12万'})], children: [
|
|
162
|
+
{dId: 'city', text: '秘密', authority: false, values: [metric('999', undefined, {authority: false})]},
|
|
163
|
+
{dId: 'city', text: '乙城', authority: true, values: [metric('1'), metric('2')]},
|
|
164
|
+
{dId: 'city', text: '丙城', authority: true, values: [metric(null, null)]},
|
|
165
|
+
]}]);
|
|
166
|
+
source.widgetBean.dimensions.region.type = 2;
|
|
167
|
+
const result = normalize(source), rows = result.datasets[0].rows;
|
|
168
|
+
assert.equal(rows[0].cells[0].reason, 'GROUP_KEY_SEMANTICS_UNKNOWN');
|
|
169
|
+
assert.equal(rows[0].cells[2].state, 'UNAVAILABLE');
|
|
170
|
+
assert.equal(rows[1].cells[1].state, 'RESTRICTED');
|
|
171
|
+
assert.equal(rows[1].cells[2].state, 'RESTRICTED');
|
|
172
|
+
assert.equal(rows[2].cells[2].reason, 'AMBIGUOUS_FIELD');
|
|
173
|
+
assert.equal(rows[3].cells[2].state, 'UNAVAILABLE');
|
|
174
|
+
assert.equal(rows[3].kind, 'UNKNOWN');
|
|
175
|
+
assert.equal(result.normalization, 'PARTIAL');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('group header expansion reports an initial view and malformed groups are not empty successes', () => {
|
|
179
|
+
const source = group([]);
|
|
180
|
+
source.widgetBean.settings.tableStyle.showRowHeaderIcon = true;
|
|
181
|
+
const result = normalize(source);
|
|
182
|
+
assert.equal(result.range.mode, 'INITIAL_VIEW');
|
|
183
|
+
assert.equal(result.range.page, null);
|
|
184
|
+
assert.equal(result.range.hasNext, null);
|
|
185
|
+
assert.equal(result.datasets[0].returnedRowCount, 0);
|
|
186
|
+
assert.equal(normalize({previewResult: {resultType: 'BIGROUP'}}).normalization, 'UNSUPPORTED');
|
|
187
|
+
source.previewResult.items = [null, {dId: 'unknown', values: [metric('1')]}];
|
|
188
|
+
assert.equal(normalize(source).normalization, 'PARTIAL');
|
|
189
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {createServer as httpServer} from 'node:http';
|
|
4
|
+
import {mkdir,mkdtemp,cp,writeFile,readFile} from 'node:fs/promises';
|
|
5
|
+
import {resolve} from 'node:path';
|
|
6
|
+
import {createServer} from 'vite';
|
|
7
|
+
|
|
8
|
+
test('real Vite uses project env, persists sessions, blocks credential files and survives token updates',async()=>{
|
|
9
|
+
await mkdir('temp',{recursive:true});
|
|
10
|
+
const root=await mkdtemp(resolve('temp','vite-auth-'));
|
|
11
|
+
for(const name of ['vite.config.ts','scripts','src','public','index.html','package.json'])await cp(resolve(name),resolve(root,name),{recursive:true});
|
|
12
|
+
await mkdir(resolve(root,'temp'));
|
|
13
|
+
await writeFile(resolve(root,'temp','staged-session'),'staged-secret');
|
|
14
|
+
let logins=0,queries=0;
|
|
15
|
+
const upstream=httpServer(async(req,res)=>{
|
|
16
|
+
for await(const _ of req){};
|
|
17
|
+
res.setHeader('content-type','application/json');
|
|
18
|
+
if(req.url==='/decision/login'){logins++;res.end(JSON.stringify({data:{accessToken:'persisted-session'}}));return;}
|
|
19
|
+
assert.equal(req.headers.cookie,'fine_auth_token=persisted-session');queries++;
|
|
20
|
+
res.end(JSON.stringify({success:true,data:{}}));
|
|
21
|
+
});
|
|
22
|
+
await new Promise(r=>upstream.listen(0,'127.0.0.1',r));
|
|
23
|
+
const baseUrl=`http://127.0.0.1:${upstream.address().port}/decision`;
|
|
24
|
+
await writeFile(resolve(root,'.env.local'),`FINEBI_DEV_BASE_URL=${baseUrl}\nFINEBI_DEV_USERNAME=alice\nFINEBI_DEV_PASSWORD=password\n`);
|
|
25
|
+
await writeFile(resolve(root,'public','app-config.json'),JSON.stringify({basePath:'/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:10,filters:[]}));
|
|
26
|
+
const server=await createServer({root,configFile:resolve(root,'vite.config.ts'),cacheDir:resolve(root,'temp','cache'),server:{port:0},logLevel:'silent'});
|
|
27
|
+
try {
|
|
28
|
+
await server.listen();
|
|
29
|
+
const url=`http://127.0.0.1:${server.httpServer.address().port}`;
|
|
30
|
+
assert.equal((await fetch(url+'/')).status,200,'project under a temp parent must still serve its index');
|
|
31
|
+
assert.equal((await fetch(url+'/src/main.tsx')).status,200,'project source must remain loadable');
|
|
32
|
+
const send=()=>fetch(url+'/decision/v5/api/dashboard/report/r/widgets/query',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
|
|
33
|
+
const result=await send();assert.equal(result.status,200);assert.doesNotMatch(await result.text(),/persisted-session|password/);
|
|
34
|
+
assert.match(await readFile(resolve(root,'.env.local'),'utf8'),/FINEBI_DEV_TOKEN="persisted-session"/);
|
|
35
|
+
assert.equal((await fetch(url+'/.env.local')).status,403);
|
|
36
|
+
assert.equal((await fetch(url+'/temp/staged-session')).status,403);
|
|
37
|
+
// Allow the file watcher to process the save; it must not restart the session manager.
|
|
38
|
+
await new Promise(r=>setTimeout(r,400));
|
|
39
|
+
assert.equal((await send()).status,200);assert.equal(logins,1);assert.equal(queries,2);
|
|
40
|
+
} finally {await server.close();await new Promise(r=>upstream.close(r));}
|
|
41
|
+
});
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {resolve} from 'node:path';
|
|
2
3
|
import {fileURLToPath} from 'node:url';
|
|
3
|
-
import {defineConfig, loadEnv} from 'vite';
|
|
4
|
+
import {defineConfig, loadEnv, normalizePath} from 'vite';
|
|
4
5
|
// @ts-expect-error The development-only Node module is tested directly with node:test.
|
|
5
6
|
import {createDevMiddleware} from './scripts/dev-proxy.mjs';
|
|
7
|
+
const projectTemp=normalizePath(fileURLToPath(new URL('./temp',import.meta.url)))+'/**';
|
|
6
8
|
export default defineConfig(({mode}) => ({
|
|
7
9
|
base: './',
|
|
8
10
|
resolve:{alias:[{find:/^date-fns\/locale$/,replacement:fileURLToPath(new URL('./src/components/calendar-locales.ts',import.meta.url))}]},
|
|
9
|
-
server: {host:'127.0.0.1',port:4173,strictPort:true},
|
|
11
|
+
server: {host:'127.0.0.1',port:4173,strictPort:true,watch:{ignored:['**/.env.local',projectTemp]},
|
|
12
|
+
fs:{deny:['.env','.env.*','*.{crt,pem}','**/.git/**',projectTemp]}},
|
|
10
13
|
plugins:[{name:'finebi-development-session',apply:'serve',configureServer(server) {
|
|
11
14
|
const config=JSON.parse(readFileSync(new URL('./public/app-config.json',import.meta.url),'utf8'));
|
|
12
|
-
const env={...loadEnv(mode,
|
|
13
|
-
|
|
15
|
+
const env={...loadEnv(mode,server.config.root,'FINEBI_DEV_'),...process.env};
|
|
16
|
+
const middleware=createDevMiddleware(config,env,fetch,{file:resolve(server.config.root,'.env.local')});
|
|
17
|
+
server.middlewares.use(middleware);
|
|
18
|
+
server.httpServer?.once('close',()=>middleware.dispose());
|
|
14
19
|
}}],
|
|
15
20
|
}));
|
package/src/app.js
CHANGED
|
@@ -40,6 +40,7 @@ export async function createApp(args, context = {}) {
|
|
|
40
40
|
try {
|
|
41
41
|
const templateRoot = context.templateRoot ? resolve(context.templateRoot) : TEMPLATE_ROOT;
|
|
42
42
|
const templatePackage = JSON.parse(await readFile(resolve(templateRoot, "package.json"), "utf8"));
|
|
43
|
+
const gitignore = await readFile(resolve(templateRoot, "gitignore.template"), "utf8");
|
|
43
44
|
const templateConfigPath = resolve(templateRoot, "public", "app-config.json");
|
|
44
45
|
let config = {schemaVersion: "1", title: name, basePath: "/webroot/decision", layout: "overview", dashboardId: "", maxRows: 1000, pageSize: 8, widgets: [], filters: []};
|
|
45
46
|
try {
|
|
@@ -53,6 +54,7 @@ export async function createApp(args, context = {}) {
|
|
|
53
54
|
return invalid("app.create", error.code === "EEXIST" ? `Destination already exists: ${destination}` : `Cannot create destination: ${error.message}`);
|
|
54
55
|
}
|
|
55
56
|
await copyTemplateContents(templateRoot, destination);
|
|
57
|
+
await writeFile(resolve(destination, ".gitignore"), gitignore, "utf8");
|
|
56
58
|
if(options.value.layout) config.layout=options.value.layout;
|
|
57
59
|
if(config.layout==='cockpit') config.insights=true;
|
|
58
60
|
await mkdir(resolve(destination, "public"), {recursive: true});
|
|
@@ -78,6 +80,7 @@ async function copyTemplateContents(sourceRoot, destinationRoot, relativeDirecto
|
|
|
78
80
|
const destinationDirectory = resolve(destinationRoot, relativeDirectory);
|
|
79
81
|
for (const entry of (await readdir(sourceDirectory, {withFileTypes: true})).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
80
82
|
const relativePath = `${relativeDirectory}/${entry.name}`.replace(/^\//u, "");
|
|
83
|
+
if (relativePath === ".gitignore" || relativePath === "gitignore.template") continue;
|
|
81
84
|
if (!templatePathAllowed(relativePath)) continue;
|
|
82
85
|
const sourcePath = resolve(sourceDirectory, entry.name);
|
|
83
86
|
const destinationPath = resolve(destinationDirectory, entry.name);
|
|
@@ -111,16 +114,20 @@ async function inspectApp(args) {
|
|
|
111
114
|
if (!(await stat(project)).isDirectory()) return invalid("app.inspect", "path must point to a project directory.");
|
|
112
115
|
const configPath = resolve(project, "public", "app-config.json");
|
|
113
116
|
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return invalid("app.inspect", "app-config.json must contain a JSON object.");
|
|
118
|
+
if (options.value.strict) {
|
|
119
|
+
const {configErrors}=await import('../templates/web-app/src/finebi/config.js');
|
|
120
|
+
const errors = configErrors(config);
|
|
121
|
+
if (errors.length) return failure("app.inspect", cliError("INVALID_ARGUMENT", {path: "public/app-config.json", message: errors.join(" ")}));
|
|
122
|
+
}
|
|
117
123
|
return success("app.inspect", {
|
|
118
124
|
path: project,
|
|
119
|
-
dashboardId: config.dashboardId,
|
|
120
|
-
layout: config.layout,
|
|
125
|
+
dashboardId: config.dashboardId ?? null,
|
|
126
|
+
layout: config.layout ?? null,
|
|
121
127
|
directoryEntryId: config.directoryEntryId || null,
|
|
122
|
-
widgets: config.widgets.length,
|
|
128
|
+
widgets: Array.isArray(config.widgets) ? config.widgets.length : null,
|
|
123
129
|
templateVersion: config.templateVersion || null,
|
|
130
|
+
configValidated: options.value.strict === true,
|
|
124
131
|
remoteValidated: false,
|
|
125
132
|
});
|
|
126
133
|
} catch (error) {
|
|
@@ -133,6 +140,11 @@ function parseOptions(args, command) {
|
|
|
133
140
|
for (let index = 0; index < args.length; index++) {
|
|
134
141
|
const option = args[index];
|
|
135
142
|
if (option === "--json") continue;
|
|
143
|
+
if (option === "--strict" && command === "app.inspect") {
|
|
144
|
+
if (value.strict) return {ok: false, error: invalid(command, "--strict must only be supplied once.")};
|
|
145
|
+
value.strict = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
136
148
|
if (!["--name","--path",...(command==='app.create'?['--layout']:[])].includes(option)) return {ok: false, error: invalid(command, `Unsupported option: ${option}`)};
|
|
137
149
|
const key = option.slice(2);
|
|
138
150
|
const next = args[++index];
|
package/src/browser-auth.js
CHANGED
|
@@ -160,9 +160,8 @@ function abortedAuthorization() {
|
|
|
160
160
|
function validateBaseUrl(value) {
|
|
161
161
|
try {
|
|
162
162
|
const url = new URL(value);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
throw new Error("Browser authorization requires HTTPS, except for localhost development.");
|
|
163
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
164
|
+
throw new Error("Browser authorization requires an HTTP or HTTPS URL.");
|
|
166
165
|
}
|
|
167
166
|
if (url.username || url.password || url.search || url.hash) {
|
|
168
167
|
throw new Error("Browser authorization base URL must not contain credentials, a query, or a fragment.");
|
|
@@ -172,7 +171,7 @@ function validateBaseUrl(value) {
|
|
|
172
171
|
return failure("auth.login", cliError("BROWSER_AUTH_URL_INVALID", {
|
|
173
172
|
path: "baseUrl",
|
|
174
173
|
message: error instanceof Error ? error.message : String(error),
|
|
175
|
-
nextActions: ["finebi-cli config set base-url <https-url>"],
|
|
174
|
+
nextActions: ["finebi-cli config set base-url <http-or-https-url>"],
|
|
176
175
|
}));
|
|
177
176
|
}
|
|
178
177
|
}
|
package/src/config.js
CHANGED
|
@@ -8,7 +8,7 @@ import {CLI_CONFIG_DIRECTORY} from "./branding.js";
|
|
|
8
8
|
import {maskSecret} from "./output.js";
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_TIMEOUT_MS = 30000;
|
|
11
|
-
export const DEFAULT_CAPABILITY_TTL_MS =
|
|
11
|
+
export const DEFAULT_CAPABILITY_TTL_MS = 20 * 60 * 60 * 1000;
|
|
12
12
|
|
|
13
13
|
export function defaultConfigPath(env = process.env) {
|
|
14
14
|
const home = env.USERPROFILE || env.HOME || homedir();
|
package/src/error-catalog.js
CHANGED
|
@@ -19,8 +19,8 @@ export const ERROR_CATALOG = [
|
|
|
19
19
|
recoverableByAgent: true,
|
|
20
20
|
defaultRetryable: false,
|
|
21
21
|
pathSemantics: "Browser authorization base URL",
|
|
22
|
-
canonicalNextActions: ["finebi-cli config set base-url <https-url>"],
|
|
23
|
-
expectedAgentBehaviors: ["configure an
|
|
22
|
+
canonicalNextActions: ["finebi-cli config set base-url <http-or-https-url>"],
|
|
23
|
+
expectedAgentBehaviors: ["configure an HTTP or HTTPS URL", "rerun browser login"],
|
|
24
24
|
benchmarkTags: ["auth", "browser", "url-validation"],
|
|
25
25
|
},
|
|
26
26
|
{
|
package/src/help.js
CHANGED
|
@@ -116,10 +116,13 @@ const BUILT_IN_RESOURCES = {
|
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
name: "inspect",
|
|
119
|
-
summary: "
|
|
120
|
-
usage: "finebi-cli app inspect --path <project>",
|
|
121
|
-
options: [
|
|
122
|
-
|
|
119
|
+
summary: "Summarize a local web app configuration; optionally validate against the bundled template.",
|
|
120
|
+
usage: "finebi-cli app inspect --path <project> [--strict]",
|
|
121
|
+
options: [
|
|
122
|
+
{name: "--path", type: "string", description: "Existing project directory; relative paths resolve from the current directory."},
|
|
123
|
+
{name: "--strict", type: "boolean", description: "Validate against this CLI's bundled template rules; custom app extensions may not match."},
|
|
124
|
+
],
|
|
125
|
+
details: ["By default, reads app-config.json as a JSON object and reports a summary with configValidated: false. --strict enables local template validation. Neither mode validates remote resources or permissions; inspection is not an application acceptance gate."],
|
|
123
126
|
},
|
|
124
127
|
],
|
|
125
128
|
},
|
|
@@ -171,7 +174,7 @@ const BUILT_IN_RESOURCES = {
|
|
|
171
174
|
examples: [
|
|
172
175
|
"finebi-cli config set base-url http://localhost:37799/webroot/decision",
|
|
173
176
|
"finebi-cli config set timeout-ms 30000",
|
|
174
|
-
"finebi-cli config set capability-ttl-ms
|
|
177
|
+
"finebi-cli config set capability-ttl-ms 72000000",
|
|
175
178
|
],
|
|
176
179
|
},
|
|
177
180
|
{
|
|
@@ -3,17 +3,30 @@
|
|
|
3
3
|
## 开发顺序
|
|
4
4
|
|
|
5
5
|
1. 用 FineBI CLI 搜索并读取 dashboard info/data,确认唯一来源、看板实例 entryId、queryFields.dId 和真实控件 filterName。按[验收文档](docs/acceptance.md#1-绑定与设计准备)记录指标公式/聚合、单位、时间粒度与时区、筛选作用范围及数据完整性;必要时读取 bean,未知项明确标记,不按组件标题猜语义。
|
|
6
|
-
2.
|
|
6
|
+
2. 按下方[阅读路径](#阅读路径)读取当前步骤需要的章节,再修改 public/app-config.json;不要求开场通读全部文档。
|
|
7
7
|
3. 按[设计选择](docs/design.md#设计选择与信息组织)写出页面面向谁、首屏重点、导航、图表分组和信息密度,遵循用户已确认的方向继续开发。新风格说明与已有布局在结构、层级和视觉上的区别。从 overview、workbench、cockpit 或 retail 起步,示例配置仅含占位绑定。
|
|
8
|
-
4.
|
|
8
|
+
4. 整页绑定前,按[最小真实查询](docs/acceptance.md#11-最小真实查询)先配置一个组件,启动开发页面,通过开发代理确认真实身份、查询、解析和页面值;再补齐本页需要的指标、图表与真实筛选链路,通过后扩展整页。使用真实接口开发;合成样本仅用于 tests,禁止将开发查询数据写入正式页面、配置或回退数据。
|
|
9
9
|
5. 业务转换放 src/services,页面放 src/pages 或 App.tsx,通用请求与原生解析复用 src/finebi。改动共享组件时检查其他布局,不为换风格复制查询、认证或筛选编码。
|
|
10
10
|
6. 按[分层验收](docs/acceptance.md)执行 npm test、npm run build、浏览器冒烟及真实绑定验收。检查整页和关键状态,分别记录 CLI、开发身份、生产 Cookie 的验证结论;构建成功不等于认证、取数或视觉验收通过。
|
|
11
11
|
|
|
12
|
+
## 阅读路径
|
|
13
|
+
|
|
14
|
+
首次进入项目先读本文件、[数据契约](docs/data-contract.md)和[绑定与设计准备](docs/acceptance.md#1-绑定与设计准备)。其余文档在对应步骤读取,同一任务中已读且未变化的章节直接复用。
|
|
15
|
+
|
|
16
|
+
| 当前步骤 | 阅读内容 |
|
|
17
|
+
|---|---|
|
|
18
|
+
| 配置并启动最小真实查询 | [README 开始](README.md#开始)、[本地开发认证](docs/auth-and-deploy.md#本地开发)、[最小真实查询](docs/acceptance.md#11-最小真实查询) |
|
|
19
|
+
| 绑定真实筛选控件 | [筛选与联动](docs/filters.md)中对应控件类型、作用范围、应用与恢复默认的规则;无控件时无需预读 |
|
|
20
|
+
| 确定页面结构、配置展示或扩展 UI | [设计选择与信息组织](docs/design.md#设计选择与信息组织)及本次布局、展示能力对应章节 |
|
|
21
|
+
| 执行整页验收与交付 | [开发与验收](docs/acceptance.md)第 2–5 节 |
|
|
22
|
+
| 部署或验证生产身份 | [生产认证与部署](docs/auth-and-deploy.md#生产) |
|
|
23
|
+
|
|
12
24
|
## 数据规则
|
|
13
25
|
|
|
14
|
-
-
|
|
26
|
+
- `widgets[].dimensionIds` 为可选字段,常规绑定省略以沿用保存配置;需要指定时列出所需维度和指标的完整白名单,只传维度会关闭未选指标。与 CLI `--dimension-ids` 使用同一来源的可选 dId,完整语义见[数据契约](docs/data-contract.md#字段选择)。
|
|
15
27
|
- 先检查 normalization、warnings、range,再选择唯一 dataset;多模型需显式 datasetId。
|
|
16
28
|
- display 用于显示;计算仅使用 AVAILABLE 的 value,保留 DECIMAL_STRING 精度。禁止从百分比/千分位/单位文本恢复数值。受限值、未知值不能补零。
|
|
29
|
+
- 分组表 BIGROUP 使用 table 展示,按原生汇总标记区分数据、小计和总计;不能混加各类行。交叉表 BICROSS 按 top 槽位对应 left 数值,仅用 table 展示当前矩阵,不能按重复的指标 dId 合并列或固定绑定跨查询槽位。详见 docs/data-contract.md。
|
|
17
30
|
- 不按名称猜字段、不累加 UNKNOWN 行、不从首页估算全量 KPI。KPI 示例仅消费单行结果。
|
|
18
31
|
- 控件筛选通过真实名称生成 refer;无控件时不伪造服务端过滤。搜索和排序的范围必须在页面说明。
|
|
19
32
|
- 筛选绑定先核对源控件类型,配置/状态/请求统一通过 src/finebi/filters.js 校验。完整支持矩阵、逗号和 null 保留字、恢复默认与筛选空值语义见 docs/filters.md;不以更换 type 绕过服务端能力限制。
|
|
@@ -21,9 +34,9 @@
|
|
|
21
34
|
|
|
22
35
|
## 认证与交付
|
|
23
36
|
|
|
24
|
-
-
|
|
37
|
+
- 开发凭证由 Node 从 FINEBI_DEV_* 环境或项目 .env.local 读取;新会话自动保存到 .env.local。优先复用 token,获取时密码优先、浏览器授权兜底,具体流程见 docs/auth-and-deploy.md。禁止 VITE_* 凭证、前端 localStorage token、提交凭据文件和认证秘密日志。
|
|
25
38
|
- 生产应用使用同源当前用户 Cookie。开发代理不属于构建产物;不要把它部署为共享多用户服务。
|
|
26
|
-
- 目录来源配置 directoryEntryId,原生请求保留 reportEntryId;需目标平台 HTTP 接口支持目录权限预检与 MOUNT
|
|
39
|
+
- 目录来源配置 directoryEntryId,原生请求保留 reportEntryId;需目标平台 HTTP 接口支持目录权限预检与 MOUNT 查询,不得失败后回退到分析主题。不支持服务端续页、全局排序或托管发布命令。
|
|
27
40
|
- 图表按生命周期销毁;异步请求取消且旧响应不得覆盖新条件。失败状态不得展示过期数据充当最新结果。
|
|
28
41
|
- 验收证据、截图和临时脚本放项目 temp/,不随包发布。交付记录已验证/失败/未验证项、运行方式、模板版本和业务绑定范围;模板维护流程由 CLI 源仓库负责,本项目无需其脚本才能开发和验收。
|
|
29
42
|
|
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
## 开始
|
|
6
6
|
|
|
7
|
+
按 [Agent 阅读路径](AGENTS.md#阅读路径)分阶段读取文档,再执行以下步骤。
|
|
8
|
+
|
|
7
9
|
1. 安装依赖:`npm ci`。
|
|
8
|
-
2.
|
|
9
|
-
3.
|
|
10
|
-
4. `npm run dev`,访问 `http://localhost:4173
|
|
11
|
-
5. 按[
|
|
10
|
+
2. 先在 `public/app-config.json` 中填写一个真实组件的最小绑定。`dashboardId` 来自目标看板;`widgets[].entryId` 使用板内实例 ID;图表 `categoryFieldId/valueFieldId` 使用本次 `dashboard info` 的结果字段 dId。相同实例可以有不同展示,但 KPI 必须对应单行查询结果。
|
|
11
|
+
3. 在项目 `.env.local` 设置 `FINEBI_DEV_BASE_URL`(完整 decision 地址);有账密时填写 `FINEBI_DEV_USERNAME/FINEBI_DEV_PASSWORD`,只有 SSO 时省略。也可提供已有的 FineBI 会话 `FINEBI_DEV_TOKEN`,不能使用 CLI Access Key。不要把值写入 VITE_* 或 app-config。
|
|
12
|
+
4. `npm run dev`,访问 `http://localhost:4173`。代理优先复用 token,再尝试密码,最后按终端提示进行浏览器授权。新 token 自动保存到 `.env.local`;过期后恢复并重试一次。浏览器授权的服务端插件和 HTTPS 要求见 [认证与部署](docs/auth-and-deploy.md)。
|
|
13
|
+
5. 按[最小真实查询](docs/acceptance.md#11-最小真实查询)核对实际请求与页面值,补齐适用的代表性图表和筛选链路,通过后再扩展整页。
|
|
14
|
+
6. 按[开发与验收](docs/acceptance.md)执行 `npm test`、`npm run build`、`npm run test:browser`,并完成整页真实绑定验收。浏览器验收页使用合成数据,不连接 FineBI。`npm run preview` 只展示 dist,不启用开发身份代理;真实生产取数需要同源 FineBI 路径。
|
|
12
15
|
|
|
13
16
|
## 配置
|
|
14
17
|
|
|
18
|
+
`widgets[].dimensionIds` 可选,常规绑定省略以沿用组件保存配置。需要指定维度和指标时填写完整白名单,`[]` 会关闭全部可选字段;示例、代理校验与同实例多绑定规则见[字段选择](docs/data-contract.md#字段选择)。
|
|
19
|
+
|
|
15
20
|
`layout` 为 `overview`(顶部概览)、`workbench`(侧栏工作台)、`cockpit`(绿色经营驾驶舱)或 `retail`(米白经营简报)。可用 `finebi-cli app create --name my-retail --path <parent> --layout retail` 创建;默认配置仍为空绑定,不会请求示例资源。`maxRows` 默认 1000,上限 10000;`pageSize` 是本地展示页大小,1–100。
|
|
16
21
|
|
|
17
22
|
```json
|
|
@@ -31,6 +36,8 @@ cockpit 的配置骨架在 [examples/cockpit.config.json](examples/cockpit.confi
|
|
|
31
36
|
|
|
32
37
|
retail 的 [examples/retail.config.json](examples/retail.config.json) 提供零售分析绑定骨架:四项 KPI、趋势、门店 TOP10、区域/品类、门店类型和明细。采用横向导航、米白背景和蓝橙图表,不依赖侧栏。示例不含业务数据,`REPLACE_*`、模型选择、时区及筛选作用范围必须核实替换;普通分析来源需删除目录入口占位字段。金额与百分比、毫秒月份的显式显示配置见[页面骨架](docs/design.md)。
|
|
33
38
|
|
|
39
|
+
分组表 BIGROUP 可使用 `type: "table"` 展示,保留分组键及小计、总计行;字段和值状态、首页范围见[数据契约](docs/data-contract.md#分组表)。交叉表 BICROSS 同样绑定为 `type: "table"`,按行列分组路径显示当前矩阵;通常省略 tableFieldIds,以保留随查询变化的全部交叉列。它不提供服务端翻页、节点展开或原生合并单元格,字段和双轴范围见[交叉表契约](docs/data-contract.md#交叉表)。
|
|
40
|
+
|
|
34
41
|
目录发布看板在配置中增加 `directoryEntryId`,使用与该入口匹配的 dashboardId/实例。模板将其原样作为 `reportEntryId` 传给 Cookie 查询接口及开发代理;目标平台需同时支持目录权限预检和 MOUNT 查询,不能只支持 CLI 目录查询。错误独立显示,不回退“我的分析”。
|
|
35
42
|
|
|
36
|
-
筛选先编辑、再点击“应用筛选”;“恢复全部默认”重新查询看板保存条件。模板支持文本、单选/多选、数值选择和日期类控件,完整类型、空值语义与配置示例以[筛选契约](docs/filters.md)为准;`app inspect`
|
|
43
|
+
筛选先编辑、再点击“应用筛选”;“恢复全部默认”重新查询看板保存条件。模板支持文本、单选/多选、数值选择和日期类控件,完整类型、空值语义与配置示例以[筛选契约](docs/filters.md)为准;`app inspect` 默认只查看配置摘要;可选 `--strict` 检查与 CLI 内置模板规则的兼容性,不作为定制应用验收门禁。绑定后仍需通过项目测试、构建与真实浏览器验证条件生效。
|
|
@@ -10,12 +10,25 @@
|
|
|
10
10
|
|---|---|
|
|
11
11
|
| 来源 | CLI profile、目标地址、dashboardId、目录入口或分析主题;板内实例 entryId |
|
|
12
12
|
| 字段与指标 | queryFields.dId、选定 datasetId、维度/指标、公式或聚合、单位;记录数不能自动解释为去重对象数 |
|
|
13
|
+
| 字段选择 | 常规绑定省略 dimensionIds;配置时核对完整白名单与输出列,分别验证省略、维度加指标、仅指标和空列表;同实例不同展示绑定分别核对请求 |
|
|
13
14
|
| 时间 | 时间粒度、原值编码、显示时区;日期条件与时间戳轴标签分开核实 |
|
|
14
15
|
| 筛选 | 真实控件名、类型、默认行为、候选项来源、组件作用范围;无源控件时不添加服务端筛选 |
|
|
15
16
|
| 范围 | 返回行数、hasNext、normalization、warnings;不根据首页推算全量 |
|
|
16
17
|
| 设计 | 使用者和主要决策、首屏重点、导航/分组/密度,与既有布局的区别 |
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
清单先覆盖最小绑定,扩展组件时逐项补齐;不必先完成整板配置。证据不足时说明未知项,继续不依赖该项的工作。
|
|
20
|
+
|
|
21
|
+
### 1.1 最小真实查询
|
|
22
|
+
|
|
23
|
+
在配置整页组件之前,先验证开发页面的真实取数链路:
|
|
24
|
+
|
|
25
|
+
1. 用同一来源的 CLI info/data 选择一个模板支持且有可核对数据的组件,例如单行 KPI;检查 resultType、normalization、warnings、range 和结果字段,不按标题或 chartType 判断可用性。CLI 查询成功仅用于准备绑定,不能证明浏览器适配和开发身份可用。
|
|
26
|
+
2. 在 public/app-config.json 中只填写该组件的真实绑定及必要配置;目录来源保留 directoryEntryId,需要字段选择时保留相同 dimensionIds。不要先复制整页占位组件,也不为测试凭空创建源指标或控件。
|
|
27
|
+
3. 按 [README 开始](../README.md#开始)安装依赖、配置开发身份并运行 npm run dev。打开实际应用,通过开发代理触发原生查询;优先复用已有会话,需要登录时遵循[本地开发认证](auth-and-deploy.md#本地开发),不要把 CLI Access Key 当作会话 token,也不要在已有会话可用时重复索要密码。
|
|
28
|
+
4. 核对请求来源和参数、业务响应、适配状态与页面显示值。单凭 HTTP 200、CLI 成功或合成验收页 PASS 不算通过;空结果只能证明当前空态,需另选有可核对数据的组件或条件验证值的消费。脱敏证据放项目 temp/,不得记录凭证。
|
|
29
|
+
5. 该组件通过后,按本页需求补齐一个指标和一张图的代表性绑定;源中不存在或本页不需要的类型注明不适用。有真实筛选时验证“源结果 → 页面值 → 应用条件 → 恢复保存默认”,确认请求及显示与条件一致。完成这些适用检查后再扩展整页,并继续验证新增绑定。
|
|
30
|
+
|
|
31
|
+
身份、权限、接口或适配失败时,记录失败层与原因,先解决当前链路再扩大绑定;缺少访问条件时可继续不依赖真实数据的设计工作,但真实查询须标记未验证。开发身份通过不代表生产 Cookie 验证通过,身份边界见第 4 节。
|
|
19
32
|
|
|
20
33
|
## 2. 本地自动检查
|
|
21
34
|
|
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## 本地开发
|
|
4
4
|
|
|
5
|
-
Vite
|
|
5
|
+
Vite开发插件读取项目 `.env.local` 或进程环境中的 `FINEBI_DEV_BASE_URL`(完整 decision 地址),可选 `FINEBI_DEV_USERNAME/FINEBI_DEV_PASSWORD` 和已有 FineBI 会话 `FINEBI_DEV_TOKEN`。进程环境优先;不要使用 `VITE_` 前缀,不要把 CLI Access Key 填入 TOKEN。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
```dotenv
|
|
8
|
+
FINEBI_DEV_BASE_URL=https://bi.example/webroot/decision
|
|
9
|
+
# 有用户名密码时填写;只有 SSO 时省略这两项。
|
|
10
|
+
FINEBI_DEV_USERNAME=your-user
|
|
11
|
+
FINEBI_DEV_PASSWORD=your-password
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
首次查询按以下顺序认证:复用有效 token;需要登录时先尝试配置的用户名密码;没有完整账密或密码明确被拒绝时,终端显示浏览器授权链接和确认码,并尝试打开浏览器。先通过客户原 FineBI/SSO 入口登录,再打开授权链接核对账号与确认码。开发进程主动轮询领取会话,不要求 FineBI 服务器连接本机,也不改变 SSO 回跳地址。
|
|
15
|
+
|
|
16
|
+
浏览器授权要求服务端插件提供 `/api/bi-cli/dev-session-auth/*`,使用 HTTPS(仅回环允许 HTTP)。旧插件返回 `AUTH_PLUGIN_REQUIRED`,应更新插件;密码路径不依赖新增端点。客户端终端只显示授权地址、确认码、账号与状态,不显示密码或 token。
|
|
17
|
+
|
|
18
|
+
新会话写入 `.env.local` 的 `FINEBI_DEV_TOKEN`、`FINEBI_DEV_TOKEN_BASE_URL`、`FINEBI_DEV_TOKEN_USERNAME`;保留其他配置,缺少 BASE_URL 时补入本次地址。保存的 token 与目标地址绑定,切换地址不复用旧 token。文件写入先限制权限(POSIX 0600;Windows 当前用户、SYSTEM、Administrators),失败返回 `AUTH_STORAGE_FAILED`。文件禁止提交或随包发布。token 只由 Node 读取;授权交出的是真实会话权限,并非仅限本应用看板。
|
|
19
|
+
|
|
20
|
+
明确过期时清除保存的旧 token,按上述顺序恢复,再重试原查询一次。并发请求共享认证;旧请求不能清除新会话。再次失效、授权拒绝或超时后返回 `AUTH_REQUIRED`,用户处理后点击重试。密码明确拒绝后本次运行不重复尝试;网络故障、上游 5xx、查询 403 不触发浏览器授权。认证文件保存会更新内存,不需要重启;手动修改地址或账密配置后需重启开发服务。若进程环境还设置了旧 TOKEN,它会覆盖文件,重新启动前应移除该覆盖。
|
|
21
|
+
|
|
22
|
+
代理只接受本机同源、带 XMLHttpRequest 标记的 POST;白名单限制为配置看板及组件查询,不向浏览器开放登录管理或任意接口。不要向他人开放开发服务端口。Vite 禁止访问 `.env*` 和 `temp/`,关闭服务会取消待完成的授权。会话有效期由 FineBI 决定;SSO 的 IP/终端/附加 Cookie 约束、退出登录行为及多节点部署需在客户环境验证,自动化测试不等于所有 SSO 已兼容。
|
|
8
23
|
|
|
9
24
|
代理使用共享筛选校验器校验配置与 refer,类型、编码、候选值和默认语义统一见[筛选契约](filters.md),不维护独立编码规则。
|
|
10
25
|
|