openxiangda 1.0.41 → 1.0.43
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 +60 -0
- package/package.json +1 -1
- package/packages/sdk/dist/components/index.cjs +2 -0
- package/packages/sdk/dist/components/index.cjs.map +1 -1
- package/packages/sdk/dist/components/index.d.mts +4 -1071
- package/packages/sdk/dist/components/index.d.ts +4 -1071
- package/packages/sdk/dist/components/index.mjs +2 -0
- package/packages/sdk/dist/components/index.mjs.map +1 -1
- package/packages/sdk/dist/runtime/index.cjs +47838 -93
- package/packages/sdk/dist/runtime/index.cjs.map +1 -1
- package/packages/sdk/dist/runtime/index.css +5313 -0
- package/packages/sdk/dist/runtime/index.css.map +1 -0
- package/packages/sdk/dist/runtime/index.d.mts +165 -1
- package/packages/sdk/dist/runtime/index.d.ts +165 -1
- package/packages/sdk/dist/runtime/index.mjs +47018 -103
- package/packages/sdk/dist/runtime/index.mjs.map +1 -1
- package/packages/sdk/dist/types-U26h_FIh.d.mts +1073 -0
- package/packages/sdk/dist/types-U26h_FIh.d.ts +1073 -0
- package/templates/sy-lowcode-app-workspace/src/dev/App.tsx +780 -28
- package/templates/sy-lowcode-app-workspace/src/runtime/builtin-overrides.tsx +8 -0
- package/templates/sy-lowcode-app-workspace/tsconfig.app.json +17 -1
- package/templates/sy-lowcode-app-workspace/vite.config.ts +81 -28
|
@@ -1,36 +1,788 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ReloadOutlined, UserSwitchOutlined } from "@ant-design/icons";
|
|
2
|
+
import { Alert, Button, Card, Empty, Input, Select, Space, Spin, Tag, Typography, message, Modal } from "antd";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { BuiltinRouteRenderer, resolveBrowserRuntimeRoute } from "openxiangda/runtime";
|
|
5
|
+
import type {
|
|
6
|
+
BrowserRuntimeRouteResolution,
|
|
7
|
+
PageApiResponse,
|
|
8
|
+
PageBinaryResponse,
|
|
9
|
+
PageContext,
|
|
10
|
+
PageRequestOptions,
|
|
11
|
+
} from "openxiangda/runtime";
|
|
12
|
+
import { runtimeRouteOverrides } from "../runtime/builtin-overrides";
|
|
2
13
|
|
|
3
|
-
|
|
14
|
+
type PageConfig = {
|
|
15
|
+
code?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
route?: {
|
|
19
|
+
pathKey?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
entry?: {
|
|
23
|
+
mode?: string;
|
|
24
|
+
hidePlatformNav?: boolean;
|
|
25
|
+
defaultRoute?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
menu?: {
|
|
29
|
+
name?: string;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
};
|
|
32
|
+
publish?: boolean;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type RuntimeModule = {
|
|
37
|
+
mount?: (el: HTMLElement, context: PageContext) => void | Promise<void>;
|
|
38
|
+
update?: (el: HTMLElement, context: PageContext) => void | Promise<void>;
|
|
39
|
+
unmount?: (el?: HTMLElement, context?: PageContext) => void | Promise<void>;
|
|
40
|
+
default?: RuntimeModule;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type PageOption = {
|
|
44
|
+
dirName: string;
|
|
45
|
+
config: PageConfig;
|
|
46
|
+
module?: RuntimeModule;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type UserProfile = {
|
|
50
|
+
id?: string;
|
|
51
|
+
username?: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
tenantId?: string;
|
|
54
|
+
departments?: Array<{ id: string; name: string }>;
|
|
55
|
+
isGuest?: boolean;
|
|
56
|
+
isPlatFormAdmin?: boolean;
|
|
57
|
+
isAppAdmin?: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const configModules = (import.meta as any).glob("../pages/*/page.config.ts", {
|
|
61
|
+
eager: true,
|
|
62
|
+
}) as Record<string, { default?: PageConfig } & PageConfig>;
|
|
63
|
+
|
|
64
|
+
const pageModules = (import.meta as any).glob("../pages/*/index.tsx", {
|
|
65
|
+
eager: true,
|
|
66
|
+
}) as Record<string, RuntimeModule>;
|
|
67
|
+
|
|
68
|
+
const servicePrefix = (process.env.APP_SERVICE_PREFIX || "/service").replace(
|
|
69
|
+
/\/+$/,
|
|
70
|
+
"",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const appType =
|
|
74
|
+
process.env.OPENXIANGDA_APP_TYPE ||
|
|
75
|
+
process.env.APP_TYPE ||
|
|
76
|
+
"APP_XXXXXXXXXXXXXXXX";
|
|
77
|
+
|
|
78
|
+
const hasPlatformProxy = Boolean(
|
|
79
|
+
process.env.OPENXIANGDA_BASE_URL || process.env.APP_PLATFORM_URL,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const LOCATION_CHANGE_EVENT = "openxiangda:location-change";
|
|
83
|
+
|
|
84
|
+
const getDirName = (path: string) => path.match(/\/pages\/([^/]+)\//)?.[1] || "";
|
|
85
|
+
|
|
86
|
+
const getPageIdentity = (page: PageOption) =>
|
|
87
|
+
page.config.route?.pathKey || page.config.code || page.dirName;
|
|
88
|
+
|
|
89
|
+
const getLocationKey = () =>
|
|
90
|
+
`${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
91
|
+
|
|
92
|
+
const emitLocationChange = () => {
|
|
93
|
+
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const setBrowserUrl = (
|
|
97
|
+
method: "pushState" | "replaceState",
|
|
98
|
+
url: string,
|
|
99
|
+
) => {
|
|
100
|
+
window.history[method](null, "", url);
|
|
101
|
+
emitLocationChange();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const createSearchParams = (query?: Record<string, unknown>) => {
|
|
105
|
+
const params = new URLSearchParams();
|
|
106
|
+
Object.entries(query || {}).forEach(([key, value]) => {
|
|
107
|
+
if (value === undefined || value === null || value === "") return;
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
value.forEach(item => params.append(key, String(item)));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
params.set(key, String(value));
|
|
113
|
+
});
|
|
114
|
+
return params;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const getRouteSegments = (pathname = window.location.pathname) => {
|
|
118
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
119
|
+
return segments[0] === "view" ? segments.slice(1) : segments;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getWorkbenchPageKeyFromPath = (pathname = window.location.pathname) => {
|
|
123
|
+
const segments = getRouteSegments(pathname);
|
|
124
|
+
if (segments[0] === appType && segments[1] === "workbench") {
|
|
125
|
+
return segments[2] || "";
|
|
126
|
+
}
|
|
127
|
+
return "";
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const normalizeModule = (moduleValue?: RuntimeModule): RuntimeModule => {
|
|
131
|
+
if (moduleValue?.default && (moduleValue.default.mount || moduleValue.default.unmount)) {
|
|
132
|
+
return {
|
|
133
|
+
...moduleValue.default,
|
|
134
|
+
...moduleValue,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return moduleValue || {};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const appendQuery = (url: string, query?: string) => {
|
|
141
|
+
if (!query) return url;
|
|
142
|
+
return `${url}${url.includes("?") ? "&" : "?"}${query}`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const normalizeQueryString = (query?: unknown) => {
|
|
146
|
+
if (!query) return undefined;
|
|
147
|
+
if (typeof query === "string") return query;
|
|
148
|
+
if (query instanceof URLSearchParams) return query.toString();
|
|
149
|
+
if (typeof query === "object") {
|
|
150
|
+
const params = createSearchParams(query as Record<string, unknown>);
|
|
151
|
+
return params.toString() || undefined;
|
|
152
|
+
}
|
|
153
|
+
return String(query);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const joinServicePath = (path: string) => {
|
|
157
|
+
if (/^https?:\/\//i.test(path)) return path;
|
|
158
|
+
if (path.startsWith(servicePrefix || "/service")) return path;
|
|
159
|
+
return `${servicePrefix}${path.startsWith("/") ? path : `/${path}`}`;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const normalizeMethod = (method?: string) => {
|
|
163
|
+
const value = String(method || "get").toUpperCase();
|
|
164
|
+
return ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(value)
|
|
165
|
+
? value
|
|
166
|
+
: "GET";
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const parseJsonResponse = async <T,>(response: Response): Promise<PageApiResponse<T>> => {
|
|
170
|
+
const payload = await response.json().catch(() => null);
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(payload?.message || response.statusText || "请求失败");
|
|
173
|
+
}
|
|
174
|
+
if (payload && typeof payload === "object" && "code" in payload) {
|
|
175
|
+
return {
|
|
176
|
+
code: Number(payload.code || response.status),
|
|
177
|
+
success: payload.code === 200 || payload.success === true,
|
|
178
|
+
message: payload.message,
|
|
179
|
+
result: payload.data ?? payload.result ?? null,
|
|
180
|
+
data: payload.data,
|
|
181
|
+
raw: payload,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
code: response.status,
|
|
186
|
+
success: response.ok,
|
|
187
|
+
message: response.statusText,
|
|
188
|
+
result: payload,
|
|
189
|
+
data: payload,
|
|
190
|
+
raw: payload,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const requestJson = async <T,>(options: PageRequestOptions): Promise<PageApiResponse<T>> => {
|
|
195
|
+
const url = appendQuery(joinServicePath(options.path), normalizeQueryString(options.query));
|
|
196
|
+
const headers = new Headers(options.headers as HeadersInit);
|
|
197
|
+
let body: BodyInit | undefined;
|
|
198
|
+
if (options.body !== undefined) {
|
|
199
|
+
headers.set("Content-Type", headers.get("Content-Type") || "application/json");
|
|
200
|
+
body = JSON.stringify(options.body);
|
|
201
|
+
}
|
|
202
|
+
const response = await fetch(url, {
|
|
203
|
+
method: normalizeMethod(options.method),
|
|
204
|
+
headers,
|
|
205
|
+
body,
|
|
206
|
+
credentials: "include",
|
|
207
|
+
});
|
|
208
|
+
return parseJsonResponse<T>(response);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const requestBinary = async (
|
|
212
|
+
options: PageRequestOptions,
|
|
213
|
+
): Promise<PageBinaryResponse> => {
|
|
214
|
+
const url = appendQuery(joinServicePath(options.path), normalizeQueryString(options.query));
|
|
215
|
+
const response = await fetch(url, {
|
|
216
|
+
method: normalizeMethod(options.method),
|
|
217
|
+
headers: options.headers as HeadersInit,
|
|
218
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
219
|
+
credentials: "include",
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(response.statusText || "下载失败");
|
|
223
|
+
}
|
|
224
|
+
const headers = Object.fromEntries(response.headers.entries());
|
|
225
|
+
return {
|
|
226
|
+
blob: await response.blob(),
|
|
227
|
+
contentType: response.headers.get("content-type") || undefined,
|
|
228
|
+
fileName: response.headers.get("content-disposition") || undefined,
|
|
229
|
+
headers,
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const buildRouteInfo = (selected?: PageOption) => {
|
|
234
|
+
const url = new URL(window.location.href);
|
|
235
|
+
const query: Record<string, string | string[]> = {};
|
|
236
|
+
url.searchParams.forEach((value, key) => {
|
|
237
|
+
const current = query[key];
|
|
238
|
+
if (current === undefined) {
|
|
239
|
+
query[key] = value;
|
|
240
|
+
} else if (Array.isArray(current)) {
|
|
241
|
+
query[key] = [...current, value];
|
|
242
|
+
} else {
|
|
243
|
+
query[key] = [current, value];
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
pathname: url.pathname,
|
|
248
|
+
fullPath: `${url.pathname}${url.search}${url.hash}`,
|
|
249
|
+
params: {
|
|
250
|
+
appType,
|
|
251
|
+
pageKey: selected?.config.route?.pathKey || selected?.config.code || selected?.dirName,
|
|
252
|
+
},
|
|
253
|
+
query,
|
|
254
|
+
hash: url.hash,
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const createFallbackUser = (profile?: UserProfile) => ({
|
|
259
|
+
id: profile?.id || "local-dev-user",
|
|
260
|
+
name: profile?.name || profile?.username || "Local Developer",
|
|
261
|
+
username: profile?.username || profile?.name || "local-dev-user",
|
|
262
|
+
tenantId: profile?.tenantId || "",
|
|
263
|
+
departments: profile?.departments || [],
|
|
264
|
+
isGuest: profile?.isGuest || false,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const createPageContext = (
|
|
268
|
+
selected: PageOption,
|
|
269
|
+
profile: UserProfile | null,
|
|
270
|
+
): PageContext => ({
|
|
271
|
+
protocolVersion: "1.0",
|
|
272
|
+
app: {
|
|
273
|
+
appType,
|
|
274
|
+
name: appType,
|
|
275
|
+
} as any,
|
|
276
|
+
page: {
|
|
277
|
+
id: selected.config.code || selected.dirName,
|
|
278
|
+
code: selected.config.code || selected.dirName,
|
|
279
|
+
name: selected.config.name || selected.dirName,
|
|
280
|
+
type: "custom",
|
|
281
|
+
rendererType: "custom_bundle",
|
|
282
|
+
routeKey:
|
|
283
|
+
selected.config.route?.pathKey || selected.config.code || selected.dirName,
|
|
284
|
+
legacyFormUuid: selected.config.code || selected.dirName,
|
|
285
|
+
status: "local",
|
|
286
|
+
props: {},
|
|
287
|
+
route: selected.config.route || {},
|
|
288
|
+
entry: selected.config.entry || {},
|
|
289
|
+
dataSources: [],
|
|
290
|
+
capabilities: {},
|
|
291
|
+
},
|
|
292
|
+
user: createFallbackUser(profile || undefined) as any,
|
|
293
|
+
route: buildRouteInfo(selected),
|
|
294
|
+
env: {
|
|
295
|
+
mode: "local",
|
|
296
|
+
servicePrefix,
|
|
297
|
+
},
|
|
298
|
+
permissions: {
|
|
299
|
+
canView: true,
|
|
300
|
+
hasFullAccess: true,
|
|
301
|
+
} as any,
|
|
302
|
+
capabilities: ["navigation", "ui.message", "ui.modal", "transport.request", "transport.download"],
|
|
303
|
+
ui: {
|
|
304
|
+
message: {
|
|
305
|
+
success: (text: string) => message.success(text),
|
|
306
|
+
error: (text: string) => message.error(text),
|
|
307
|
+
warning: (text: string) => message.warning(text),
|
|
308
|
+
info: (text: string) => message.info(text),
|
|
309
|
+
loading: (text: string) => {
|
|
310
|
+
const hide = message.loading(text, 0);
|
|
311
|
+
return () => {
|
|
312
|
+
if (typeof hide === "function") hide();
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
modal: {
|
|
317
|
+
confirm: (input: { title: string; content: string }) =>
|
|
318
|
+
new Promise<boolean>(resolve => {
|
|
319
|
+
Modal.confirm({
|
|
320
|
+
title: input.title,
|
|
321
|
+
content: input.content,
|
|
322
|
+
onOk: () => resolve(true),
|
|
323
|
+
onCancel: () => resolve(false),
|
|
324
|
+
});
|
|
325
|
+
}),
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
navigation: {
|
|
329
|
+
pushPage: (pageKey: string, query?: Record<string, unknown>) => {
|
|
330
|
+
const params = createSearchParams(query);
|
|
331
|
+
setBrowserUrl(
|
|
332
|
+
"pushState",
|
|
333
|
+
`/view/${appType}/workbench/${pageKey}${params.size ? `?${params}` : ""}`,
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
replacePage: (pageKey: string, query?: Record<string, unknown>) => {
|
|
337
|
+
const params = createSearchParams(query);
|
|
338
|
+
setBrowserUrl(
|
|
339
|
+
"replaceState",
|
|
340
|
+
`/view/${appType}/workbench/${pageKey}${params.size ? `?${params}` : ""}`,
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
pushRoute: (route: string) => {
|
|
344
|
+
const url = new URL(window.location.href);
|
|
345
|
+
url.searchParams.set("route", route);
|
|
346
|
+
setBrowserUrl("pushState", `${url.pathname}${url.search}${url.hash}`);
|
|
347
|
+
},
|
|
348
|
+
replaceRoute: (route: string) => {
|
|
349
|
+
const url = new URL(window.location.href);
|
|
350
|
+
url.searchParams.set("route", route);
|
|
351
|
+
setBrowserUrl("replaceState", `${url.pathname}${url.search}${url.hash}`);
|
|
352
|
+
},
|
|
353
|
+
updateQuery: (query: Record<string, unknown>) => {
|
|
354
|
+
const url = new URL(window.location.href);
|
|
355
|
+
Object.entries(query || {}).forEach(([key, value]) => {
|
|
356
|
+
if (value === undefined || value === null || value === "") {
|
|
357
|
+
url.searchParams.delete(key);
|
|
358
|
+
} else {
|
|
359
|
+
url.searchParams.set(key, String(value));
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
setBrowserUrl("replaceState", `${url.pathname}${url.search}${url.hash}`);
|
|
363
|
+
},
|
|
364
|
+
setHash: (hash: string) => {
|
|
365
|
+
window.location.hash = hash ? (hash.startsWith("#") ? hash : `#${hash}`) : "";
|
|
366
|
+
},
|
|
367
|
+
back: () => window.history.back(),
|
|
368
|
+
},
|
|
369
|
+
bridge: {
|
|
370
|
+
invoke: async <T = unknown>(method: string, payload?: unknown): Promise<T> => {
|
|
371
|
+
if (method === "transport.request") {
|
|
372
|
+
return (await requestJson((payload || {}) as PageRequestOptions)) as T;
|
|
373
|
+
}
|
|
374
|
+
if (method === "transport.download") {
|
|
375
|
+
return (await requestBinary((payload || {}) as PageRequestOptions)) as T;
|
|
376
|
+
}
|
|
377
|
+
throw new Error(`不支持的 bridge 方法: ${method}`);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
sdk: {
|
|
381
|
+
packageName: "openxiangda",
|
|
382
|
+
supportedBridgeMethods: ["transport.request", "transport.download"],
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const discoverPages = (): PageOption[] => {
|
|
387
|
+
return Object.entries(configModules)
|
|
388
|
+
.map(([configPath, moduleValue]) => {
|
|
389
|
+
const dirName = getDirName(configPath);
|
|
390
|
+
const modulePath = `../pages/${dirName}/index.tsx`;
|
|
391
|
+
const config = moduleValue.default || moduleValue;
|
|
392
|
+
return {
|
|
393
|
+
dirName,
|
|
394
|
+
config,
|
|
395
|
+
module: normalizeModule(pageModules[modulePath]),
|
|
396
|
+
};
|
|
397
|
+
})
|
|
398
|
+
.filter(item => item.dirName && item.config.publish !== false)
|
|
399
|
+
.sort((left, right) =>
|
|
400
|
+
String(left.config.name || left.dirName).localeCompare(
|
|
401
|
+
String(right.config.name || right.dirName),
|
|
402
|
+
"zh-Hans-CN",
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const findPageByIdentity = (pages: PageOption[], identity: string) =>
|
|
408
|
+
pages.find(
|
|
409
|
+
page =>
|
|
410
|
+
page.config.code === identity ||
|
|
411
|
+
page.config.route?.pathKey === identity ||
|
|
412
|
+
page.dirName === identity,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const RuntimeRouteNotice = ({
|
|
416
|
+
route,
|
|
417
|
+
routeError,
|
|
418
|
+
resolving,
|
|
419
|
+
}: {
|
|
420
|
+
route: BrowserRuntimeRouteResolution | null;
|
|
421
|
+
routeError: string;
|
|
422
|
+
resolving: boolean;
|
|
423
|
+
}) => {
|
|
424
|
+
if (resolving) {
|
|
425
|
+
return (
|
|
426
|
+
<Alert
|
|
427
|
+
type="info"
|
|
428
|
+
showIcon
|
|
429
|
+
message="正在解析 /view 路由..."
|
|
430
|
+
style={{ margin: 16 }}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (routeError) {
|
|
436
|
+
return (
|
|
437
|
+
<Alert
|
|
438
|
+
type="warning"
|
|
439
|
+
showIcon
|
|
440
|
+
message="后端 runtime route resolver 不可用"
|
|
441
|
+
description={routeError}
|
|
442
|
+
style={{ margin: 16 }}
|
|
443
|
+
/>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!route) return null;
|
|
448
|
+
|
|
449
|
+
if (route.mode === "builtin-route") {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (route.mode === "legacy-fallback") {
|
|
454
|
+
return (
|
|
455
|
+
<Alert
|
|
456
|
+
type="info"
|
|
457
|
+
showIcon
|
|
458
|
+
message="后端建议走旧 view fallback"
|
|
459
|
+
description={route.fallback?.reason || route.message || "legacy-fallback"}
|
|
460
|
+
style={{ margin: 16 }}
|
|
461
|
+
/>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (route.mode === "not-found") {
|
|
466
|
+
return (
|
|
467
|
+
<Alert
|
|
468
|
+
type="warning"
|
|
469
|
+
showIcon
|
|
470
|
+
message="后端未识别该 runtime 路由"
|
|
471
|
+
description={route.message || route.path}
|
|
472
|
+
style={{ margin: 16 }}
|
|
473
|
+
/>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
};
|
|
4
479
|
|
|
5
480
|
export default function App() {
|
|
481
|
+
const pages = useMemo(discoverPages, []);
|
|
482
|
+
const [selectedCode, setSelectedCode] = useState(
|
|
483
|
+
() => getWorkbenchPageKeyFromPath() || (pages[0] ? getPageIdentity(pages[0]) : ""),
|
|
484
|
+
);
|
|
485
|
+
const [profile, setProfile] = useState<UserProfile | null>(null);
|
|
486
|
+
const [profileLoading, setProfileLoading] = useState(false);
|
|
487
|
+
const [targetUserId, setTargetUserId] = useState("");
|
|
488
|
+
const [mountError, setMountError] = useState("");
|
|
489
|
+
const [mountSeed, setMountSeed] = useState(0);
|
|
490
|
+
const [locationKey, setLocationKey] = useState(() => getLocationKey());
|
|
491
|
+
const [routeResolution, setRouteResolution] =
|
|
492
|
+
useState<BrowserRuntimeRouteResolution | null>(null);
|
|
493
|
+
const [routeResolving, setRouteResolving] = useState(false);
|
|
494
|
+
const [routeError, setRouteError] = useState("");
|
|
495
|
+
const mountRef = useRef<HTMLDivElement | null>(null);
|
|
496
|
+
|
|
497
|
+
const selected = useMemo(
|
|
498
|
+
() => findPageByIdentity(pages, selectedCode),
|
|
499
|
+
[pages, selectedCode],
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const shouldBlockLocalMount =
|
|
503
|
+
routeResolution?.mode === "builtin-route" ||
|
|
504
|
+
routeResolution?.kind === "work-center" ||
|
|
505
|
+
(routeResolution?.mode === "not-found" &&
|
|
506
|
+
routeResolution.params?.routeAppType !== undefined);
|
|
507
|
+
const shouldRenderBuiltinRoute = routeResolution?.mode === "builtin-route";
|
|
508
|
+
|
|
509
|
+
const handleSelectPage = (value: string) => {
|
|
510
|
+
setSelectedCode(value);
|
|
511
|
+
setBrowserUrl("pushState", `/view/${appType}/workbench/${value}`);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const loadProfile = async () => {
|
|
515
|
+
setProfileLoading(true);
|
|
516
|
+
try {
|
|
517
|
+
const response = await requestJson<UserProfile>({
|
|
518
|
+
path: "/api/auth/profile",
|
|
519
|
+
method: "post",
|
|
520
|
+
body: { appType },
|
|
521
|
+
});
|
|
522
|
+
setProfile((response.result || response.data || null) as UserProfile | null);
|
|
523
|
+
if (response.success) {
|
|
524
|
+
message.success("已读取当前登录用户");
|
|
525
|
+
}
|
|
526
|
+
} catch (error: any) {
|
|
527
|
+
setProfile(null);
|
|
528
|
+
message.warning(error?.message || "未读取到当前登录用户");
|
|
529
|
+
} finally {
|
|
530
|
+
setProfileLoading(false);
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const loginAsTargetUser = async () => {
|
|
535
|
+
const normalizedTarget = targetUserId.trim();
|
|
536
|
+
if (!normalizedTarget) {
|
|
537
|
+
message.warning("请输入目标用户 ID");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const redirectPageKey =
|
|
541
|
+
(selected ? getPageIdentity(selected) : selectedCode) ||
|
|
542
|
+
getWorkbenchPageKeyFromPath();
|
|
543
|
+
const redirectUri = `${window.location.origin}/view/${appType}/workbench/${
|
|
544
|
+
redirectPageKey || ""
|
|
545
|
+
}`;
|
|
546
|
+
const response = await requestJson<{ loginUrl: string; expireIn: number }>({
|
|
547
|
+
path: `/openxiangda-api/v1/apps/${appType}/verification-login-links`,
|
|
548
|
+
method: "post",
|
|
549
|
+
body: {
|
|
550
|
+
targetUserId: normalizedTarget,
|
|
551
|
+
redirectUri,
|
|
552
|
+
purpose: "workspace-dev-preview",
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
const loginUrl = response.result?.loginUrl || response.data?.loginUrl;
|
|
556
|
+
if (!loginUrl) {
|
|
557
|
+
throw new Error(response.message || "未生成登录链接");
|
|
558
|
+
}
|
|
559
|
+
window.location.href = loginUrl;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
useEffect(() => {
|
|
563
|
+
void loadProfile();
|
|
564
|
+
}, []);
|
|
565
|
+
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
const syncLocation = () => setLocationKey(getLocationKey());
|
|
568
|
+
window.addEventListener("popstate", syncLocation);
|
|
569
|
+
window.addEventListener(LOCATION_CHANGE_EVENT, syncLocation);
|
|
570
|
+
return () => {
|
|
571
|
+
window.removeEventListener("popstate", syncLocation);
|
|
572
|
+
window.removeEventListener(LOCATION_CHANGE_EVENT, syncLocation);
|
|
573
|
+
};
|
|
574
|
+
}, []);
|
|
575
|
+
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
let cancelled = false;
|
|
578
|
+
|
|
579
|
+
const resolveRoute = async () => {
|
|
580
|
+
const pageKey = getWorkbenchPageKeyFromPath();
|
|
581
|
+
if (pageKey) {
|
|
582
|
+
setSelectedCode(pageKey);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!hasPlatformProxy) {
|
|
586
|
+
setRouteResolution(null);
|
|
587
|
+
setRouteError("");
|
|
588
|
+
setRouteResolving(false);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
setRouteResolving(true);
|
|
593
|
+
setRouteError("");
|
|
594
|
+
try {
|
|
595
|
+
const route = await resolveBrowserRuntimeRoute({
|
|
596
|
+
appType,
|
|
597
|
+
path: window.location.pathname,
|
|
598
|
+
search: window.location.search,
|
|
599
|
+
servicePrefix,
|
|
600
|
+
});
|
|
601
|
+
if (cancelled) return;
|
|
602
|
+
setRouteResolution(route);
|
|
603
|
+
const resolvedPageKey = route.params?.pageKey || pageKey;
|
|
604
|
+
if (
|
|
605
|
+
resolvedPageKey &&
|
|
606
|
+
(route.kind === "custom-page" || route.kind === "legacy-workbench")
|
|
607
|
+
) {
|
|
608
|
+
setSelectedCode(resolvedPageKey);
|
|
609
|
+
}
|
|
610
|
+
} catch (error: any) {
|
|
611
|
+
if (!cancelled) {
|
|
612
|
+
setRouteResolution(null);
|
|
613
|
+
setRouteError(error?.message || "解析运行时路由失败");
|
|
614
|
+
}
|
|
615
|
+
} finally {
|
|
616
|
+
if (!cancelled) {
|
|
617
|
+
setRouteResolving(false);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
void resolveRoute();
|
|
623
|
+
|
|
624
|
+
return () => {
|
|
625
|
+
cancelled = true;
|
|
626
|
+
};
|
|
627
|
+
}, [locationKey]);
|
|
628
|
+
|
|
629
|
+
useEffect(() => {
|
|
630
|
+
let cancelled = false;
|
|
631
|
+
let cleanup: (() => Promise<void> | void) | undefined;
|
|
632
|
+
|
|
633
|
+
const mount = async () => {
|
|
634
|
+
setMountError("");
|
|
635
|
+
const host = mountRef.current;
|
|
636
|
+
if (!host) return;
|
|
637
|
+
host.innerHTML = "";
|
|
638
|
+
if (shouldBlockLocalMount) return;
|
|
639
|
+
if (!selected) {
|
|
640
|
+
if (selectedCode) {
|
|
641
|
+
setMountError(`本地 src/pages 中没有找到页面: ${selectedCode}`);
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const moduleValue = selected.module;
|
|
646
|
+
if (!moduleValue?.mount) {
|
|
647
|
+
setMountError(`页面 ${selected.dirName} 缺少 index.tsx runtime export`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const context = createPageContext(selected, profile);
|
|
651
|
+
try {
|
|
652
|
+
await moduleValue.mount(host, context);
|
|
653
|
+
cleanup = async () => {
|
|
654
|
+
await moduleValue.unmount?.(host, context);
|
|
655
|
+
host.innerHTML = "";
|
|
656
|
+
};
|
|
657
|
+
} catch (error: any) {
|
|
658
|
+
if (!cancelled) {
|
|
659
|
+
setMountError(error?.message || "本地页面挂载失败");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
void mount();
|
|
665
|
+
|
|
666
|
+
return () => {
|
|
667
|
+
cancelled = true;
|
|
668
|
+
void cleanup?.();
|
|
669
|
+
};
|
|
670
|
+
}, [selected, profile, mountSeed, selectedCode, shouldBlockLocalMount]);
|
|
671
|
+
|
|
6
672
|
return (
|
|
7
|
-
<main style={{
|
|
8
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
673
|
+
<main style={{ minHeight: "100vh", background: "#f7f8fb" }}>
|
|
674
|
+
<header
|
|
675
|
+
style={{
|
|
676
|
+
display: "flex",
|
|
677
|
+
alignItems: "center",
|
|
678
|
+
justifyContent: "space-between",
|
|
679
|
+
gap: 16,
|
|
680
|
+
padding: "16px 20px",
|
|
681
|
+
borderBottom: "1px solid #e5e7eb",
|
|
682
|
+
background: "#fff",
|
|
683
|
+
position: "sticky",
|
|
684
|
+
top: 0,
|
|
685
|
+
zIndex: 10,
|
|
686
|
+
}}
|
|
687
|
+
>
|
|
688
|
+
<Space size={16} wrap>
|
|
689
|
+
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
690
|
+
OpenXiangda Runtime
|
|
691
|
+
</Typography.Title>
|
|
692
|
+
<Tag color="blue">{appType}</Tag>
|
|
693
|
+
<Select
|
|
694
|
+
style={{ minWidth: 240 }}
|
|
695
|
+
value={selected ? getPageIdentity(selected) : selectedCode || undefined}
|
|
696
|
+
placeholder="选择本地页面"
|
|
697
|
+
onChange={handleSelectPage}
|
|
698
|
+
options={pages.map(page => ({
|
|
699
|
+
value: getPageIdentity(page),
|
|
700
|
+
label: page.config.name || page.config.code || page.dirName,
|
|
701
|
+
}))}
|
|
702
|
+
/>
|
|
703
|
+
<Button
|
|
704
|
+
icon={<ReloadOutlined />}
|
|
705
|
+
onClick={() => setMountSeed(value => value + 1)}
|
|
706
|
+
>
|
|
707
|
+
重新挂载
|
|
708
|
+
</Button>
|
|
709
|
+
</Space>
|
|
710
|
+
|
|
711
|
+
<Space wrap>
|
|
712
|
+
<Button loading={profileLoading} onClick={loadProfile}>
|
|
713
|
+
{profile?.name || profile?.username || "读取登录态"}
|
|
714
|
+
</Button>
|
|
715
|
+
<Input
|
|
716
|
+
style={{ width: 220 }}
|
|
717
|
+
value={targetUserId}
|
|
718
|
+
placeholder="目标用户 ID"
|
|
719
|
+
onChange={event => setTargetUserId(event.target.value)}
|
|
720
|
+
onPressEnter={() => void loginAsTargetUser()}
|
|
721
|
+
/>
|
|
722
|
+
<Button
|
|
723
|
+
type="primary"
|
|
724
|
+
icon={<UserSwitchOutlined />}
|
|
725
|
+
onClick={() => void loginAsTargetUser()}
|
|
726
|
+
>
|
|
727
|
+
以该用户打开
|
|
728
|
+
</Button>
|
|
32
729
|
</Space>
|
|
33
|
-
</
|
|
730
|
+
</header>
|
|
731
|
+
|
|
732
|
+
{!process.env.OPENXIANGDA_BASE_URL && !process.env.APP_PLATFORM_URL ? (
|
|
733
|
+
<Alert
|
|
734
|
+
type="warning"
|
|
735
|
+
showIcon
|
|
736
|
+
message="未配置远端平台地址"
|
|
737
|
+
description="设置 OPENXIANGDA_BASE_URL 或 APP_PLATFORM_URL 后,Vite 会代理 /service 并重写 Cookie,SDK 请求才能访问远端后端。"
|
|
738
|
+
style={{ margin: 16 }}
|
|
739
|
+
/>
|
|
740
|
+
) : null}
|
|
741
|
+
|
|
742
|
+
{mountError ? (
|
|
743
|
+
<Alert type="error" showIcon message={mountError} style={{ margin: 16 }} />
|
|
744
|
+
) : null}
|
|
745
|
+
|
|
746
|
+
<RuntimeRouteNotice
|
|
747
|
+
route={routeResolution}
|
|
748
|
+
routeError={routeError}
|
|
749
|
+
resolving={routeResolving}
|
|
750
|
+
/>
|
|
751
|
+
|
|
752
|
+
{shouldRenderBuiltinRoute ? (
|
|
753
|
+
<BuiltinRouteRenderer
|
|
754
|
+
route={routeResolution}
|
|
755
|
+
appType={appType}
|
|
756
|
+
servicePrefix={servicePrefix}
|
|
757
|
+
overrides={runtimeRouteOverrides}
|
|
758
|
+
style={{ minHeight: "calc(100vh - 73px)" }}
|
|
759
|
+
/>
|
|
760
|
+
) : pages.length === 0 ? (
|
|
761
|
+
<Card style={{ margin: 16 }}>
|
|
762
|
+
<Empty
|
|
763
|
+
description={
|
|
764
|
+
<span>
|
|
765
|
+
在 <Typography.Text code>src/pages/<page></Typography.Text>{" "}
|
|
766
|
+
下添加 <Typography.Text code>index.tsx</Typography.Text>、
|
|
767
|
+
<Typography.Text code>App.tsx</Typography.Text> 和{" "}
|
|
768
|
+
<Typography.Text code>page.config.ts</Typography.Text> 后即可本地预览。
|
|
769
|
+
</span>
|
|
770
|
+
}
|
|
771
|
+
/>
|
|
772
|
+
</Card>
|
|
773
|
+
) : (
|
|
774
|
+
<section style={{ minHeight: "calc(100vh - 73px)" }}>
|
|
775
|
+
{shouldBlockLocalMount ? (
|
|
776
|
+
<div style={{ padding: 16 }}>
|
|
777
|
+
<Empty description="当前路由不挂载本地代码页" />
|
|
778
|
+
</div>
|
|
779
|
+
) : !selected && !selectedCode ? (
|
|
780
|
+
<Spin style={{ margin: 32 }} />
|
|
781
|
+
) : (
|
|
782
|
+
<div ref={mountRef} style={{ minHeight: "calc(100vh - 73px)" }} />
|
|
783
|
+
)}
|
|
784
|
+
</section>
|
|
785
|
+
)}
|
|
34
786
|
</main>
|
|
35
787
|
);
|
|
36
788
|
}
|