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.
@@ -1,36 +1,788 @@
1
- import { Card, Divider, Space, Typography } from "antd";
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
- const { Paragraph, Text, Title } = Typography;
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={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
8
- <Card>
9
- <Space direction="vertical" size={12}>
10
- <Title level={3} style={{ margin: 0 }}>
11
- OpenXiangda Workspace
12
- </Title>
13
- <Paragraph style={{ margin: 0 }}>
14
- Add form pages under <Text code>src/forms</Text>, code pages under{" "}
15
- <Text code>src/pages</Text>, and JS_CODE nodes under{" "}
16
- <Text code>src/js-code-nodes</Text>.
17
- </Paragraph>
18
- <Paragraph style={{ margin: 0 }}>
19
- Publish from this workspace with{" "}
20
- <Text code>openxiangda workspace publish --profile &lt;name&gt;</Text>.
21
- </Paragraph>
22
- <Divider style={{ margin: "4px 0" }} />
23
- <Paragraph style={{ margin: 0 }}>
24
- Best-practice templates live under{" "}
25
- <Text code>examples/best-practices</Text>. They are not published by
26
- default. Copy selected modules into <Text code>src</Text> when you
27
- want to use them.
28
- </Paragraph>
29
- <Paragraph style={{ margin: 0 }}>
30
- Validate the cookbook with <Text code>pnpm examples:check</Text>.
31
- </Paragraph>
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
- </Card>
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/&lt;page&gt;</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
  }