@nocobase/portal-template-default 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +22 -3
- package/package.json +4 -2
- package/registry/nocobase-acl/components/role-options.ts +7 -0
- package/registry/nocobase-acl/components/role-switcher.tsx +9 -4
- package/registry/nocobase-acl/demo/boundary-api.tsx +87 -16
- package/registry/nocobase-acl/demo/components.tsx +32 -11
- package/registry/nocobase-acl/demo/index.tsx +45 -14
- package/registry/nocobase-acl/demo/lazy-route.tsx +2 -7
- package/registry/nocobase-acl/demo/prompt-generator.tsx +52 -33
- package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +28 -9
- package/registry/nocobase-acl/extension.tsx +1 -1
- package/registry/nocobase-acl/tests/acl-regression.mjs +7 -6
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +3 -4
- package/registry/nocobase-ai/components/chat/conversation-list.tsx +2 -4
- package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +16 -19
- package/registry/nocobase-ai/components/tools/chart-renderer.tsx +3 -14
- package/registry/nocobase-ai/components/tools/echarts-preview.tsx +2 -5
- package/registry/nocobase-ai/demo/configuration-gate.tsx +9 -12
- package/registry/nocobase-ai/extension.tsx +2 -7
- package/registry/nocobase-i18n/README.md +53 -0
- package/registry/nocobase-i18n/components/index.ts +1 -0
- package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
- package/registry/nocobase-i18n/demo/index.tsx +209 -0
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
- package/registry/nocobase-i18n/extension.tsx +46 -0
- package/registry/nocobase-i18n/i18n-provider.ts +9 -0
- package/registry/nocobase-i18n/index.ts +6 -0
- package/registry/nocobase-i18n/locale-store.ts +134 -0
- package/registry/nocobase-i18n/locales/en-US.ts +89 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
- package/registry/nocobase-i18n/provider.tsx +32 -0
- package/registry/nocobase-i18n/runtime.ts +118 -0
- package/registry/nocobase-i18n/server-resources.ts +184 -0
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
- package/registry.config.json +34 -14
- package/scripts/registry.mjs +3 -0
- package/src/App.tsx +27 -86
- package/src/app/extension.ts +3 -1
- package/src/app/extensions.tsx +21 -0
- package/src/components/access-control/acl-gate.tsx +4 -10
- package/src/components/app-shell/breadcrumb.tsx +39 -3
- package/src/components/app-shell/document-title-handler.tsx +11 -20
- package/src/components/app-shell/header.tsx +18 -3
- package/src/components/app-shell/loading-overlay.tsx +2 -4
- package/src/components/app-shell/loading-state.tsx +15 -0
- package/src/components/app-shell/sidebar.tsx +75 -47
- package/src/components/data-table/data-table-pagination.tsx +27 -7
- package/src/components/data-table/data-table-styles.ts +57 -0
- package/src/components/data-table/data-table.tsx +14 -59
- package/src/components/resources/resource-label.ts +101 -0
- package/src/components/resources/views/create-view.tsx +17 -14
- package/src/components/resources/views/edit-view.tsx +17 -15
- package/src/components/resources/views/list-view.tsx +20 -10
- package/src/components/resources/views/show-view.tsx +20 -18
- package/src/index.tsx +2 -3
- package/src/lib/i18n.ts +161 -0
- package/src/lib/nocobase/client.ts +19 -2
- package/src/locales/en-US.ts +82 -0
- package/src/locales/index.ts +13 -0
- package/src/locales/zh-CN.ts +79 -0
- package/src/pages/users/create.tsx +98 -0
- package/src/pages/users/edit.tsx +92 -0
- package/src/pages/users/form-context.ts +57 -0
- package/src/pages/users/form-fields.tsx +211 -0
- package/src/pages/users/list.tsx +205 -0
- package/src/pages/users/show.tsx +187 -0
- package/src/pages/users/types.ts +20 -0
- package/src/providers/constants.ts +1 -0
- package/src/pages/blog-posts/create.tsx +0 -177
- package/src/pages/blog-posts/edit.tsx +0 -197
- package/src/pages/blog-posts/list.tsx +0 -131
- package/src/pages/blog-posts/show.tsx +0 -81
- package/src/pages/categories/create.tsx +0 -73
- package/src/pages/categories/edit.tsx +0 -73
- package/src/pages/categories/index.ts +0 -4
- package/src/pages/categories/list.tsx +0 -86
- package/src/pages/categories/show.tsx +0 -37
- /package/src/pages/{blog-posts → users}/index.ts +0 -0
|
@@ -2,7 +2,13 @@ import { Check, Copy } from "lucide-react";
|
|
|
2
2
|
import { useMemo, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from "@/components/ui/card";
|
|
6
12
|
import { Input } from "@/components/ui/input";
|
|
7
13
|
import { Label } from "@/components/ui/label";
|
|
8
14
|
import {
|
|
@@ -52,7 +58,7 @@ export function RoleSwitcherPromptGenerator() {
|
|
|
52
58
|
const [copyError, setCopyError] = useState(false);
|
|
53
59
|
|
|
54
60
|
const prompt = useMemo(
|
|
55
|
-
() => `Integrate the RoleSwitcher into the existing NocoBase
|
|
61
|
+
() => `Integrate the RoleSwitcher into the existing NocoBase Admin Starter.
|
|
56
62
|
|
|
57
63
|
Target placement
|
|
58
64
|
- Placement: ${placementLabels[placement]}
|
|
@@ -71,7 +77,7 @@ Component presentation
|
|
|
71
77
|
Integration requirements
|
|
72
78
|
- Import and reuse RoleSwitcher from the installed local entry point at @/extensions/nocobase-acl.
|
|
73
79
|
- Update the existing application surface instead of creating an isolated example page.
|
|
74
|
-
- Do not fetch, hard-code, or pass a duplicate role list. The component reads roles from the
|
|
80
|
+
- Do not fetch, hard-code, or pass a duplicate role list. The component reads roles from the signed-in identity.
|
|
75
81
|
- Do not reimplement Full permissions, Anonymous, or role-mode logic. The component derives these options from the Starter ACL store.
|
|
76
82
|
- Do not add a separate role-switch mutation. The component already calls users:setDefaultRole and reloads the application after a successful switch.
|
|
77
83
|
- Preserve existing account, responsive navigation, and sign-out behavior.
|
|
@@ -83,14 +89,20 @@ Integration requirements
|
|
|
83
89
|
<div className="grid items-start gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
|
|
84
90
|
<Card className="gap-0 py-0">
|
|
85
91
|
<CardHeader className="border-b py-4">
|
|
86
|
-
<CardTitle>Placement settings</CardTitle>
|
|
92
|
+
<CardTitle className="text-base">Placement settings</CardTitle>
|
|
93
|
+
<CardDescription>
|
|
94
|
+
Choose where the shared role switcher belongs and how its compact
|
|
95
|
+
state should appear.
|
|
96
|
+
</CardDescription>
|
|
87
97
|
</CardHeader>
|
|
88
98
|
<CardContent className="space-y-5 py-5">
|
|
89
99
|
<div className="space-y-2">
|
|
90
100
|
<Label>Placement</Label>
|
|
91
101
|
<Select
|
|
92
102
|
value={placement}
|
|
93
|
-
onValueChange={(value) =>
|
|
103
|
+
onValueChange={(value) =>
|
|
104
|
+
value && setPlacement(value as Placement)
|
|
105
|
+
}
|
|
94
106
|
>
|
|
95
107
|
<SelectTrigger className="w-full">
|
|
96
108
|
<SelectValue>{placementLabels[placement]}</SelectValue>
|
|
@@ -144,8 +156,15 @@ Integration requirements
|
|
|
144
156
|
</Card>
|
|
145
157
|
|
|
146
158
|
<Card className="gap-0 overflow-hidden py-0">
|
|
147
|
-
<
|
|
148
|
-
<
|
|
159
|
+
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
160
|
+
<div>
|
|
161
|
+
<div className="text-sm font-medium">
|
|
162
|
+
Generated integration prompt
|
|
163
|
+
</div>
|
|
164
|
+
<div className="text-xs text-muted-foreground">
|
|
165
|
+
Updates as you change placement and presentation settings.
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
149
168
|
<Button
|
|
150
169
|
size="sm"
|
|
151
170
|
onClick={async () => {
|
|
@@ -162,14 +181,14 @@ Integration requirements
|
|
|
162
181
|
{copied ? <Check /> : <Copy />}
|
|
163
182
|
{copied ? "Copied" : "Copy prompt"}
|
|
164
183
|
</Button>
|
|
165
|
-
</
|
|
184
|
+
</div>
|
|
166
185
|
{copyError ? (
|
|
167
186
|
<p className="border-b px-4 py-2 text-xs text-destructive">
|
|
168
187
|
Clipboard access failed. Select the prompt and copy it manually.
|
|
169
188
|
</p>
|
|
170
189
|
) : null}
|
|
171
190
|
<CardContent className="p-0">
|
|
172
|
-
<pre className="max-h-[560px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-5 text-muted-foreground">
|
|
191
|
+
<pre className="max-h-[560px] overflow-auto whitespace-pre-wrap bg-muted/25 p-5 font-mono text-xs leading-5 text-muted-foreground">
|
|
173
192
|
{prompt}
|
|
174
193
|
</pre>
|
|
175
194
|
</CardContent>
|
|
@@ -21,7 +21,7 @@ const nocobaseAclExtension: AppExtension = {
|
|
|
21
21
|
meta: {
|
|
22
22
|
label: "Access control",
|
|
23
23
|
icon: <ShieldCheck />,
|
|
24
|
-
description: "NocoBase ACL integration for
|
|
24
|
+
description: "NocoBase ACL integration for admin applications.",
|
|
25
25
|
acl: { type: "authenticated" },
|
|
26
26
|
},
|
|
27
27
|
},
|
|
@@ -12,8 +12,7 @@ try {
|
|
|
12
12
|
canAccessWithSnapshot,
|
|
13
13
|
getAclDataForDataSource,
|
|
14
14
|
resolveActionPermission,
|
|
15
|
-
} =
|
|
16
|
-
await server.ssrLoadModule("/src/lib/nocobase/acl/action.ts");
|
|
15
|
+
} = await server.ssrLoadModule("/src/lib/nocobase/acl/action.ts");
|
|
17
16
|
const { resolveAclDataSourceKey } = await server.ssrLoadModule(
|
|
18
17
|
"/src/lib/nocobase/acl/data-source.ts"
|
|
19
18
|
);
|
|
@@ -23,13 +22,11 @@ try {
|
|
|
23
22
|
clearRecordPermissions,
|
|
24
23
|
getRecordActionPermission,
|
|
25
24
|
updateRecordPermissions,
|
|
26
|
-
} = await server.ssrLoadModule(
|
|
27
|
-
"/src/lib/nocobase/acl/record-permissions.ts"
|
|
28
|
-
);
|
|
25
|
+
} = await server.ssrLoadModule("/src/lib/nocobase/acl/record-permissions.ts");
|
|
29
26
|
const { RoleSwitcher } = await server.ssrLoadModule(
|
|
30
27
|
"/registry/nocobase-acl/components/role-switcher.tsx"
|
|
31
28
|
);
|
|
32
|
-
const { getRoleOptions } = await server.ssrLoadModule(
|
|
29
|
+
const { getRoleOptions, resolveRoleTitle } = await server.ssrLoadModule(
|
|
33
30
|
"/registry/nocobase-acl/components/role-options.ts"
|
|
34
31
|
);
|
|
35
32
|
|
|
@@ -305,6 +302,10 @@ try {
|
|
|
305
302
|
{ name: "anonymous", title: "Anonymous" },
|
|
306
303
|
]
|
|
307
304
|
);
|
|
305
|
+
assert.equal(
|
|
306
|
+
resolveRoleTitle({ name: "admin", title: '{{t("Admin")}}' }),
|
|
307
|
+
"Admin"
|
|
308
|
+
);
|
|
308
309
|
|
|
309
310
|
assert.equal(typeof RoleSwitcher, "function");
|
|
310
311
|
console.log("NocoBase ACL regression tests passed");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
2
3
|
import {
|
|
3
4
|
useAIChatBase,
|
|
4
5
|
useAIChatMessages,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
type AIToolCallDecision,
|
|
8
9
|
} from "../../providers";
|
|
9
10
|
import { cn } from "@/lib/utils";
|
|
10
|
-
import { ArrowDown
|
|
11
|
+
import { ArrowDown } from "lucide-react";
|
|
11
12
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
12
13
|
import { ChatEmptyState } from "./chat-empty-state";
|
|
13
14
|
import { ChatMessage } from "./chat-message";
|
|
@@ -107,9 +108,7 @@ export function AIChatMessageList({
|
|
|
107
108
|
}}
|
|
108
109
|
>
|
|
109
110
|
{loading ? (
|
|
110
|
-
<
|
|
111
|
-
<LoaderCircle className="size-4 animate-spin" /> Loading messages…
|
|
112
|
-
</div>
|
|
111
|
+
<LoadingState className="h-full" />
|
|
113
112
|
) : (
|
|
114
113
|
<>
|
|
115
114
|
{messages.length ? (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
2
3
|
import {
|
|
3
4
|
Dialog,
|
|
4
5
|
DialogContent,
|
|
@@ -170,10 +171,7 @@ export function ConversationList({
|
|
|
170
171
|
</form>
|
|
171
172
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
|
|
172
173
|
{conversationsLoading ? (
|
|
173
|
-
<
|
|
174
|
-
<LoaderCircle className="size-4 animate-spin" /> Loading
|
|
175
|
-
conversations…
|
|
176
|
-
</div>
|
|
174
|
+
<LoadingState className="py-8" />
|
|
177
175
|
) : conversations.length ? (
|
|
178
176
|
<div className="space-y-1">
|
|
179
177
|
{conversations.map((conversation) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MarkdownMessage } from "../chat/markdown-message";
|
|
2
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
2
3
|
import { Button } from "@/components/ui/button";
|
|
3
4
|
import {
|
|
4
5
|
Dialog,
|
|
@@ -58,7 +59,11 @@ const sameReport = (
|
|
|
58
59
|
sameCharts(left.charts, right.charts);
|
|
59
60
|
|
|
60
61
|
type BusinessReportDialogController = {
|
|
61
|
-
open: (
|
|
62
|
+
open: (
|
|
63
|
+
toolCallId: string,
|
|
64
|
+
report: BusinessReportData,
|
|
65
|
+
ready: boolean
|
|
66
|
+
) => void;
|
|
62
67
|
update: (
|
|
63
68
|
toolCallId: string,
|
|
64
69
|
report: BusinessReportData,
|
|
@@ -79,9 +84,7 @@ export function useBusinessReportDialog() {
|
|
|
79
84
|
return value;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
export function BusinessReportDialogProvider({
|
|
83
|
-
children,
|
|
84
|
-
}: PropsWithChildren) {
|
|
87
|
+
export function BusinessReportDialogProvider({ children }: PropsWithChildren) {
|
|
85
88
|
const [state, setState] = useState(closedSnapshot);
|
|
86
89
|
const open = useCallback(
|
|
87
90
|
(toolCallId: string, report: BusinessReportData, ready: boolean) =>
|
|
@@ -107,9 +110,7 @@ export function BusinessReportDialogProvider({
|
|
|
107
110
|
state={state}
|
|
108
111
|
onOpenChange={(nextOpen) =>
|
|
109
112
|
setState((current) =>
|
|
110
|
-
current.open === nextOpen
|
|
111
|
-
? current
|
|
112
|
-
: { ...current, open: nextOpen }
|
|
113
|
+
current.open === nextOpen ? current : { ...current, open: nextOpen }
|
|
113
114
|
)
|
|
114
115
|
}
|
|
115
116
|
/>
|
|
@@ -119,13 +120,7 @@ export function BusinessReportDialogProvider({
|
|
|
119
120
|
|
|
120
121
|
function ChartPreview({ options }: { options: Record<string, unknown> }) {
|
|
121
122
|
return (
|
|
122
|
-
<Suspense
|
|
123
|
-
fallback={
|
|
124
|
-
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
|
|
125
|
-
<LoaderCircle className="mr-2 size-4 animate-spin" /> Loading chart…
|
|
126
|
-
</div>
|
|
127
|
-
}
|
|
128
|
-
>
|
|
123
|
+
<Suspense fallback={<LoadingState className="h-[280px]" />}>
|
|
129
124
|
<EChartsPreview options={options} />
|
|
130
125
|
</Suspense>
|
|
131
126
|
);
|
|
@@ -150,7 +145,10 @@ function BusinessReportDialogHost({
|
|
|
150
145
|
[report, state.ready]
|
|
151
146
|
);
|
|
152
147
|
const reportMarkdown = useMemo(
|
|
153
|
-
() =>
|
|
148
|
+
() =>
|
|
149
|
+
state.open && state.ready && report
|
|
150
|
+
? buildBusinessReportMarkdown(report)
|
|
151
|
+
: "",
|
|
154
152
|
[report, state.open, state.ready]
|
|
155
153
|
);
|
|
156
154
|
const previewParts = useMemo(
|
|
@@ -270,12 +268,11 @@ function BusinessReportDialogHost({
|
|
|
270
268
|
srcDoc={htmlPreview}
|
|
271
269
|
className="size-full min-h-[480px] border-0 bg-white"
|
|
272
270
|
/>
|
|
271
|
+
) : htmlLoading ? (
|
|
272
|
+
<LoadingState className="h-full min-h-[480px]" />
|
|
273
273
|
) : (
|
|
274
274
|
<div className="flex h-full min-h-[480px] items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
275
|
-
|
|
276
|
-
<LoaderCircle className="size-4 animate-spin" />
|
|
277
|
-
) : null}
|
|
278
|
-
{htmlLoading ? "Preparing HTML preview…" : "HTML is unavailable"}
|
|
275
|
+
HTML is unavailable
|
|
279
276
|
</div>
|
|
280
277
|
)}
|
|
281
278
|
</TabsContent>
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
2
3
|
import { CheckCircle2, Copy, LoaderCircle } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
Component,
|
|
5
|
-
lazy,
|
|
6
|
-
Suspense,
|
|
7
|
-
useState,
|
|
8
|
-
type ReactNode,
|
|
9
|
-
} from "react";
|
|
4
|
+
import { Component, lazy, Suspense, useState, type ReactNode } from "react";
|
|
10
5
|
import type { AIToolRendererProps } from "./tool-renderer-provider";
|
|
11
6
|
import { asRecord } from "./tool-renderer-utils";
|
|
12
7
|
|
|
@@ -75,13 +70,7 @@ export function ChartPreview({
|
|
|
75
70
|
options: Record<string, unknown>;
|
|
76
71
|
}) {
|
|
77
72
|
return (
|
|
78
|
-
<Suspense
|
|
79
|
-
fallback={
|
|
80
|
-
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
|
|
81
|
-
<LoaderCircle className="mr-2 size-4 animate-spin" /> Loading chart…
|
|
82
|
-
</div>
|
|
83
|
-
}
|
|
84
|
-
>
|
|
73
|
+
<Suspense fallback={<LoadingState className="h-[280px]" />}>
|
|
85
74
|
<EChartsPreview options={options} />
|
|
86
75
|
</Suspense>
|
|
87
76
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ReactEChartsCore from "echarts-for-react/lib/core";
|
|
2
2
|
import { useTheme } from "next-themes";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
4
5
|
import echarts, {
|
|
5
6
|
getEChartsRuntimeSignature,
|
|
6
7
|
prepareEChartsRuntime,
|
|
@@ -33,11 +34,7 @@ export default function EChartsPreview({
|
|
|
33
34
|
|
|
34
35
|
if (error) throw error;
|
|
35
36
|
if (preparedSignature !== signature) {
|
|
36
|
-
return
|
|
37
|
-
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
|
|
38
|
-
Loading chart…
|
|
39
|
-
</div>
|
|
40
|
-
);
|
|
37
|
+
return <LoadingState className="h-[280px]" />;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
return (
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
2
2
|
import { Badge } from "@/components/ui/badge";
|
|
3
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
3
4
|
import { useAI } from "../providers";
|
|
4
|
-
import { CircleAlert
|
|
5
|
+
import { CircleAlert } from "lucide-react";
|
|
5
6
|
import type { ReactNode } from "react";
|
|
6
7
|
|
|
7
8
|
export function AIConfigurationGate({ children }: { children: ReactNode }) {
|
|
@@ -17,6 +18,8 @@ export function AIConfigurationGate({ children }: { children: ReactNode }) {
|
|
|
17
18
|
const loading = configurationStatus === "loading";
|
|
18
19
|
const error = configurationError ?? modelConfigurationError;
|
|
19
20
|
|
|
21
|
+
if (loading) return <LoadingState className="min-h-80" />;
|
|
22
|
+
|
|
20
23
|
return (
|
|
21
24
|
<div className="space-y-6 pb-12">
|
|
22
25
|
<section className="border-b pb-8">
|
|
@@ -33,18 +36,12 @@ export function AIConfigurationGate({ children }: { children: ReactNode }) {
|
|
|
33
36
|
</p>
|
|
34
37
|
</section>
|
|
35
38
|
|
|
36
|
-
<Alert variant=
|
|
37
|
-
|
|
38
|
-
<AlertTitle>
|
|
39
|
-
{loading
|
|
40
|
-
? "Loading NocoBase AI configuration"
|
|
41
|
-
: "NocoBase AI is not available"}
|
|
42
|
-
</AlertTitle>
|
|
39
|
+
<Alert variant="destructive">
|
|
40
|
+
<CircleAlert />
|
|
41
|
+
<AlertTitle>NocoBase AI is not available</AlertTitle>
|
|
43
42
|
<AlertDescription>
|
|
44
|
-
{
|
|
45
|
-
|
|
46
|
-
: error?.message ??
|
|
47
|
-
"Check the NocoBase connection and the AI employees available to the current user."}
|
|
43
|
+
{error?.message ??
|
|
44
|
+
"Check the NocoBase connection and the AI employees available to the current user."}
|
|
48
45
|
</AlertDescription>
|
|
49
46
|
</Alert>
|
|
50
47
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NocoBaseAIExtensionProvider } from "./global-ai-chat";
|
|
2
2
|
import type { AppExtension } from "@/app/extension";
|
|
3
|
+
import { LoadingState } from "@/components/app-shell/loading-state";
|
|
3
4
|
import {
|
|
4
5
|
Bot,
|
|
5
6
|
MessageSquare,
|
|
@@ -37,13 +38,7 @@ const ToolCardsPage = lazy(() =>
|
|
|
37
38
|
|
|
38
39
|
function LazyDemoRoute({ children }: { children: ReactNode }) {
|
|
39
40
|
return (
|
|
40
|
-
<Suspense
|
|
41
|
-
fallback={
|
|
42
|
-
<div className="flex min-h-[320px] items-center justify-center text-sm text-muted-foreground">
|
|
43
|
-
Loading AI components…
|
|
44
|
-
</div>
|
|
45
|
-
}
|
|
46
|
-
>
|
|
41
|
+
<Suspense fallback={<LoadingState className="min-h-[320px]" />}>
|
|
47
42
|
{children}
|
|
48
43
|
</Suspense>
|
|
49
44
|
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# NocoBase i18n
|
|
2
|
+
|
|
3
|
+
Optional frontend-owned internationalization for the NocoBase Admin Starter.
|
|
4
|
+
|
|
5
|
+
The Registry connects i18next to the application translation provider, persists
|
|
6
|
+
the selected locale through the Starter's shared NocoBase client, sends it as
|
|
7
|
+
`X-Locale`, and adds a reusable language switcher to the signed-in user menu.
|
|
8
|
+
Its integrated Demo route shows the complete configuration model without
|
|
9
|
+
requiring a second Registry item.
|
|
10
|
+
|
|
11
|
+
Starter and Registry UI resources remain frontend-owned. At startup the runtime
|
|
12
|
+
reads `enabledLanguages` from `systemSettings:get` and merges only registered
|
|
13
|
+
dynamic NocoBase namespaces from `app:getLang`; `lm-collections` is registered
|
|
14
|
+
by default so collection and field translations remain compatible.
|
|
15
|
+
|
|
16
|
+
Application-owned React translations belong in `src/locales`, outside the
|
|
17
|
+
installed Registry directory. Register them through the Starter-level
|
|
18
|
+
`registerTranslationResources` helper so the application remains buildable when
|
|
19
|
+
the optional i18n Registry is not installed. When this Registry is present, it
|
|
20
|
+
consumes both resources registered before startup and resources added later by
|
|
21
|
+
lazy features.
|
|
22
|
+
|
|
23
|
+
On NocoBase versions that support filtered language resources, the request uses
|
|
24
|
+
`app:getLang?ns=lm-collections,...`. Older servers ignore the query parameter;
|
|
25
|
+
the client still merges only registered namespaces, so the integration remains
|
|
26
|
+
backward compatible.
|
|
27
|
+
|
|
28
|
+
Other installed components can register their own namespace without changing
|
|
29
|
+
the runtime:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { registerLocaleResources } from "@/extensions/nocobase-i18n";
|
|
33
|
+
|
|
34
|
+
registerLocaleResources("my-extension", {
|
|
35
|
+
"en-US": { title: "Orders" },
|
|
36
|
+
"zh-CN": { title: "订单" },
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If a component relies on another server-generated namespace, opt in explicitly:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { registerServerResourceNamespace } from "@/extensions/nocobase-i18n";
|
|
44
|
+
|
|
45
|
+
registerServerResourceNamespace("my-dynamic-namespace");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Namespaces registered after application startup are loaded incrementally, so
|
|
49
|
+
lazy Registry components do not need to participate in the initial request.
|
|
50
|
+
|
|
51
|
+
Use the application's `useTranslate` hook in React components. Existing exact
|
|
52
|
+
NocoBase-style expressions such as `{{t("Orders")}}` and expressions with a
|
|
53
|
+
string namespace are resolved through the Starter compatibility helper.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./language-switcher";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useGetLocale, useSetLocale, useTranslate } from "@refinedev/core";
|
|
2
|
+
import { Languages, Loader2 } from "lucide-react";
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenuRadioGroup,
|
|
7
|
+
DropdownMenuRadioItem,
|
|
8
|
+
DropdownMenuSeparator,
|
|
9
|
+
DropdownMenuSub,
|
|
10
|
+
DropdownMenuSubContent,
|
|
11
|
+
DropdownMenuSubTrigger,
|
|
12
|
+
} from "@/components/ui/dropdown-menu";
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from "@/components/ui/select";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
import { useEnabledLocales } from "../locale-store";
|
|
22
|
+
|
|
23
|
+
export type LanguageSwitcherProps = {
|
|
24
|
+
className?: string;
|
|
25
|
+
label?: ReactNode | false;
|
|
26
|
+
triggerClassName?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function LanguageSwitcher({
|
|
30
|
+
className,
|
|
31
|
+
label,
|
|
32
|
+
triggerClassName,
|
|
33
|
+
}: LanguageSwitcherProps) {
|
|
34
|
+
const translate = useTranslate();
|
|
35
|
+
const getLocale = useGetLocale();
|
|
36
|
+
const setLocale = useSetLocale();
|
|
37
|
+
const locales = useEnabledLocales();
|
|
38
|
+
const currentLocale = getLocale();
|
|
39
|
+
const [switching, setSwitching] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string>();
|
|
41
|
+
const resolvedLabel =
|
|
42
|
+
typeof label === "undefined"
|
|
43
|
+
? translate("language.label", { ns: "nocobase-i18n" }, "Language")
|
|
44
|
+
: label;
|
|
45
|
+
|
|
46
|
+
if (locales.length < 2) return null;
|
|
47
|
+
|
|
48
|
+
const currentDefinition =
|
|
49
|
+
locales.find(({ locale }) => locale === currentLocale) ?? locales[0];
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={cn("space-y-2", className)}>
|
|
53
|
+
{resolvedLabel === false ? null : (
|
|
54
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
55
|
+
{resolvedLabel}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
<Select
|
|
59
|
+
value={currentLocale}
|
|
60
|
+
disabled={switching}
|
|
61
|
+
onValueChange={(value) => {
|
|
62
|
+
if (!value || value === currentLocale) return;
|
|
63
|
+
setSwitching(true);
|
|
64
|
+
setError(undefined);
|
|
65
|
+
Promise.resolve(setLocale(value)).catch((reason) => {
|
|
66
|
+
setError(
|
|
67
|
+
reason instanceof Error
|
|
68
|
+
? reason.message
|
|
69
|
+
: translate(
|
|
70
|
+
"language.switchError",
|
|
71
|
+
{ ns: "nocobase-i18n" },
|
|
72
|
+
"Unable to switch language."
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
setSwitching(false);
|
|
76
|
+
});
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<SelectTrigger
|
|
80
|
+
className={cn("w-full min-w-52", triggerClassName)}
|
|
81
|
+
aria-label={String(resolvedLabel || "Language")}
|
|
82
|
+
>
|
|
83
|
+
{switching ? <Loader2 className="animate-spin" /> : <Languages />}
|
|
84
|
+
<SelectValue>{currentDefinition?.label ?? currentLocale}</SelectValue>
|
|
85
|
+
</SelectTrigger>
|
|
86
|
+
<SelectContent>
|
|
87
|
+
{locales.map((definition) => (
|
|
88
|
+
<SelectItem key={definition.locale} value={definition.locale}>
|
|
89
|
+
{definition.label}
|
|
90
|
+
</SelectItem>
|
|
91
|
+
))}
|
|
92
|
+
</SelectContent>
|
|
93
|
+
</Select>
|
|
94
|
+
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function LanguageUserMenuItems() {
|
|
100
|
+
const translate = useTranslate();
|
|
101
|
+
const getLocale = useGetLocale();
|
|
102
|
+
const setLocale = useSetLocale();
|
|
103
|
+
const locales = useEnabledLocales();
|
|
104
|
+
const currentLocale = getLocale();
|
|
105
|
+
|
|
106
|
+
if (locales.length < 2) return null;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<DropdownMenuSeparator />
|
|
111
|
+
<DropdownMenuSub>
|
|
112
|
+
<DropdownMenuSubTrigger className="min-h-9 gap-2 px-2 text-muted-foreground focus:text-foreground">
|
|
113
|
+
<Languages />
|
|
114
|
+
<span>
|
|
115
|
+
{translate("language.label", { ns: "nocobase-i18n" }, "Language")}
|
|
116
|
+
</span>
|
|
117
|
+
</DropdownMenuSubTrigger>
|
|
118
|
+
<DropdownMenuSubContent className="min-w-44">
|
|
119
|
+
<DropdownMenuRadioGroup
|
|
120
|
+
value={currentLocale}
|
|
121
|
+
onValueChange={(value) => {
|
|
122
|
+
if (value && value !== currentLocale) void setLocale(value);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{locales.map((definition) => (
|
|
126
|
+
<DropdownMenuRadioItem
|
|
127
|
+
key={definition.locale}
|
|
128
|
+
value={definition.locale}
|
|
129
|
+
>
|
|
130
|
+
{definition.label}
|
|
131
|
+
</DropdownMenuRadioItem>
|
|
132
|
+
))}
|
|
133
|
+
</DropdownMenuRadioGroup>
|
|
134
|
+
</DropdownMenuSubContent>
|
|
135
|
+
</DropdownMenuSub>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
}
|