@finebi/finebi-cli 1.6.0 → 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.
Files changed (160) hide show
  1. package/README.md +16 -12
  2. package/package.json +1 -1
  3. package/skills/finebi-cli-agent/SKILL.md +43 -123
  4. package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
  5. package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
  6. package/skills/finebi-cli-agent/references/command-workflows.md +31 -2
  7. package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
  8. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
  9. package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
  10. package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
  11. package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
  12. package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
  13. package/skills/finebi-cli-agent/references/web-app.md +12 -6
  14. package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +157 -189
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +28 -28
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +65 -61
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +23 -7
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +19 -6
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +19 -4
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +48 -3
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +43 -2
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +6 -2
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -0
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2 -2
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +3 -2
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +26 -24
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -31
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -31
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +12 -5
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +16 -10
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -0
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -0
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -0
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -0
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -28
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -0
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -0
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -0
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -0
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -0
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +32 -11
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -0
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -0
  60. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  61. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  62. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  63. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  64. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
  65. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
  66. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  67. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +13 -0
  68. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -0
  69. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -0
  70. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  71. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -0
  72. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -0
  73. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -0
  74. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -0
  75. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -0
  76. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -0
  77. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -0
  78. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -0
  79. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  80. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +31 -4
  81. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -0
  82. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +8 -1
  83. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  84. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  85. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  86. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  87. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +11 -4
  88. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  89. package/src/app.js +157 -189
  90. package/src/browser-auth.js +3 -4
  91. package/src/config.js +28 -28
  92. package/src/error-catalog.js +2 -2
  93. package/src/help.js +65 -61
  94. package/src/install-skill.js +89 -89
  95. package/src/profiles.js +158 -158
  96. package/src/skill-files.js +94 -94
  97. package/templates/web-app/AGENTS.md +23 -7
  98. package/templates/web-app/README.md +19 -6
  99. package/templates/web-app/docs/acceptance.md +80 -0
  100. package/templates/web-app/docs/auth-and-deploy.md +19 -4
  101. package/templates/web-app/docs/data-contract.md +48 -3
  102. package/templates/web-app/docs/design.md +43 -2
  103. package/templates/web-app/docs/filters.md +6 -2
  104. package/templates/web-app/examples/cockpit.config.json +22 -0
  105. package/templates/web-app/examples/retail.config.json +185 -0
  106. package/templates/web-app/index.html +2 -2
  107. package/templates/web-app/package-lock.json +2 -2
  108. package/templates/web-app/package.json +3 -2
  109. package/templates/web-app/public/app-config.json +11 -11
  110. package/templates/web-app/scripts/browser-session.mjs +59 -0
  111. package/templates/web-app/scripts/dev-proxy.mjs +26 -24
  112. package/templates/web-app/scripts/dev-session.mjs +57 -0
  113. package/templates/web-app/scripts/session-file.mjs +45 -0
  114. package/templates/web-app/src/App.tsx +63 -31
  115. package/templates/web-app/src/cockpit.css +3 -0
  116. package/templates/web-app/src/components/Chart.tsx +40 -31
  117. package/templates/web-app/src/components/DataTable.tsx +12 -5
  118. package/templates/web-app/src/components/FilterBar.tsx +16 -10
  119. package/templates/web-app/src/components/Insights.tsx +17 -0
  120. package/templates/web-app/src/components/MonthPicker.tsx +32 -0
  121. package/templates/web-app/src/components/OptionFilter.tsx +7 -0
  122. package/templates/web-app/src/components/RangeFilter.tsx +9 -0
  123. package/templates/web-app/src/components/Widget.tsx +40 -28
  124. package/templates/web-app/src/components/calendar-locales.ts +3 -0
  125. package/templates/web-app/src/components/calendar.ts +14 -14
  126. package/templates/web-app/src/components/charts/Bar.tsx +6 -0
  127. package/templates/web-app/src/components/charts/Line.tsx +6 -0
  128. package/templates/web-app/src/components/charts/Pie.tsx +6 -0
  129. package/templates/web-app/src/components/month-picker.css +1 -0
  130. package/templates/web-app/src/finebi/client.ts +32 -11
  131. package/templates/web-app/src/finebi/config.d.ts +3 -0
  132. package/templates/web-app/src/finebi/config.js +58 -0
  133. package/templates/web-app/src/finebi/date.d.ts +1 -1
  134. package/templates/web-app/src/finebi/date.js +5 -5
  135. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  136. package/templates/web-app/src/finebi/filters.js +106 -106
  137. package/templates/web-app/src/finebi/normalize.ts +235 -77
  138. package/templates/web-app/src/finebi/types.ts +20 -18
  139. package/templates/web-app/src/main.tsx +5 -5
  140. package/templates/web-app/src/retail.css +13 -0
  141. package/templates/web-app/src/services/presentation.ts +36 -0
  142. package/templates/web-app/src/services/table-data.ts +47 -0
  143. package/templates/web-app/src/services/view-data.ts +18 -18
  144. package/templates/web-app/src/services/widget-queries.ts +18 -0
  145. package/templates/web-app/tests/browser/fixtures.mjs +28 -0
  146. package/templates/web-app/tests/browser/index.html +16 -0
  147. package/templates/web-app/tests/browser/main.tsx +149 -0
  148. package/templates/web-app/tests/browser/test-page.css +4 -0
  149. package/templates/web-app/tests/browser/vite.config.mjs +13 -0
  150. package/templates/web-app/tests/bundle.test.mjs +19 -0
  151. package/templates/web-app/tests/cockpit.test.mjs +73 -0
  152. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  153. package/templates/web-app/tests/proxy.test.mjs +31 -4
  154. package/templates/web-app/tests/retail.test.mjs +31 -0
  155. package/templates/web-app/tests/runtime.test.mjs +8 -1
  156. package/templates/web-app/tests/session.test.mjs +91 -0
  157. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  158. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  159. package/templates/web-app/tsconfig.json +4 -4
  160. package/templates/web-app/vite.config.ts +11 -4
