@finebi/finebi-cli 1.6.1 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
- 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 +157 -148
- 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 +28 -28
- 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 +63 -60
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +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 +80 -67
- 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/examples/cockpit.config.json +22 -22
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
- 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 +63 -56
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
- 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/Insights.tsx +17 -17
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -39
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
- 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 +3 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -44
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
- 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/retail.test.mjs +31 -31
- 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/tsconfig.json +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +157 -148
- package/src/browser-auth.js +3 -4
- package/src/config.js +28 -28
- package/src/error-catalog.js +2 -2
- package/src/help.js +63 -60
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +94 -94
- package/templates/web-app/AGENTS.md +18 -5
- package/templates/web-app/README.md +12 -5
- package/templates/web-app/docs/acceptance.md +80 -67
- 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/examples/cockpit.config.json +22 -22
- package/templates/web-app/examples/retail.config.json +185 -185
- package/templates/web-app/index.html +2 -2
- package/templates/web-app/public/app-config.json +11 -11
- 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 +63 -56
- package/templates/web-app/src/cockpit.css +3 -3
- package/templates/web-app/src/components/Chart.tsx +40 -40
- package/templates/web-app/src/components/DataTable.tsx +1 -1
- package/templates/web-app/src/components/Insights.tsx +17 -17
- package/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/templates/web-app/src/components/Widget.tsx +40 -39
- package/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/templates/web-app/src/components/calendar.ts +14 -14
- package/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/templates/web-app/src/components/month-picker.css +1 -1
- package/templates/web-app/src/finebi/client.ts +20 -4
- package/templates/web-app/src/finebi/config.d.ts +3 -1
- package/templates/web-app/src/finebi/config.js +58 -44
- package/templates/web-app/src/finebi/date.d.ts +1 -1
- package/templates/web-app/src/finebi/date.js +5 -5
- package/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/templates/web-app/src/finebi/filters.js +106 -106
- package/templates/web-app/src/finebi/normalize.ts +235 -77
- package/templates/web-app/src/finebi/types.ts +20 -18
- package/templates/web-app/src/main.tsx +5 -5
- package/templates/web-app/src/retail.css +12 -12
- package/templates/web-app/src/services/presentation.ts +36 -36
- package/templates/web-app/src/services/table-data.ts +47 -47
- package/templates/web-app/src/services/view-data.ts +18 -18
- package/templates/web-app/src/services/widget-queries.ts +18 -18
- package/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/templates/web-app/tests/browser/index.html +16 -16
- package/templates/web-app/tests/browser/main.tsx +149 -149
- package/templates/web-app/tests/browser/test-page.css +4 -4
- package/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/templates/web-app/tests/bundle.test.mjs +19 -19
- package/templates/web-app/tests/cockpit.test.mjs +73 -73
- 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/retail.test.mjs +31 -31
- 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/tsconfig.json +4 -4
- package/templates/web-app/vite.config.ts +9 -4
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json
CHANGED
|
@@ -1,185 +1,185 @@
|
|
|
1
|
-
{
|
|
2
|
-
"schemaVersion": "1",
|
|
3
|
-
"templateVersion": "0.3.0",
|
|
4
|
-
"title": "零售经营简报",
|
|
5
|
-
"description": "请根据同一来源的真实指标、字段和控件替换绑定。",
|
|
6
|
-
"basePath": "/webroot/decision",
|
|
7
|
-
"layout": "retail",
|
|
8
|
-
"dashboardId": "REPLACE_DASHBOARD_ID",
|
|
9
|
-
"directoryEntryId": "REPLACE_DIRECTORY_ENTRY_ID",
|
|
10
|
-
"maxRows": 1000,
|
|
11
|
-
"pageSize": 8,
|
|
12
|
-
"insights": false,
|
|
13
|
-
"widgets": [
|
|
14
|
-
{
|
|
15
|
-
"entryId": "REPLACE_ENTRY_1",
|
|
16
|
-
"name": "销售额",
|
|
17
|
-
"type": "kpi",
|
|
18
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
19
|
-
"filterNames": [],
|
|
20
|
-
"valueFieldId": "REPLACE_VALUE_1",
|
|
21
|
-
"valueFormat": "number"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"entryId": "REPLACE_ENTRY_2",
|
|
25
|
-
"name": "毛利",
|
|
26
|
-
"type": "kpi",
|
|
27
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
28
|
-
"filterNames": [],
|
|
29
|
-
"valueFieldId": "REPLACE_VALUE_2",
|
|
30
|
-
"valueFormat": "number"
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"entryId": "REPLACE_ENTRY_3",
|
|
34
|
-
"name": "比率指标",
|
|
35
|
-
"type": "kpi",
|
|
36
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
37
|
-
"filterNames": [],
|
|
38
|
-
"valueFieldId": "REPLACE_VALUE_3",
|
|
39
|
-
"valueFormat": "percent"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"entryId": "REPLACE_ENTRY_4",
|
|
43
|
-
"name": "计数指标",
|
|
44
|
-
"type": "kpi",
|
|
45
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
46
|
-
"filterNames": [],
|
|
47
|
-
"valueFieldId": "REPLACE_VALUE_4",
|
|
48
|
-
"valueFormat": "number"
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
"entryId": "REPLACE_ENTRY_5",
|
|
52
|
-
"name": "销售与毛利趋势",
|
|
53
|
-
"type": "line",
|
|
54
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
55
|
-
"filterNames": [],
|
|
56
|
-
"categoryFieldId": "REPLACE_CATEGORY_5",
|
|
57
|
-
"valueFieldId": "REPLACE_VALUE_5",
|
|
58
|
-
"categoryFormat": "epoch-month",
|
|
59
|
-
"categoryTimeZone": "Asia/Shanghai",
|
|
60
|
-
"series": [
|
|
61
|
-
{
|
|
62
|
-
"fieldId": "REPLACE_SERIES_5_1",
|
|
63
|
-
"label": "销售额"
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
"fieldId": "REPLACE_SERIES_5_2",
|
|
67
|
-
"label": "毛利"
|
|
68
|
-
}
|
|
69
|
-
]
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"entryId": "REPLACE_ENTRY_6",
|
|
73
|
-
"name": "销售额 TOP10 门店",
|
|
74
|
-
"type": "bar",
|
|
75
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
76
|
-
"filterNames": [],
|
|
77
|
-
"categoryFieldId": "REPLACE_CATEGORY_6",
|
|
78
|
-
"valueFieldId": "REPLACE_VALUE_6"
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
"entryId": "REPLACE_ENTRY_7",
|
|
82
|
-
"name": "区域销售构成",
|
|
83
|
-
"type": "pie",
|
|
84
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
85
|
-
"filterNames": [],
|
|
86
|
-
"categoryFieldId": "REPLACE_CATEGORY_7",
|
|
87
|
-
"valueFieldId": "REPLACE_VALUE_7"
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"entryId": "REPLACE_ENTRY_8",
|
|
91
|
-
"name": "品类销售额",
|
|
92
|
-
"type": "bar",
|
|
93
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
94
|
-
"filterNames": [],
|
|
95
|
-
"categoryFieldId": "REPLACE_CATEGORY_8",
|
|
96
|
-
"valueFieldId": "REPLACE_VALUE_8"
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
"entryId": "REPLACE_ENTRY_9",
|
|
100
|
-
"name": "自有店 / 管理店销售趋势",
|
|
101
|
-
"type": "line",
|
|
102
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
103
|
-
"filterNames": [],
|
|
104
|
-
"categoryFieldId": "REPLACE_CATEGORY_9",
|
|
105
|
-
"valueFieldId": "REPLACE_VALUE_9",
|
|
106
|
-
"categoryFormat": "epoch-month",
|
|
107
|
-
"categoryTimeZone": "Asia/Shanghai",
|
|
108
|
-
"series": [
|
|
109
|
-
{
|
|
110
|
-
"fieldId": "REPLACE_SERIES_9_1",
|
|
111
|
-
"label": "自有店"
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
"fieldId": "REPLACE_SERIES_9_2",
|
|
115
|
-
"label": "管理店"
|
|
116
|
-
}
|
|
117
|
-
]
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
"entryId": "REPLACE_ENTRY_10",
|
|
121
|
-
"name": "生活馆 / 时尚馆销售趋势",
|
|
122
|
-
"type": "line",
|
|
123
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
124
|
-
"filterNames": [],
|
|
125
|
-
"categoryFieldId": "REPLACE_CATEGORY_10",
|
|
126
|
-
"valueFieldId": "REPLACE_VALUE_10",
|
|
127
|
-
"categoryFormat": "epoch-month",
|
|
128
|
-
"categoryTimeZone": "Asia/Shanghai",
|
|
129
|
-
"series": [
|
|
130
|
-
{
|
|
131
|
-
"fieldId": "REPLACE_SERIES_10_1",
|
|
132
|
-
"label": "生活馆"
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
"fieldId": "REPLACE_SERIES_10_2",
|
|
136
|
-
"label": "时尚馆"
|
|
137
|
-
}
|
|
138
|
-
]
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
"entryId": "REPLACE_ENTRY_11",
|
|
142
|
-
"name": "门店风格构成",
|
|
143
|
-
"type": "pie",
|
|
144
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
145
|
-
"filterNames": [],
|
|
146
|
-
"categoryFieldId": "REPLACE_CATEGORY_11",
|
|
147
|
-
"valueFieldId": "REPLACE_VALUE_11"
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
"entryId": "REPLACE_ENTRY_12",
|
|
151
|
-
"name": "门店性质构成",
|
|
152
|
-
"type": "pie",
|
|
153
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
154
|
-
"filterNames": [],
|
|
155
|
-
"categoryFieldId": "REPLACE_CATEGORY_12",
|
|
156
|
-
"valueFieldId": "REPLACE_VALUE_12"
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
"entryId": "REPLACE_ENTRY_13",
|
|
160
|
-
"name": "门店经营明细",
|
|
161
|
-
"type": "table",
|
|
162
|
-
"description": "替换为已核实的源指标口径与范围。",
|
|
163
|
-
"filterNames": [],
|
|
164
|
-
"tableFieldIds": [
|
|
165
|
-
"REPLACE_TABLE_FIELD_1",
|
|
166
|
-
"REPLACE_TABLE_FIELD_2",
|
|
167
|
-
"REPLACE_TABLE_FIELD_3",
|
|
168
|
-
"REPLACE_TABLE_FIELD_4"
|
|
169
|
-
],
|
|
170
|
-
"localFilterFieldIds": [
|
|
171
|
-
"REPLACE_TABLE_FIELD_1"
|
|
172
|
-
]
|
|
173
|
-
}
|
|
174
|
-
],
|
|
175
|
-
"filters": [
|
|
176
|
-
{
|
|
177
|
-
"name": "REPLACE_STORE_CONTROL",
|
|
178
|
-
"label": "店名(精确匹配)",
|
|
179
|
-
"type": "text"
|
|
180
|
-
}
|
|
181
|
-
],
|
|
182
|
-
"dataNotes": [
|
|
183
|
-
"请填写已核实的来源、单位、期间、门店计数口径与控件作用范围。未确认公式时保留源指标名称。"
|
|
184
|
-
]
|
|
185
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1",
|
|
3
|
+
"templateVersion": "0.3.0",
|
|
4
|
+
"title": "零售经营简报",
|
|
5
|
+
"description": "请根据同一来源的真实指标、字段和控件替换绑定。",
|
|
6
|
+
"basePath": "/webroot/decision",
|
|
7
|
+
"layout": "retail",
|
|
8
|
+
"dashboardId": "REPLACE_DASHBOARD_ID",
|
|
9
|
+
"directoryEntryId": "REPLACE_DIRECTORY_ENTRY_ID",
|
|
10
|
+
"maxRows": 1000,
|
|
11
|
+
"pageSize": 8,
|
|
12
|
+
"insights": false,
|
|
13
|
+
"widgets": [
|
|
14
|
+
{
|
|
15
|
+
"entryId": "REPLACE_ENTRY_1",
|
|
16
|
+
"name": "销售额",
|
|
17
|
+
"type": "kpi",
|
|
18
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
19
|
+
"filterNames": [],
|
|
20
|
+
"valueFieldId": "REPLACE_VALUE_1",
|
|
21
|
+
"valueFormat": "number"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"entryId": "REPLACE_ENTRY_2",
|
|
25
|
+
"name": "毛利",
|
|
26
|
+
"type": "kpi",
|
|
27
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
28
|
+
"filterNames": [],
|
|
29
|
+
"valueFieldId": "REPLACE_VALUE_2",
|
|
30
|
+
"valueFormat": "number"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"entryId": "REPLACE_ENTRY_3",
|
|
34
|
+
"name": "比率指标",
|
|
35
|
+
"type": "kpi",
|
|
36
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
37
|
+
"filterNames": [],
|
|
38
|
+
"valueFieldId": "REPLACE_VALUE_3",
|
|
39
|
+
"valueFormat": "percent"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"entryId": "REPLACE_ENTRY_4",
|
|
43
|
+
"name": "计数指标",
|
|
44
|
+
"type": "kpi",
|
|
45
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
46
|
+
"filterNames": [],
|
|
47
|
+
"valueFieldId": "REPLACE_VALUE_4",
|
|
48
|
+
"valueFormat": "number"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"entryId": "REPLACE_ENTRY_5",
|
|
52
|
+
"name": "销售与毛利趋势",
|
|
53
|
+
"type": "line",
|
|
54
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
55
|
+
"filterNames": [],
|
|
56
|
+
"categoryFieldId": "REPLACE_CATEGORY_5",
|
|
57
|
+
"valueFieldId": "REPLACE_VALUE_5",
|
|
58
|
+
"categoryFormat": "epoch-month",
|
|
59
|
+
"categoryTimeZone": "Asia/Shanghai",
|
|
60
|
+
"series": [
|
|
61
|
+
{
|
|
62
|
+
"fieldId": "REPLACE_SERIES_5_1",
|
|
63
|
+
"label": "销售额"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"fieldId": "REPLACE_SERIES_5_2",
|
|
67
|
+
"label": "毛利"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"entryId": "REPLACE_ENTRY_6",
|
|
73
|
+
"name": "销售额 TOP10 门店",
|
|
74
|
+
"type": "bar",
|
|
75
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
76
|
+
"filterNames": [],
|
|
77
|
+
"categoryFieldId": "REPLACE_CATEGORY_6",
|
|
78
|
+
"valueFieldId": "REPLACE_VALUE_6"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"entryId": "REPLACE_ENTRY_7",
|
|
82
|
+
"name": "区域销售构成",
|
|
83
|
+
"type": "pie",
|
|
84
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
85
|
+
"filterNames": [],
|
|
86
|
+
"categoryFieldId": "REPLACE_CATEGORY_7",
|
|
87
|
+
"valueFieldId": "REPLACE_VALUE_7"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"entryId": "REPLACE_ENTRY_8",
|
|
91
|
+
"name": "品类销售额",
|
|
92
|
+
"type": "bar",
|
|
93
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
94
|
+
"filterNames": [],
|
|
95
|
+
"categoryFieldId": "REPLACE_CATEGORY_8",
|
|
96
|
+
"valueFieldId": "REPLACE_VALUE_8"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"entryId": "REPLACE_ENTRY_9",
|
|
100
|
+
"name": "自有店 / 管理店销售趋势",
|
|
101
|
+
"type": "line",
|
|
102
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
103
|
+
"filterNames": [],
|
|
104
|
+
"categoryFieldId": "REPLACE_CATEGORY_9",
|
|
105
|
+
"valueFieldId": "REPLACE_VALUE_9",
|
|
106
|
+
"categoryFormat": "epoch-month",
|
|
107
|
+
"categoryTimeZone": "Asia/Shanghai",
|
|
108
|
+
"series": [
|
|
109
|
+
{
|
|
110
|
+
"fieldId": "REPLACE_SERIES_9_1",
|
|
111
|
+
"label": "自有店"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"fieldId": "REPLACE_SERIES_9_2",
|
|
115
|
+
"label": "管理店"
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"entryId": "REPLACE_ENTRY_10",
|
|
121
|
+
"name": "生活馆 / 时尚馆销售趋势",
|
|
122
|
+
"type": "line",
|
|
123
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
124
|
+
"filterNames": [],
|
|
125
|
+
"categoryFieldId": "REPLACE_CATEGORY_10",
|
|
126
|
+
"valueFieldId": "REPLACE_VALUE_10",
|
|
127
|
+
"categoryFormat": "epoch-month",
|
|
128
|
+
"categoryTimeZone": "Asia/Shanghai",
|
|
129
|
+
"series": [
|
|
130
|
+
{
|
|
131
|
+
"fieldId": "REPLACE_SERIES_10_1",
|
|
132
|
+
"label": "生活馆"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"fieldId": "REPLACE_SERIES_10_2",
|
|
136
|
+
"label": "时尚馆"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"entryId": "REPLACE_ENTRY_11",
|
|
142
|
+
"name": "门店风格构成",
|
|
143
|
+
"type": "pie",
|
|
144
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
145
|
+
"filterNames": [],
|
|
146
|
+
"categoryFieldId": "REPLACE_CATEGORY_11",
|
|
147
|
+
"valueFieldId": "REPLACE_VALUE_11"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"entryId": "REPLACE_ENTRY_12",
|
|
151
|
+
"name": "门店性质构成",
|
|
152
|
+
"type": "pie",
|
|
153
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
154
|
+
"filterNames": [],
|
|
155
|
+
"categoryFieldId": "REPLACE_CATEGORY_12",
|
|
156
|
+
"valueFieldId": "REPLACE_VALUE_12"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"entryId": "REPLACE_ENTRY_13",
|
|
160
|
+
"name": "门店经营明细",
|
|
161
|
+
"type": "table",
|
|
162
|
+
"description": "替换为已核实的源指标口径与范围。",
|
|
163
|
+
"filterNames": [],
|
|
164
|
+
"tableFieldIds": [
|
|
165
|
+
"REPLACE_TABLE_FIELD_1",
|
|
166
|
+
"REPLACE_TABLE_FIELD_2",
|
|
167
|
+
"REPLACE_TABLE_FIELD_3",
|
|
168
|
+
"REPLACE_TABLE_FIELD_4"
|
|
169
|
+
],
|
|
170
|
+
"localFilterFieldIds": [
|
|
171
|
+
"REPLACE_TABLE_FIELD_1"
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
"filters": [
|
|
176
|
+
{
|
|
177
|
+
"name": "REPLACE_STORE_CONTROL",
|
|
178
|
+
"label": "店名(精确匹配)",
|
|
179
|
+
"type": "text"
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
"dataNotes": [
|
|
183
|
+
"请填写已核实的来源、单位、期间、门店计数口径与控件作用范围。未确认公式时保留源指标名称。"
|
|
184
|
+
]
|
|
185
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>FineBI 数据应用</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>FineBI 数据应用</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
{
|
|
2
|
-
"schemaVersion": "1",
|
|
3
|
-
"title": "FineBI 数据应用",
|
|
4
|
-
"basePath": "/webroot/decision",
|
|
5
|
-
"layout": "overview",
|
|
6
|
-
"dashboardId": "",
|
|
7
|
-
"maxRows": 1000,
|
|
8
|
-
"pageSize": 8,
|
|
9
|
-
"widgets": [],
|
|
10
|
-
"filters": []
|
|
11
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1",
|
|
3
|
+
"title": "FineBI 数据应用",
|
|
4
|
+
"basePath": "/webroot/decision",
|
|
5
|
+
"layout": "overview",
|
|
6
|
+
"dashboardId": "",
|
|
7
|
+
"maxRows": 1000,
|
|
8
|
+
"pageSize": 8,
|
|
9
|
+
"widgets": [],
|
|
10
|
+
"filters": []
|
|
11
|
+
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
function openBrowser(url) {
|
|
4
|
+
const [command,args]=process.platform==='win32' ? ['rundll32.exe',['url.dll,FileProtocolHandler',url]]
|
|
5
|
+
: process.platform==='darwin' ? ['open',[url]] : ['xdg-open',[url]];
|
|
6
|
+
const child=spawn(command,args,{stdio:'ignore',windowsHide:true});child.on('error',()=>{});child.unref();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function sleep(ms,signal) {
|
|
10
|
+
return new Promise((resolve,reject)=>{
|
|
11
|
+
if(signal?.aborted){reject(Error('AUTH_REQUIRED'));return;}
|
|
12
|
+
const abort=()=>{clearTimeout(timer);reject(Error('AUTH_REQUIRED'));};
|
|
13
|
+
const timer=setTimeout(()=>{signal?.removeEventListener('abort',abort);resolve();},ms);
|
|
14
|
+
signal?.addEventListener('abort',abort,{once:true});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function authRequest(url,body,{fetchImpl=fetch,signal}={}) {
|
|
19
|
+
const controller=new AbortController();
|
|
20
|
+
const abort=()=>controller.abort();
|
|
21
|
+
if(signal?.aborted)abort();else signal?.addEventListener('abort',abort,{once:true});
|
|
22
|
+
const timer=setTimeout(abort,30000);
|
|
23
|
+
try {
|
|
24
|
+
const response=await fetchImpl(url,{method:'POST',redirect:'manual',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),signal:controller.signal});
|
|
25
|
+
const payload=await response.json().catch(()=>null);
|
|
26
|
+
return {response,payload};
|
|
27
|
+
} catch {throw Error(signal?.aborted?'AUTH_REQUIRED':'UPSTREAM_UNAVAILABLE');}
|
|
28
|
+
finally {clearTimeout(timer);signal?.removeEventListener('abort',abort);}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function browserSession(baseUrl,options={}) {
|
|
32
|
+
const {signal,notify=console.info,openBrowser:launch=openBrowser,sleep:wait=sleep}=options;
|
|
33
|
+
const target=new URL(baseUrl);
|
|
34
|
+
if(target.protocol!=='https:' && !(target.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname)))throw Error('AUTH_HTTPS_REQUIRED');
|
|
35
|
+
const root=baseUrl.replace(/\/$/,'')+'/api/bi-cli/dev-session-auth';
|
|
36
|
+
const started=await authRequest(root+'/start',{description:'FineBI Web 本地开发'},options);
|
|
37
|
+
if(started.response.status===404)throw Error('AUTH_PLUGIN_REQUIRED');
|
|
38
|
+
if(!started.response.ok)throw Error('AUTH_UNAVAILABLE');
|
|
39
|
+
const data=started.payload?.data;
|
|
40
|
+
if(!data || !/^[A-Za-z0-9_-]{43}$/.test(data.deviceCode) || !/^[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(data.userCode)
|
|
41
|
+
|| data.verificationPath!==`/api/bi-cli/dev-session-auth/authorize?code=${data.userCode}` || data.expiresIn!==600 || data.interval!==5)throw Error('AUTH_PROTOCOL_ERROR');
|
|
42
|
+
const verification=baseUrl.replace(/\/$/,'')+data.verificationPath;
|
|
43
|
+
notify(`请先通过原有入口登录 FineBI,再打开授权页:${verification}\n核对确认码:${data.userCode}。授权会将当前账号会话保存到本地 .env.local。`);
|
|
44
|
+
try{await launch(verification);}catch{notify('未能打开浏览器,请手动打开上述授权页。');}
|
|
45
|
+
const deadline=Date.now()+data.expiresIn*1000;
|
|
46
|
+
while(Date.now()<deadline && !signal?.aborted) {
|
|
47
|
+
await wait(data.interval*1000,signal);
|
|
48
|
+
const polled=await authRequest(root+'/poll',{deviceCode:data.deviceCode},options);
|
|
49
|
+
if(polled.response.status===429)continue;
|
|
50
|
+
if(polled.response.status===404)throw Error('AUTH_REQUIRED');
|
|
51
|
+
if(!polled.response.ok)throw Error('AUTH_UNAVAILABLE');
|
|
52
|
+
const result=polled.payload?.data;
|
|
53
|
+
if(result?.status==='pending')continue;
|
|
54
|
+
if(result?.status==='approved' && typeof result.token==='string' && /^[\x21-\x7e]+$/.test(result.token) && !/[;,]/.test(result.token) && typeof result.username==='string')return result;
|
|
55
|
+
if(['denied','expired','consumed'].includes(result?.status))throw Error('AUTH_REQUIRED');
|
|
56
|
+
throw Error('AUTH_PROTOCOL_ERROR');
|
|
57
|
+
}
|
|
58
|
+
throw Error('AUTH_REQUIRED');
|
|
59
|
+
}
|
|
@@ -1,24 +1,13 @@
|
|
|
1
1
|
// Development only: the browser never receives the developer's session token.
|
|
2
2
|
import {validateRefer} from '../src/finebi/filters.js';
|
|
3
|
-
|
|
3
|
+
import {validateDimensionSelections} from '../src/finebi/config.js';
|
|
4
|
+
import {createDevSession} from './dev-session.mjs';
|
|
5
|
+
export function createDevMiddleware(config, env = process.env, fetchImpl = fetch, sessionOptions = {}) {
|
|
4
6
|
const prefix = config.basePath.replace(/\/$/, '');
|
|
5
7
|
const queryPath = `${prefix}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`;
|
|
6
|
-
|
|
7
|
-
let login;
|
|
8
|
+
const session = createDevSession(env,{...sessionOptions,fetchImpl});
|
|
8
9
|
const reply = (res, status, errorCode) => {res.writeHead(status, {'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify({success:false,errorCode}));};
|
|
9
|
-
async
|
|
10
|
-
if (token) return token;
|
|
11
|
-
if (!env.FINEBI_DEV_USERNAME || !env.FINEBI_DEV_PASSWORD) throw new Error('AUTH_REQUIRED');
|
|
12
|
-
if (!login) login = (async()=>{
|
|
13
|
-
const response = await fetchImpl(`${env.FINEBI_DEV_BASE_URL.replace(/\/$/, '')}/login`, {method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''}),signal:AbortSignal.timeout(30000)});
|
|
14
|
-
const body = await response.json();
|
|
15
|
-
if (!response.ok || typeof body?.data?.accessToken !== 'string' || !body.data.accessToken) throw new Error('AUTH_REQUIRED');
|
|
16
|
-
token=body.data.accessToken;
|
|
17
|
-
return token;
|
|
18
|
-
})().finally(()=>{login=undefined;});
|
|
19
|
-
return login;
|
|
20
|
-
}
|
|
21
|
-
return async (req, res, next) => {
|
|
10
|
+
const middleware = async (req, res, next) => {
|
|
22
11
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
23
12
|
if (!pathname.startsWith(prefix+'/') && pathname !== prefix) return next();
|
|
24
13
|
const host = req.headers.host || '';
|
|
@@ -33,25 +22,37 @@ export function createDevMiddleware(config, env = process.env, fetchImpl = fetch
|
|
|
33
22
|
if (!Array.isArray(body.widgetIds) || !body.widgetIds.length || body.widgetIds.some(id=>!config.widgets.some(w=>w.entryId===id)) || !Number.isSafeInteger(body.pageLimit) || body.pageLimit<1 || body.pageLimit>config.maxRows) throw new Error('INVALID_REQUEST');
|
|
34
23
|
if(body.reportEntryId!==config.directoryEntryId)throw new Error('INVALID_SOURCE');
|
|
35
24
|
validateRefer(config.filters || [],body.refer);
|
|
25
|
+
validateDimensionSelections(config.widgets,body.widgetIds,body.dimensionSelections);
|
|
36
26
|
} catch {return reply(res,400,'INVALID_REQUEST');}
|
|
37
27
|
if (!env.FINEBI_DEV_BASE_URL) return reply(res,401,'AUTH_REQUIRED');
|
|
38
28
|
try {
|
|
39
29
|
const target = new URL(env.FINEBI_DEV_BASE_URL);
|
|
40
30
|
if (!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash) throw new Error('INVALID_TARGET');
|
|
41
|
-
|
|
31
|
+
for(let attempt=0;attempt<2;attempt++) {
|
|
32
|
+
const auth=await session.get();
|
|
42
33
|
const response=await fetchImpl(`${target.href.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
|
|
43
34
|
method:'POST',redirect:'manual',signal:AbortSignal.timeout(30000),
|
|
44
35
|
headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest',Cookie:`fine_auth_token=${auth}`},
|
|
45
|
-
body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {},...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{})}),
|
|
36
|
+
body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {},...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{}),...(body.dimensionSelections!==undefined?{dimensionSelections:body.dimensionSelections}:{})}),
|
|
46
37
|
});
|
|
47
|
-
if (response.status===401 || response.status>=300 && response.status<400 || response.headers.get('content-type')?.includes('text/html')) {token='';return reply(res,401,'AUTH_REQUIRED');}
|
|
48
|
-
if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
|
|
49
|
-
const json=await response.json();
|
|
50
|
-
if (response.status===401 || ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json.errorCode)) {token='';return reply(res,401,'AUTH_REQUIRED');}
|
|
51
38
|
if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
|
|
39
|
+
const loginResponse=response.status===401 || response.status>=300 && response.status<400 || response.ok && response.headers.get('content-type')?.includes('text/html');
|
|
40
|
+
const json=loginResponse ? null : await response.json();
|
|
41
|
+
if (loginResponse || response.ok && ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json?.errorCode ?? json?.code)) {
|
|
42
|
+
await session.invalidate(auth);
|
|
43
|
+
if(attempt===0)continue;
|
|
44
|
+
return reply(res,401,'AUTH_REQUIRED');
|
|
45
|
+
}
|
|
52
46
|
// Forward only the query envelope, never upstream headers or login payloads.
|
|
53
47
|
res.writeHead(response.status,{'Content-Type':'application/json','Cache-Control':'no-store'});
|
|
54
|
-
res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || 'QUERY_FAILED'}));
|
|
55
|
-
|
|
48
|
+
return res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || json.code || 'QUERY_FAILED'}));
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const code=['AUTH_REQUIRED','AUTH_PLUGIN_REQUIRED','AUTH_HTTPS_REQUIRED','AUTH_PROTOCOL_ERROR','AUTH_UNAVAILABLE','AUTH_STORAGE_FAILED'].includes(error.message)?error.message:'UPSTREAM_UNAVAILABLE';
|
|
52
|
+
if(code!=='UPSTREAM_UNAVAILABLE') (sessionOptions.notify || console.info)(`FineBI 开发认证:${code}。请检查终端提示与 .env.local,修正后重试。`);
|
|
53
|
+
reply(res,code==='AUTH_REQUIRED'?401:502,code);
|
|
54
|
+
}
|
|
56
55
|
};
|
|
56
|
+
middleware.dispose=()=>session.dispose();
|
|
57
|
+
return middleware;
|
|
57
58
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {resolve} from 'node:path';
|
|
2
|
+
import {saveSession} from './session-file.mjs';
|
|
3
|
+
import {authRequest,browserSession} from './browser-session.mjs';
|
|
4
|
+
|
|
5
|
+
export function createDevSession(env,options={}) {
|
|
6
|
+
const {fetchImpl=fetch,browser=browserSession,notify=console.info}=options;
|
|
7
|
+
const file=options.file || resolve('.env.local');
|
|
8
|
+
const save=options.save || ((...args)=>saveSession(file,...args));
|
|
9
|
+
const baseUrl=(env.FINEBI_DEV_BASE_URL || '').replace(/\/$/,'');
|
|
10
|
+
let token=env.FINEBI_DEV_TOKEN || '';
|
|
11
|
+
if(env.FINEBI_DEV_TOKEN_BASE_URL && env.FINEBI_DEV_TOKEN_BASE_URL.replace(/\/$/,'')!==baseUrl)token='';
|
|
12
|
+
let username=env.FINEBI_DEV_TOKEN_USERNAME || '';
|
|
13
|
+
let pending,clearing,passwordRejected=false;
|
|
14
|
+
const controller=new AbortController();
|
|
15
|
+
const valid=value=>typeof value==='string' && /^[\x21-\x7e]+$/.test(value) && !/[;,]/.test(value);
|
|
16
|
+
if(token && !valid(token))token='';
|
|
17
|
+
|
|
18
|
+
async function acquire() {
|
|
19
|
+
if(!baseUrl || controller.signal.aborted)throw Error('AUTH_REQUIRED');
|
|
20
|
+
const target=new URL(baseUrl);
|
|
21
|
+
if(!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash)throw Error('AUTH_PROTOCOL_ERROR');
|
|
22
|
+
let result;
|
|
23
|
+
if(!passwordRejected && env.FINEBI_DEV_USERNAME && env.FINEBI_DEV_PASSWORD) {
|
|
24
|
+
const {response,payload}=await authRequest(baseUrl+'/login',{username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''},{fetchImpl,signal:controller.signal});
|
|
25
|
+
if(response.status>=500 || response.status===429)throw Error('UPSTREAM_UNAVAILABLE');
|
|
26
|
+
if(response.ok && valid(payload?.data?.accessToken))result={token:payload.data.accessToken,username:env.FINEBI_DEV_USERNAME};
|
|
27
|
+
else if([401,403].includes(response.status) || response.status>=300 && response.status<400 || payload?.errorCode || payload?.code || response.headers.get('content-type')?.includes('text/html')) {
|
|
28
|
+
passwordRejected=true;notify('密码登录被拒绝或需要交互登录,转为浏览器授权;本次运行不再重复尝试该密码。');
|
|
29
|
+
} else throw Error('AUTH_PROTOCOL_ERROR');
|
|
30
|
+
}
|
|
31
|
+
if(!result)result=await browser(baseUrl,{fetchImpl,signal:controller.signal,notify});
|
|
32
|
+
if(controller.signal.aborted)throw Error('AUTH_REQUIRED');
|
|
33
|
+
if(!valid(result?.token) || typeof result.username!=='string')throw Error('AUTH_PROTOCOL_ERROR');
|
|
34
|
+
if(username && username!==result.username)notify(`开发身份已切换:${username} → ${result.username}`);
|
|
35
|
+
try{await save(baseUrl,result.token,result.username);}catch{throw Error('AUTH_STORAGE_FAILED');}
|
|
36
|
+
token=result.token;username=result.username;
|
|
37
|
+
notify(`FineBI 开发会话已保存,当前账号:${username}`);
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
async get() {
|
|
42
|
+
if(clearing)await clearing;
|
|
43
|
+
if(controller.signal.aborted)throw Error('AUTH_REQUIRED');
|
|
44
|
+
if(token)return token;
|
|
45
|
+
if(!pending)pending=acquire().finally(()=>{pending=undefined;});
|
|
46
|
+
return pending;
|
|
47
|
+
},
|
|
48
|
+
async invalidate(rejected) {
|
|
49
|
+
if(token!==rejected)return;
|
|
50
|
+
token='';
|
|
51
|
+
// Serialize invalidation with acquisition so an old response cannot overwrite a new session on disk.
|
|
52
|
+
clearing=save(baseUrl,'',username).catch(()=>{throw Error('AUTH_STORAGE_FAILED');}).finally(()=>{clearing=undefined;});
|
|
53
|
+
await clearing;
|
|
54
|
+
},
|
|
55
|
+
dispose(){controller.abort();},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {lstat, readFile, open, mkdir, rename, chmod, unlink} from 'node:fs/promises';
|
|
2
|
+
import {dirname, resolve} from 'node:path';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {spawnSync} from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
async function regularFile(path) {
|
|
7
|
+
try {const info=await lstat(path);if(!info.isFile() || info.isSymbolicLink())throw Error('SESSION_FILE_UNSAFE');}
|
|
8
|
+
catch(error){if(error.code!=='ENOENT')throw error;}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function restrict(path) {
|
|
12
|
+
if(process.platform!=='win32'){await chmod(path,0o600);return;}
|
|
13
|
+
const options={encoding:'utf8',windowsHide:true};
|
|
14
|
+
const identity=spawnSync('whoami.exe',['/user','/fo','csv','/nh'],options);
|
|
15
|
+
const sid=identity.status===0 && identity.stdout.match(/"[^"]*","(S-[^"]+)"/)?.[1];
|
|
16
|
+
if(!sid)throw Error('SESSION_FILE_PERMISSIONS');
|
|
17
|
+
const result=spawnSync('icacls.exe',[path,'/inheritance:r','/grant:r',`*${sid}:(F)`,'*S-1-5-18:(F)','*S-1-5-32-544:(F)'],options);
|
|
18
|
+
if(result.status!==0)throw Error('SESSION_FILE_PERMISSIONS');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function saveSession(path,baseUrl,token,username='') {
|
|
22
|
+
path=resolve(path);
|
|
23
|
+
await regularFile(path);
|
|
24
|
+
const original=await readFile(path,'utf8').catch(error=>{if(error.code==='ENOENT')return '';throw error;});
|
|
25
|
+
const updates={FINEBI_DEV_TOKEN:token,FINEBI_DEV_TOKEN_BASE_URL:baseUrl,FINEBI_DEV_TOKEN_USERNAME:username};
|
|
26
|
+
if(!/^\s*(?:export\s+)?FINEBI_DEV_BASE_URL\s*=/m.test(original))updates.FINEBI_DEV_BASE_URL=baseUrl;
|
|
27
|
+
const retained=original.split(/\r?\n/).filter(line=>{
|
|
28
|
+
const key=line.match(/^\s*(?:export\s+)?([\w]+)\s*=/)?.[1];return !Object.hasOwn(updates,key);
|
|
29
|
+
}).join('\n').replace(/\n*$/,'');
|
|
30
|
+
const content=(retained?retained+'\n':'')+Object.entries(updates).map(([key,value])=>`${key}=${JSON.stringify(value)}`).join('\n')+'\n';
|
|
31
|
+
const temp=resolve(dirname(path),'temp');
|
|
32
|
+
await mkdir(temp,{recursive:true});
|
|
33
|
+
if((await lstat(temp)).isSymbolicLink())throw Error('SESSION_FILE_UNSAFE');
|
|
34
|
+
const stage=resolve(temp,`.finebi-session-${randomUUID()}`);
|
|
35
|
+
const handle=await open(stage,'wx',0o600);
|
|
36
|
+
try {
|
|
37
|
+
await restrict(stage);
|
|
38
|
+
await handle.writeFile(content,'utf8');await handle.sync();await handle.close();
|
|
39
|
+
await regularFile(path);
|
|
40
|
+
// A concurrent configuration edit wins; do not overwrite it with the earlier snapshot.
|
|
41
|
+
const current=await readFile(path,'utf8').catch(error=>{if(error.code==='ENOENT')return '';throw error;});
|
|
42
|
+
if(current!==original)throw Error('SESSION_FILE_CHANGED');
|
|
43
|
+
await rename(stage,path);
|
|
44
|
+
} finally {await handle.close().catch(()=>{});await unlink(stage).catch(error=>{if(error.code!=='ENOENT')throw error;});}
|
|
45
|
+
}
|