@@ -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
- export function createDevMiddleware(config, env = process.env, fetchImpl = fetch) {
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
- let token = env.FINEBI_DEV_TOKEN || '';
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 function session() {
10
- if (token) return token;
11
- if (!env.FINEBI_DEV_USERNAME || !env.FINEBI_DEV_PASSWORD) throw new Error('AUTH_REQUIRED');
12
- if (!login) login = (async()=>{
13
- const response = await fetchImpl(`${env.FINEBI_DEV_BASE_URL.replace(/\/$/, '')}/login`, {method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:env.FINEBI_DEV_USERNAME,password:env.FINEBI_DEV_PASSWORD,encrypted:false,tenantId:''}),signal:AbortSignal.timeout(30000)});
14
- const body = await response.json();
15
- if (!response.ok || typeof body?.data?.accessToken !== 'string' || !body.data.accessToken) throw new Error('AUTH_REQUIRED');
16
- token=body.data.accessToken;
17
- return token;
18
- })().finally(()=>{login=undefined;});
19
- return login;
20
- }
21
- return async (req, res, next) => {
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 || '';
@@ -31,26 +20,39 @@ export function createDevMiddleware(config, env = process.env, fetchImpl = fetch
31
20
  for await (const chunk of req) {raw+=chunk.toString();if (raw.length>65536) throw new Error('BODY_TOO_LARGE');}
32
21
  body=JSON.parse(raw);
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');
23
+ if(body.reportEntryId!==config.directoryEntryId)throw new Error('INVALID_SOURCE');
34
24
  validateRefer(config.filters || [],body.refer);
25
+ validateDimensionSelections(config.widgets,body.widgetIds,body.dimensionSelections);
35
26
  } catch {return reply(res,400,'INVALID_REQUEST');}
36
27
  if (!env.FINEBI_DEV_BASE_URL) return reply(res,401,'AUTH_REQUIRED');
37
28
  try {
38
29
  const target = new URL(env.FINEBI_DEV_BASE_URL);
39
30
  if (!['http:','https:'].includes(target.protocol) || target.username || target.password || target.search || target.hash) throw new Error('INVALID_TARGET');
40
- const auth=await session();
31
+ for(let attempt=0;attempt<2;attempt++) {
32
+ const auth=await session.get();
41
33
  const response=await fetchImpl(`${target.href.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
42
34
  method:'POST',redirect:'manual',signal:AbortSignal.timeout(30000),
43
35
  headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest',Cookie:`fine_auth_token=${auth}`},
44
- body:JSON.stringify({widgetIds:body.widgetIds,includeWidgetBean:true,pageLimit:body.pageLimit,refer:body.refer || {}}),
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}:{})}),
45
37
  });
46
- if (response.status===401 || response.status>=300 && response.status<400 || response.headers.get('content-type')?.includes('text/html')) {token='';return reply(res,401,'AUTH_REQUIRED');}
47
- if (response.status===403) return reply(res,403,'PERMISSION_DENIED');
48
- const json=await response.json();
49
- if (response.status===401 || ['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN'].includes(json.errorCode)) {token='';return reply(res,401,'AUTH_REQUIRED');}
50
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
+ }
51
46
  // Forward only the query envelope, never upstream headers or login payloads.
52
47
  res.writeHead(response.status,{'Content-Type':'application/json','Cache-Control':'no-store'});
53
- res.end(JSON.stringify(json.success===true ? {success:true,data:json.data} : {success:false,errorCode:json.errorCode || 'QUERY_FAILED'}));
54
- } catch (error) {reply(res,error.message==='AUTH_REQUIRED'?401:502,error.message==='AUTH_REQUIRED'?'AUTH_REQUIRED':'UPSTREAM_UNAVAILABLE');}
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
+ }
55
55
  };
56
+ middleware.dispose=()=>session.dispose();
57
+ return middleware;
56
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
+ }
@@ -1,31 +1,63 @@
1
- import {useEffect, useState} from 'react';
2
- import {FineBIError, queryWidgets, validateConfig} from './finebi/client';
3
- import type {AppConfig, QueryResult, FilterState} from './finebi/types';
4
- import {Widget} from './components/Widget';
5
- import {FilterBar} from './components/FilterBar';
6
- function Dashboard({config}: {config: AppConfig}) {
7
- const [filters, setFilters] = useState<FilterState>({});
8
- const [refresh, setRefresh] = useState(0);
9
- const [state, setState] = useState<{loading:boolean; results?:Record<string,QueryResult>; error?:Error; at?:string}>({loading:true});
10
- useEffect(() => {
11
- const controller = new AbortController(); let current = true;
12
- setState({loading:true});
13
- queryWidgets(config, filters, controller.signal).then(results => {if(current) setState({loading:false,results,at:new Date().toLocaleTimeString()});}).catch(error => {if(current) setState({loading:false,error});});
14
- return () => {current=false;controller.abort();};
15
- },[config,filters,refresh]);
16
- return <div className={`app ${config.layout}`}>
17
- {config.layout==='workbench' && <aside><strong>FineBI</strong><a href="#dashboard">数据概览</a></aside>}
18
- <main id="dashboard"><header><div><span className="eyebrow">FINEBI · LIVE DATA</span><h1>{config.title}</h1><p className="muted">{state.at ? `最近更新 ${state.at}` : '连接 FineBI 数据'} · {config.widgets.length} 个展示组件</p></div><button onClick={()=>setRefresh(n=>n+1)}>刷新</button></header>
19
- {config.filters.length>0 && <FilterBar bindings={config.filters} onApply={setFilters}/>}
20
- {state.loading && <p className="state" role="status">正在获取实时数据…</p>}
21
- {state.error && <div className="state error" role="alert"><h2>{state.error instanceof FineBIError && state.error.code==='AUTH_REQUIRED'?'需要登录 FineBI':'数据暂不可用'}</h2><p>{state.error.message}</p><a href={config.basePath} target="_blank" rel="noreferrer">打开 FineBI</a><button onClick={()=>setRefresh(n=>n+1)}>重试</button></div>}
22
- {state.results && <><p className="live" role="status">实时数据已更新</p><div className="grid">{config.widgets.map((binding,i)=><Widget key={`${refresh}-${JSON.stringify(filters)}-${i}`} result={state.results![binding.entryId]} binding={binding} pageSize={config.pageSize}/>)}</div></>}
23
- <footer>数据范围以当前查询结果为准 · 权限由 FineBI 服务端控制</footer></main>
24
- </div>;
25
- }
26
- export default function App() {
27
- const [config, setConfig] = useState<AppConfig>(); const [error, setError] = useState('');
28
- useEffect(()=>{const controller=new AbortController(); fetch('./app-config.json',{signal:controller.signal,cache:'no-store'}).then(r=>{if(!r.ok)throw Error('应用配置加载失败');return r.json();}).then(c=>{validateConfig(c);if(!Number.isSafeInteger(c.pageSize)||c.pageSize<1||c.pageSize>100)throw Error('pageSize 必须在 1–100 范围内');if(!Array.isArray(c.filters))throw Error('filters 必须为数组');setConfig(c);}).catch(e=>{if(!controller.signal.aborted)setError(e.message);});return()=>controller.abort();},[]);
29
- if(error)return <main className="state" role="alert"><h1>请完成应用配置</h1><p>{error}</p><p>在 public/app-config.json 中绑定 CLI 发现的仪表板实例与字段。</p></main>;
30
- return config ? <Dashboard config={config}/> : <p className="state">正在加载应用…</p>;
31
- }
1
+ import {lazy,Suspense,useEffect,useRef,useState} from 'react';
2
+ import {queryWidget,validateConfig} from './finebi/client';
3
+ import type {AppConfig,FilterState,WidgetBinding} from './finebi/types';
4
+ import {Boundary,Widget} from './components/Widget';
5
+ import {Insights} from './components/Insights';
6
+ import {loadWidgets,widgetKey,type WidgetState} from './services/widget-queries';
7
+ import './cockpit.css';
8
+ import './retail.css';
9
+ const FilterBar=lazy(()=>import('./components/FilterBar').then(module=>({default:module.FilterBar})));
10
+ function Dashboard({config}: {config: AppConfig}) {
11
+ const [filters,setFilters]=useState<FilterState>({}),[refresh,setRefresh]=useState(0);
12
+ const [states,setStates]=useState<Record<string,WidgetState>>({});
13
+ const [activeHash,setActiveHash]=useState(()=>window.location.hash||'#dashboard');
14
+ useEffect(()=>{
15
+ const syncHash=()=>setActiveHash(window.location.hash||'#dashboard');
16
+ window.addEventListener('hashchange',syncHash);
17
+ syncHash();
18
+ return ()=>window.removeEventListener('hashchange',syncHash);
19
+ },[]);
20
+ const generation=useRef(0),retries=useRef(new Map<string,AbortController>());
21
+ useEffect(()=>{
22
+ const controller=new AbortController();generation.current++;setStates({});
23
+ void loadWidgets(config,filters,controller.signal,(key,state)=>setStates(old=>({...old,[key]:state})));
24
+ return ()=>{controller.abort();retries.current.forEach(c=>c.abort());retries.current.clear();};
25
+ },[config,filters,refresh]);
26
+ const retry=async(binding:WidgetBinding,index:number)=>{
27
+ const key=widgetKey(binding,index),current=generation.current,controller=new AbortController();
28
+ retries.current.get(key)?.abort();retries.current.set(key,controller);
29
+ setStates(old=>({...old,[key]:{loading:true}}));
30
+ try {const result=await queryWidget(config,binding,filters,controller.signal);if(current===generation.current&&!controller.signal.aborted)setStates(old=>({...old,[key]:{loading:false,result,at:new Date().toLocaleTimeString()}}));}
31
+ catch(error){if(current===generation.current&&!controller.signal.aborted)setStates(old=>({...old,[key]:{loading:false,error:error as Error}}));}
32
+ };
33
+ const complete=Object.values(states).filter(s=>s.result).length,failed=Object.values(states).filter(s=>s.error).length;
34
+ const scope=Object.fromEntries(config.filters.map(filter=>{
35
+ const known=config.widgets.filter(w=>w.filterNames?.includes(filter.name)).map(w=>w.name);
36
+ const unknown=config.widgets.some(w=>w.filterNames===undefined);
37
+ return [filter.name,`${known.length?known.join('、'):'无已确认组件'}${unknown?';未映射组件由源看板决定':''}`];
38
+ }));
39
+ const retail=config.layout==='retail';
40
+ const card=(binding:WidgetBinding,index:number)=><Widget key={`${refresh}-${JSON.stringify(filters)}-${index}`} binding={binding} state={states[widgetKey(binding,index)]??{loading:true}} pageSize={config.pageSize} onRetry={()=>void retry(binding,index)} compact={retail}/>;
41
+ const cockpit=config.layout==='cockpit';
42
+ const separateKpis=cockpit||retail;
43
+ return <div className={`app ${config.layout}`}>
44
+ {retail&&<nav className="retail-nav" aria-label="页面导航"><a className="retail-brand" href="#dashboard"><b>F.</b><span>FineBI <small>经营简报</small></span></a><div><a href="#dashboard" aria-current={activeHash==='#dashboard'?'location':undefined}>经营概览</a><a href="#components" aria-current={activeHash==='#components'?'location':undefined}>销售分析</a><a href="#data-notes" aria-current={activeHash==='#data-notes'?'location':undefined}>口径说明</a></div><span className="retail-live">动态数据</span></nav>}
45
+ {config.layout!=='overview'&&!retail&&<aside><strong>FineBI<span>数据应用</span></strong><nav><a href="#dashboard">数据概览</a><a href="#components">分析组件</a>{config.insights&&<a href="#observations">经营观察</a>}<a href="#data-notes">数据口径</a></nav><p className="source-label">{config.directoryEntryId?'目录发布来源':'分析主题来源'}<br/>访问者权限由 FineBI 控制</p></aside>}
46
+ <main id="dashboard"><header><div><span className="eyebrow">FINEBI · LIVE DATA</span><h1>{config.title}</h1><p className="muted">{config.description??'基于源看板的实时数据,按当前访问权限呈现'}</p></div><div className="header-actions"><a href={retail?'#data-notes':config.basePath} target={retail?undefined:'_blank'} rel="noreferrer">{retail?'口径说明':'打开 FineBI'}</a><button onClick={()=>setRefresh(n=>n+1)}>刷新数据</button></div></header>
47
+ {config.filters.length>0&&<Boundary><Suspense fallback={<p role="status">正在加载筛选控件…</p>}><FilterBar bindings={config.filters} onApply={setFilters} cockpit={cockpit} scope={scope} compact={retail}/></Suspense></Boundary>}
48
+ <p className="live query-status" role="status">{config.widgets.length} 个展示组件 · 已返回 {complete} · 失败 {failed} · 其余加载中或等待查询</p>
49
+ {retail&&<div className="section-heading"><span>01 / OVERVIEW</span><h2>经营概览</h2><p>源看板当前条件 · 随查询更新</p></div>}
50
+ {separateKpis&&<div className="kpi-grid">{config.widgets.map((binding,i)=>binding.type==='kpi'?card(binding,i):null)}</div>}
51
+ {config.insights&&<Insights config={config} states={states}/>}
52
+ {retail&&<div className="section-heading" id="components"><span>02 / PERFORMANCE</span><h2>销售表现</h2><p>趋势、构成与门店表现</p></div>}
53
+ <div className="grid" id={retail?undefined:'components'}>{config.widgets.map((binding,i)=>!separateKpis||binding.type!=='kpi'?card(binding,i):null)}</div>
54
+ <details className="data-notes" id="data-notes"><summary>数据口径与范围</summary><p>当前仅消费单次返回结果;COMPLETE 表示本次转换完整,不保证全量。搜索、排序、筛选、分页和 CSV 仅作用于已加载数据。UNKNOWN 行不自动累加,受限值与空值不补零。</p>{config.dataNotes?.map((note,i)=><p key={i}>{note}</p>)}<p>{config.directoryEntryId?'目录入口已绑定;查询失败不会切换到其他来源。':'来源为当前配置的分析看板。'}</p></details>
55
+ <footer>数据范围以当前查询结果为准 · 权限由 FineBI 服务端控制</footer></main>
56
+ </div>;
57
+ }
58
+ export default function App() {
59
+ const [config, setConfig] = useState<AppConfig>(); const [error, setError] = useState('');
60
+ useEffect(()=>{const controller=new AbortController(); fetch('./app-config.json',{signal:controller.signal,cache:'no-store'}).then(r=>{if(!r.ok)throw Error('应用配置加载失败');return r.json();}).then(c=>{validateConfig(c);setConfig(c);}).catch(e=>{if(!controller.signal.aborted)setError(e.message);});return()=>controller.abort();},[]);
61
+ if(error)return <main className="state" role="alert"><h1>请完成应用配置</h1><p>{error}</p><p>在 public/app-config.json 中绑定 CLI 发现的仪表板实例与字段。</p></main>;
62
+ return config ? <Dashboard config={config}/> : <p className="state">正在加载应用…</p>;
63
+ }
@@ -0,0 +1,3 @@
1
+ .panel-heading{display:flex;align-items:start;justify-content:space-between;gap:12px}.panel-heading h2{margin-bottom:6px}.component-status{font-size:10px;color:#71867d;white-space:nowrap}.component-status.failed{color:#bd534f}.result-range,.filter-scope{font-size:11px;color:#84928b;overflow-wrap:anywhere}.filter-scope{margin:4px 0 10px}.component-error{padding:14px 0;color:#af4e46;overflow-wrap:anywhere}.component-error button{color:#8f5044}.header-actions{display:flex;gap:18px;align-items:center;white-space:nowrap}.insight-panel{display:grid;grid-template-columns:220px minmax(0,1fr);gap:28px;background:#edf6f1;border:1px solid #dbece2;border-radius:15px;padding:24px;margin:24px 0}.insight-panel h2{margin:8px 0}.insight-panel p{font-size:12px;color:#6b8576}.insight-panel ul{margin:0;padding-left:18px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px;font-size:12px;color:#37604a}.data-notes{background:#fff;padding:20px 24px;border:1px solid #e2e8e3;border-radius:12px;margin-top:24px;font-size:12px}.sort-column{border:0;background:transparent;padding:0;font-weight:600;font-size:inherit}.local-filters,.series-controls{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-bottom:16px}.local-filters label{font-size:12px;color:#64748b}.local-filters select{margin-left:8px;max-width:220px}.series-controls{font-size:12px}.series-controls label{display:flex;gap:6px;align-items:center}.workbench aside strong span{display:block;font-size:12px;font-weight:400}.source-label{font-size:11px;line-height:1.9;opacity:.65;margin-top:45px}
2
+ .cockpit{display:grid;grid-template-columns:208px minmax(0,1fr);min-height:100vh;background:#f3f6f3;color:#253f33}.cockpit aside{background:#123d2c;color:#e0ece4;padding:38px 24px;position:sticky;top:0;height:100vh;min-height:0;display:flex;flex-direction:column}.cockpit aside strong{font-size:27px;letter-spacing:-1px}.cockpit aside strong span{display:block;font-size:11px;letter-spacing:3px;font-weight:400;margin-top:3px;color:#b1c6b8}.cockpit aside nav{margin-top:48px}.cockpit aside a{display:block;font-size:13px;margin:8px -8px;padding:12px 16px;color:#c5d5ca;border-radius:8px;transition:background .15s}.cockpit aside a:hover{background:#ffffff10;color:white}.cockpit aside a:first-child{background:#ffffff13;color:#fff}.cockpit .source-label{margin-top:auto;padding-top:40px}.cockpit main{width:100%;max-width:1640px;padding:38px 40px}.cockpit header{padding-bottom:22px;border-bottom:1px solid #dfe8df;margin-bottom:26px}.cockpit h1{font-size:29px;letter-spacing:-.8px;margin:8px 0}.cockpit h2{font-size:15px;font-weight:650}.cockpit .eyebrow{font-size:10px;color:#659476;letter-spacing:2.5px}.cockpit .muted{font-size:12px;color:#839186}.cockpit a{color:#35775a}.cockpit button:hover{border-color:#197a58}.cockpit .header-actions>button{background:#197a58;border-color:#197a58;color:white;padding:10px 18px}.cockpit .grid{gap:20px}.cockpit .panel{border:1px solid #e0e8df;border-radius:14px;padding:24px;box-shadow:0 3px 12px #193f2103}.cockpit .kpi-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px}.cockpit .widget-kpi{position:relative;overflow:hidden}.cockpit .widget-kpi:before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:#448b64}.cockpit .widget-kpi:nth-child(2):before{background:#588db2}.cockpit .widget-kpi:nth-child(3):before{background:#d2ad60}.cockpit .widget-kpi:nth-child(4):before{background:#8f7db0}.cockpit .widget-kpi .panel-heading{display:block}.cockpit .widget-kpi .component-status{font-size:9px}.cockpit .kpi{font-size:clamp(22px,2.3vw,36px);color:#24533b;font-variant-numeric:tabular-nums;overflow-wrap:anywhere;margin:15px 0 6px}.cockpit .widget-kpi .result-range{font-size:9px}.cockpit .filter-card{border-color:#dfe8df;border-radius:14px}.cockpit .filter-symbol{color:#377d57;background:#edf5ef}.cockpit .filter-heading h2,.cockpit .filter-label-row label{color:#35533f}.cockpit .query-status{margin:18px 0;color:#6a8976;font-size:11px}.cockpit footer{font-size:11px;color:#8b9b8f}.cockpit .notice{border-radius:5px;font-size:11px}.cockpit .chart{height:290px}.cockpit th{background:#f5f8f4;color:#54725d}.cockpit tbody tr:hover{background:#f5f8f4}.cockpit .table-tools{font-size:11px;flex-wrap:wrap}.cockpit .table-tools input{max-width:220px}.cockpit .pager{font-size:12px}.cockpit .data-notes{color:#6d8171}
3
+ @media(min-width:1500px){.cockpit main{padding:42px 54px}}@media(max-width:1150px){.cockpit{grid-template-columns:170px minmax(0,1fr)}.cockpit main{padding:28px 24px}.cockpit .kpi-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-panel{grid-template-columns:1fr;gap:8px}}@media(max-width:800px){.cockpit{grid-template-columns:1fr}.cockpit aside{display:none}.cockpit main{padding:24px 18px}.cockpit .grid{grid-template-columns:1fr}.cockpit header{align-items:flex-start;gap:20px;flex-direction:column}.cockpit h1{font-size:25px}.cockpit .filter-card{padding:20px 18px}.insight-panel ul{grid-template-columns:1fr}.cockpit .panel{padding:20px}.header-actions{font-size:12px}.local-filters{align-items:stretch;flex-direction:column}.cockpit .kpi-grid{gap:12px}.cockpit .kpi-grid .panel{padding:17px 14px}.cockpit .kpi{font-size:24px}.table-tools input{max-width:180px}.panel-heading{flex-wrap:wrap}.component-status{white-space:normal}}@media(prefers-reduced-motion:reduce){.cockpit *{transition:none!important;scroll-behavior:auto!important}}
@@ -1,31 +1,40 @@
1
- import {useEffect, useRef, useState} from 'react';
2
- import * as echarts from 'echarts/core';
3
- import {BarChart, LineChart, PieChart} from 'echarts/charts';
4
- import {GridComponent, TooltipComponent, LegendComponent} from 'echarts/components';
5
- import {CanvasRenderer} from 'echarts/renderers';
6
- import type {Dataset, WidgetBinding} from '../finebi/types';
7
- import {chartValues} from '../services/view-data';
8
- echarts.use([BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
9
- export function Chart({dataset, binding}: {dataset: Dataset; binding: WidgetBinding}) {
10
- const container = useRef<HTMLDivElement>(null);
11
- const [order, setOrder] = useState('source');
12
- const values = chartValues(dataset, binding);
13
- useEffect(() => {
14
- if (!container.current) return;
15
- const chart = echarts.init(container.current);
16
- const data = [...values];
17
- if (order !== 'source') data.sort((a, b) => a.value === null ? 1 : b.value === null ? -1 : (a.value - b.value) * (order === 'asc' ? 1 : -1));
18
- chart.setOption({
19
- color: ['#2563eb','#14b8a6','#f59e0b','#8b5cf6','#ec4899'],
20
- tooltip: {trigger: binding.type === 'pie' ? 'item' : 'axis', renderMode: 'richText'},
21
- legend: binding.type === 'pie' ? {bottom: 0} : undefined,
22
- grid: {left: 65, right: 24, top: 24, bottom: 65},
23
- xAxis: binding.type === 'pie' ? undefined : {type: 'category', data: data.map(d => d.name), axisLabel: {rotate: data.length > 8 ? 30 : 0}},
24
- yAxis: binding.type === 'pie' ? undefined : {type: 'value'},
25
- series: [{type: binding.type, data: binding.type === 'pie' ? data : data.map(d => d.value), radius: binding.type === 'pie' ? ['42%','67%'] : undefined}],
26
- });
27
- const observer = new ResizeObserver(() => chart.resize()); observer.observe(container.current);
28
- return () => {observer.disconnect(); chart.dispose();};
29
- }, [dataset, binding, order]);
30
- return <><label className="chart-sort">显示顺序 <select aria-label={`${binding.name}显示顺序`} value={order} onChange={e => setOrder(e.target.value)}><option value="source">查询顺序</option><option value="desc">已返回值降序</option><option value="asc">已返回值升序</option></select></label><div className="chart" ref={container} role="img" aria-label={binding.name}/><details><summary>查看图表数据</summary><table><tbody>{values.map((v,i) => <tr key={i}><td>{v.name}</td><td>{v.value ?? '不可用'}</td></tr>)}</tbody></table></details></>;
31
- }
1
+ import {useEffect, useRef, useState} from 'react';
2
+ import * as echarts from 'echarts/core';
3
+ import {TooltipComponent} from 'echarts/components';
4
+ import {CanvasRenderer} from 'echarts/renderers';
5
+ import type {Dataset, WidgetBinding} from '../finebi/types';
6
+ import {chartValues,displayCell} from '../services/view-data';
7
+ import {formatCategory} from '../services/presentation';
8
+ echarts.use([TooltipComponent, CanvasRenderer]);
9
+ export function Chart({dataset, binding}: {dataset: Dataset; binding: WidgetBinding}) {
10
+ const container = useRef<HTMLDivElement>(null);
11
+ const [order, setOrder] = useState('source');
12
+ const [metric,setMetric]=useState(0),[hidden,setHidden]=useState<string[]>([]);
13
+ const series=binding.series??[{fieldId:binding.valueFieldId!,label:binding.name}];
14
+ const all=series.map(s=>({...s,values:chartValues(dataset,{categoryFieldId:binding.categoryFieldId,valueFieldId:s.fieldId})}));
15
+ const active=binding.type==='bar'?[all[metric]??all[0]]:all.filter(s=>!hidden.includes(s.fieldId));
16
+ const values=all[0].values;
17
+ useEffect(() => {
18
+ if (!container.current) return;
19
+ const chart = echarts.init(container.current);
20
+ const style=getComputedStyle(container.current);
21
+ const palette=['#197a58','#4988bd','#d6aa4e','#8b75b5','#ec4899'].map((color,i)=>style.getPropertyValue(`--chart-${i+1}`).trim()||color);
22
+ const categoryIndex=dataset.columns.findIndex(c=>c.fieldId===binding.categoryFieldId);
23
+ const categoryNames=dataset.rows.map(row=>formatCategory(row.cells[categoryIndex],binding));
24
+ const indices=values.map((_,i)=>i),ordered=active[0]?.values??values;
25
+ if(order!=='source')indices.sort((i,j)=>ordered[i].value===null?1:ordered[j].value===null?-1:(ordered[i].value!-ordered[j].value!)*(order==='asc'?1:-1));
26
+ chart.setOption({
27
+ color: palette,
28
+ tooltip: {trigger: binding.type === 'pie' ? 'item' : 'axis', renderMode: 'richText'},
29
+ ...(binding.type === 'pie' ? {legend:{bottom:0}} : {
30
+ grid:{left:24,right:24,top:24,bottom:24,containLabel:true},
31
+ xAxis:{type:'category',data:indices.map(i=>categoryNames[i]),axisLabel:{rotate:values.length>8?30:0}},
32
+ yAxis:{type:'value'},
33
+ }),
34
+ series: active.map(s=>({name:s.label,type:binding.type,connectNulls:false,data:indices.map(i=>binding.type==='pie'?s.values[i]:s.values[i].value),radius:binding.type==='pie'?['42%','67%']:undefined})),
35
+ });
36
+ const observer = new ResizeObserver(() => chart.resize()); observer.observe(container.current);
37
+ return () => {observer.disconnect(); chart.dispose();};
38
+ }, [dataset, binding, order,metric,hidden]);
39
+ return <>{all.length>1&&<div className="series-controls">{binding.type==='bar'?<label>显示指标 <select aria-label={`${binding.name}指标`} value={metric} onChange={e=>setMetric(Number(e.target.value))}>{all.map((s,i)=><option key={s.fieldId} value={i}>{s.label}</option>)}</select></label>:all.map(s=><label key={s.fieldId}><input type="checkbox" checked={!hidden.includes(s.fieldId)} onChange={e=>setHidden(old=>e.target.checked?old.filter(id=>id!==s.fieldId):[...old,s.fieldId])}/>{s.label}</label>)}</div>}<label className="chart-sort">显示顺序 <select aria-label={`${binding.name}显示顺序`} value={order} onChange={e => setOrder(e.target.value)}><option value="source">查询顺序</option><option value="desc">已返回值降序</option><option value="asc">已返回值升序</option></select></label><div className="chart" ref={container} role="img" aria-label={binding.name}/><details><summary>查看图表原始显示值(已加载结果)</summary><div className="table-scroll"><table><thead><tr>{dataset.columns.map(c=><th key={c.key}>{c.name}</th>)}</tr></thead><tbody>{dataset.rows.map((row,i)=><tr key={i}>{row.cells.map((cell,j)=><td key={j}>{displayCell(cell)}</td>)}</tr>)}</tbody></table></div></details></>;
40
+ }
@@ -1,14 +1,21 @@
1
1
  import {useState} from 'react';
2
- import type {Dataset} from '../finebi/types';
2
+ import type {Dataset,WidgetBinding} from '../finebi/types';
3
3
  import {displayCell} from '../services/view-data';
4
- export function DataTable({dataset, pageSize}: {dataset: Dataset; pageSize: number}) {
4
+ import {fieldIndex,tableCsv,tableDataset,tableRows} from '../services/table-data';
5
+ export function DataTable({dataset, pageSize,binding}: {dataset: Dataset; pageSize: number;binding:WidgetBinding}) {
5
6
  const [search, setSearch] = useState('');
6
7
  const [page, setPage] = useState(0);
7
- const rows = dataset.rows.filter(row => row.cells.some(c => displayCell(c).toLocaleLowerCase().includes(search.toLocaleLowerCase())));
8
+ const [sort,setSort]=useState<{fieldId:string;direction:'asc'|'desc'}>();
9
+ const [filters,setFilters]=useState<Record<string,string>>({});
10
+ const visible=tableDataset(dataset,binding);
11
+ const rows=tableRows(dataset,{search,sort,filters});
12
+ const shown=tableDataset({...dataset,rows},binding).rows;
8
13
  const pages = Math.max(1, Math.ceil(rows.length / pageSize));
9
14
  const current = Math.min(page, pages - 1);
10
- return <><div className="table-tools"><label>搜索已加载数据 <input placeholder="搜索已加载数据" value={search} onChange={e => {setSearch(e.target.value);setPage(0);}}/></label><span>已加载 {dataset.returnedRowCount} 行 · 匹配 {rows.length} 行</span></div>
11
- <div className="table-scroll"><table><thead><tr><th>序号</th>{dataset.columns.map(c => <th key={c.key}>{c.name}</th>)}</tr></thead><tbody>{rows.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={dataset.columns[j].key}>{displayCell(c)}</td>)}</tr>)}</tbody></table></div>
15
+ const download=()=>{const url=URL.createObjectURL(new Blob([tableCsv(visible,shown)],{type:'text/csv;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download='loaded-results.csv';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
16
+ return <><div className="table-tools"><label>搜索已加载数据 <input placeholder="搜索已加载数据" value={search} onChange={e => {setSearch(e.target.value);setPage(0);}}/></label><span>已加载 {dataset.returnedRowCount} 行 · 匹配 {rows.length} 行</span><button onClick={download}>导出匹配结果 CSV</button></div>
17
+ {(binding.localFilterFieldIds?.length??0)>0&&<div className="local-filters">{binding.localFilterFieldIds!.map(id=>{const index=fieldIndex(dataset,id);return <label key={id}>{dataset.columns[index].name}(已加载值)<select value={filters[id]??''} onChange={e=>{setFilters(old=>({...old,[id]:e.target.value}));setPage(0);}}><option value="">全部已加载值</option>{[...new Set(dataset.rows.map(row=>displayCell(row.cells[index])))].filter(Boolean).map(value=><option key={value}>{value}</option>)}</select></label>;})}</div>}
18
+ <div className="table-scroll"><table><thead><tr><th>序号</th>{visible.columns.map(c => <th key={c.key} aria-sort={sort?.fieldId===c.fieldId?(sort.direction==='asc'?'ascending':'descending'):'none'}><button className="sort-column" onClick={()=>{setSort({fieldId:c.fieldId,direction:sort?.fieldId===c.fieldId&&sort.direction==='asc'?'desc':'asc'});setPage(0);}}>{c.name} {sort?.fieldId===c.fieldId?(sort.direction==='asc'?'↑':'↓'):'↕'}</button></th>)}<th>行类型</th></tr></thead><tbody>{shown.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={visible.columns[j].key}>{displayCell(c)}</td>)}<td>{{DATA:'数据',SUBTOTAL:'小计',GRAND_TOTAL:'总计',UNKNOWN:'UNKNOWN'}[row.kind]}</td></tr>)}</tbody></table></div>
12
19
  {!rows.length && <p className="empty">没有匹配的数据</p>}
13
20
  <nav className="pager" aria-label="已加载数据分页"><button disabled={current===0} onClick={()=>setPage(current-1)}>上一页</button><span>{current+1} / {pages}</span><button disabled={current===pages-1} onClick={()=>setPage(current+1)}>下一页</button></nav></>;
14
21
  }
@@ -1,13 +1,18 @@
1
- import {useId, useState} from 'react';
2
- import {Alert, Button, Checkbox, ConfigProvider, DatePicker, Input, Select} from 'antd';
1
+ import {lazy, Suspense, useId, useState} from 'react';
2
+ import Alert from 'antd/es/alert';
3
+ import Button from 'antd/es/button';
4
+ import Checkbox from 'antd/es/checkbox';
5
+ import ConfigProvider from 'antd/es/config-provider';
6
+ import Input from 'antd/es/input';
3
7
  import zhCN from 'antd/locale/zh_CN';
4
- import {calendarConfig, calendarValue} from './calendar';
5
8
  import {encodeFilters} from '../finebi/filters.js';
6
9
  import type {FilterBinding, FilterState, FilterValue} from '../finebi/types';
10
+ import {MonthPicker} from './MonthPicker';
7
11
 
8
- const CalendarPicker = DatePicker.generatePicker<Date>(calendarConfig);
12
+ const RangeFilter=lazy(()=>import('./RangeFilter'));
13
+ const OptionFilter=lazy(()=>import('./OptionFilter'));
9
14
 
10
- export function FilterBar({bindings,onApply}: {bindings:FilterBinding[];onApply:(state:FilterState)=>void}) {
15
+ export function FilterBar({bindings,onApply,cockpit=false,scope,compact=false}: {bindings:FilterBinding[];onApply:(state:FilterState)=>void;cockpit?:boolean;scope?:Record<string,string>;compact?:boolean}) {
11
16
  const id=useId();
12
17
  const [draft,setDraft]=useState<FilterState>({});
13
18
  const [applied,setApplied]=useState<FilterState>({});
@@ -18,7 +23,7 @@ export function FilterBar({bindings,onApply}: {bindings:FilterBinding[];onApply:
18
23
  catch(e){setError((e as Error).message);}
19
24
  };
20
25
  const dirty=JSON.stringify(draft)!==JSON.stringify(applied);
21
- return <ConfigProvider locale={zhCN} theme={{token:{colorPrimary:'#2563eb',colorText:'#253858',colorBorder:'#dce4ef',borderRadius:10,controlHeightLG:46,fontFamily:'Inter,"Microsoft YaHei",system-ui,sans-serif'},components:{Select:{optionSelectedBg:'#edf4ff'},DatePicker:{cellActiveWithRangeBg:'#edf4ff'}}}}>
26
+ return <ConfigProvider locale={zhCN} theme={{token:{colorPrimary:cockpit?'#197a58':'#2563eb',colorText:'#253858',colorBorder:'#dce4ef',borderRadius:10,controlHeightLG:46,fontFamily:'Inter,"Microsoft YaHei",system-ui,sans-serif'},components:{Select:{optionSelectedBg:cockpit?'#eaf6ef':'#edf4ff'},DatePicker:{cellActiveWithRangeBg:cockpit?'#eaf6ef':'#edf4ff'}}}}>
22
27
  <form className="filter-card" onSubmit={e=>{e.preventDefault();apply(draft);}}>
23
28
  <div className="filter-heading"><div className="filter-heading-title"><span className="filter-symbol" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"><path d="M4 7h16M7 12h10M10 17h4"/></svg></span><div><h2>筛选条件</h2><p>选择筛选条件,查看对应的数据</p></div></div><span className={`filter-status ${dirty?'pending':''}`}>{dirty?'待应用':'当前条件已应用'}</span></div>
24
29
  <div className="filter-fields">{bindings.map((binding,index)=>{
@@ -26,13 +31,14 @@ export function FilterBar({bindings,onApply}: {bindings:FilterBinding[];onApply:
26
31
  const isRange=type==='date-range'||type==='month-range';
27
32
  const interval=value && typeof value==='object' && !Array.isArray(value)?value:{start:'',end:''};
28
33
  const fieldId=`${id}-filter-${index}`;
29
- const updateRange=(_dates:unknown,strings:[string,string]|null)=>update(binding.name,{start:strings?.[0]||'',end:strings?.[1]||''});
30
34
  return <div className={`filter-field ${isRange?'date-field':''}`} key={binding.name}>
31
35
  <div className="filter-label-row"><label htmlFor={isRange?fieldId+'-start':fieldId}>{binding.label}<span>{type==='multi-select'?'可多选':type==='month-range'?'按起止月份筛选':isRange?'按起止日期筛选':''}</span></label><button className="field-reset" type="button" aria-label={`恢复${binding.label}默认`} onClick={()=>update(binding.name,'')}>重置</button></div>
32
- {isRange ? <><label className="sr-only" htmlFor={fieldId+'-start'}>{binding.label}开始日期</label><label className="sr-only" htmlFor={fieldId+'-end'}>{binding.label}结束日期</label><CalendarPicker.RangePicker size="large" className="filter-date-range" id={{start:fieldId+'-start',end:fieldId+'-end'}} picker={type==='month-range'?'month':'date'} format={type==='month-range'?'YYYY-MM':'YYYY-MM-DD'} placeholder={type==='month-range'?['开始月份','结束月份']:['开始日期','结束日期']} allowEmpty={[true,true]} allowClear value={[calendarValue(interval.start),calendarValue(interval.end)]} minDate={calendarValue('0001-01-01')!} maxDate={calendarValue('9999-12-31')!} onCalendarChange={updateRange} onChange={updateRange} onKeyDown={e=>{if(e.key==='Enter')e.preventDefault();}}/></>
33
- : binding.options ? <Select id={fieldId} aria-label={binding.label} size="large" className="filter-select" mode={type==='multi-select'?'multiple':undefined} allowClear showSearch={{optionFilterProp:'label'}} maxTagCount="responsive" placeholder={`选择${binding.label},支持搜索`} options={binding.options} disabled={value===null} value={type==='multi-select'?(Array.isArray(value)?value:[]):text||undefined} onChange={selected=>update(binding.name,selected??'')} notFoundContent={`没有匹配的${binding.label}`}/>
34
- : <Input id={fieldId} aria-label={binding.label} size="large" type={type==='date'?'date':type==='month'?'month':'text'} min={type==='date'?'0001-01-01':type==='month'?'0001-01':undefined} max={type==='date'?'9999-12-31':type==='month'?'9999-12':undefined} inputMode={type==='number'?'decimal':undefined} placeholder={type==='quarter'?'YYYY-Q,例如 2026-3':type==='year'?'YYYY':type==='text'?'输入精确匹配值':undefined} disabled={value===null} value={text} onChange={e=>update(binding.name,e.target.value)}/>}
36
+ {isRange ? <><label className="sr-only" htmlFor={fieldId+'-start'}>{binding.label}开始日期</label><label className="sr-only" htmlFor={fieldId+'-end'}>{binding.label}结束日期</label><Suspense fallback={<span role="status">正在加载日期控件…</span>}><RangeFilter id={fieldId} month={type==='month-range'} value={interval} onChange={value=>update(binding.name,value)}/></Suspense></>
37
+ : binding.options ? <Suspense fallback={<span role="status">正在加载选项…</span>}><OptionFilter id={fieldId} binding={binding} value={value} onChange={value=>update(binding.name,value)}/></Suspense>
38
+ : type==='month'?<MonthPicker id={fieldId} label={binding.label} value={text} onChange={value=>update(binding.name,value)}/>
39
+ : <Input id={fieldId} aria-label={binding.label} size="large" type={type==='date'?'date':'text'} min={type==='date'?'0001-01-01':undefined} max={type==='date'?'9999-12-31':undefined} inputMode={type==='number'?'decimal':undefined} placeholder={type==='quarter'?'YYYY-Q,例如 2026-3':type==='year'?'YYYY':type==='text'?'输入精确匹配值':undefined} disabled={value===null} value={text} onChange={e=>update(binding.name,e.target.value)}/>}
35
40
  <div className="field-hint">{binding.allowNull ? <Checkbox checked={value===null} onChange={e=>update(binding.name,e.target.checked?null:'')}>仅筛选空值</Checkbox> : isRange ? <span>可只填一端;全部留空沿用默认条件</span> : <span>留空沿用默认条件</span>}</div>
41
+ {scope&&(compact?<details className="filter-scope"><summary>查看筛选作用范围</summary>{scope[binding.name]}</details>:<p className="filter-scope">作用范围:{scope[binding.name]}</p>)}
36
42
  </div>;
37
43
  })}</div>
38
44
  <div className="filter-bottom"><p className={dirty?'unsaved-note':''}>{dirty?'条件已修改,点击「应用筛选」更新数据':'未设置的条件沿用看板保存值'}</p><div className="filter-buttons"><Button size="large" onClick={()=>{setDraft({});apply({});}}>恢复全部默认</Button><Button size="large" type="primary" htmlType="submit">应用筛选</Button></div></div>
@@ -0,0 +1,17 @@
1
+ import type {AppConfig} from '../finebi/types';
2
+ import type {WidgetState} from '../services/widget-queries';
3
+ import {widgetKey} from '../services/widget-queries';
4
+ import {displayCell,selectDataset} from '../services/view-data';
5
+ export function Insights({config,states}:{config:AppConfig;states:Record<string,WidgetState>}) {
6
+ const notes=config.widgets.map((binding,index)=>{
7
+ const state=states[widgetKey(binding,index)];
8
+ if(!state||state.loading)return `${binding.name}:等待当前查询`;
9
+ if(state.error)return `${binding.name}:查询失败,未参与观察`;
10
+ try {
11
+ const result=state.result!,data=selectDataset(result,binding.datasetId);
12
+ if(binding.type==='kpi'&&data.rows.length===1){const i=data.columns.findIndex(c=>c.fieldId===binding.valueFieldId);if(i<0)throw Error();return `${binding.name}:${displayCell(data.rows[0].cells[i])}(${result.normalization})`;}
13
+ return `${binding.name}:已返回 ${data.returnedRowCount} 行,${result.normalization}${result.range.hasNext?',还有后续数据':''}${data.rows.some(r=>r.kind==='UNKNOWN')?';含 UNKNOWN 行,不自动汇总':''}`;
14
+ } catch{return `${binding.name}:绑定或适配不可用,未参与观察`;}
15
+ });
16
+ return <section className="insight-panel" id="observations"><div><span className="eyebrow">CURRENT QUERY</span><h2>经营观察</h2><p>根据当前组件返回动态生成,范围以各组件提示为准。</p></div><ul>{notes.map((note,i)=><li key={i}>{note}</li>)}</ul></section>;
17
+ }