@nocobase/portal-template-default 1.0.0 → 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 +90 -1
- package/components.json +3 -1
- package/package.json +21 -5
- package/registry/nocobase-acl/README.md +19 -0
- package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
- package/registry/nocobase-acl/components/index.ts +3 -0
- package/registry/nocobase-acl/components/role-options.ts +36 -0
- package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
- package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
- package/registry/nocobase-acl/demo/components.tsx +231 -0
- package/registry/nocobase-acl/demo/index.tsx +505 -0
- package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
- package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
- package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
- package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
- package/registry/nocobase-acl/extension.tsx +71 -0
- package/registry/nocobase-acl/index.ts +1 -0
- package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
- package/registry/nocobase-ai/README.md +70 -0
- package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
- package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
- package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
- package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
- package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
- package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
- package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
- package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
- package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
- package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
- package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
- package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
- package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
- package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
- package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
- package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
- package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
- package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
- package/registry/nocobase-ai/components/index.ts +59 -0
- package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
- package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
- package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
- package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
- package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
- package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
- package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
- package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
- package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
- package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
- package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
- package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
- package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
- package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
- package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
- package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
- package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
- package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
- package/registry/nocobase-ai/demo/floating.tsx +134 -0
- package/registry/nocobase-ai/demo/index.tsx +346 -0
- package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
- package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
- package/registry/nocobase-ai/demo/page-context.tsx +884 -0
- package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
- package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
- package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
- package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
- package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
- package/registry/nocobase-ai/extension.tsx +158 -0
- package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
- package/registry/nocobase-ai/index.ts +3 -0
- package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
- package/registry/nocobase-ai/providers/avatars.ts +817 -0
- package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
- package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
- package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
- package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
- package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
- package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
- package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
- package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
- package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
- package/registry/nocobase-ai/providers/index.ts +61 -0
- package/registry/nocobase-ai/providers/model.ts +32 -0
- package/registry/nocobase-ai/providers/page-context.tsx +136 -0
- package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
- package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
- package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
- package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
- package/registry/nocobase-ai/providers/types.ts +183 -0
- package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
- package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
- package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
- package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
- package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
- package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
- package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
- package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
- package/registry/nocobase-ai/services/index.ts +7 -0
- package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
- package/registry/nocobase-ai/services/types.ts +82 -0
- package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
- package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
- package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
- package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
- package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
- 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 +163 -0
- package/scripts/registry.mjs +164 -0
- package/src/App.css +95 -0
- package/src/App.tsx +87 -69
- package/src/app/extension.ts +11 -0
- package/src/app/extensions.tsx +49 -0
- package/src/components/access-control/access-denied.tsx +30 -0
- package/src/components/access-control/acl-bootstrap.tsx +9 -0
- package/src/components/access-control/acl-gate.tsx +36 -0
- package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
- package/src/components/access-control/resource-access-guard.tsx +21 -0
- package/src/components/access-control/use-acl-runtime.ts +28 -0
- 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 +22 -4
- 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 +103 -45
- 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/acl/action.ts +147 -0
- package/src/lib/nocobase/acl/data-source.ts +24 -0
- package/src/lib/nocobase/acl/index.ts +6 -0
- package/src/lib/nocobase/acl/menu.ts +41 -0
- package/src/lib/nocobase/acl/record-permissions.ts +111 -0
- package/src/lib/nocobase/acl/store.ts +182 -0
- package/src/lib/nocobase/acl/types.ts +96 -0
- package/src/lib/nocobase/client.ts +279 -0
- package/src/lib/nocobase/error.ts +32 -0
- 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/access-control.ts +34 -0
- package/src/providers/auth.ts +74 -60
- package/src/providers/constants.ts +3 -37
- package/src/providers/data.ts +242 -85
- package/vite.config.ts +17 -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
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { useTranslate, type HttpError, type BaseRecord } from "@refinedev/core";
|
|
4
4
|
import type { UseTableReturnType } from "@refinedev/react-table";
|
|
5
|
-
import type { Column } from "@tanstack/react-table";
|
|
6
5
|
import { flexRender } from "@tanstack/react-table";
|
|
7
6
|
import { Loader2 } from "lucide-react";
|
|
8
7
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -16,6 +15,7 @@ import {
|
|
|
16
15
|
TableRow,
|
|
17
16
|
} from "@/components/ui/table";
|
|
18
17
|
import { DataTablePagination } from "@/components/data-table/data-table-pagination";
|
|
18
|
+
import { getCommonStyles } from "@/components/data-table/data-table-styles";
|
|
19
19
|
import { cn } from "@/lib/utils";
|
|
20
20
|
|
|
21
21
|
type DataTableProps<TData extends BaseRecord> = {
|
|
@@ -25,6 +25,7 @@ type DataTableProps<TData extends BaseRecord> = {
|
|
|
25
25
|
export function DataTable<TData extends BaseRecord>({
|
|
26
26
|
table,
|
|
27
27
|
}: DataTableProps<TData>) {
|
|
28
|
+
const translate = useTranslate();
|
|
28
29
|
const {
|
|
29
30
|
reactTable: { getHeaderGroups, getRowModel, getAllColumns },
|
|
30
31
|
refineCore: {
|
|
@@ -198,6 +199,11 @@ export function DataTable<TData extends BaseRecord>({
|
|
|
198
199
|
<DataTableNoData
|
|
199
200
|
isOverflowing={isOverflowing}
|
|
200
201
|
columnsLength={columns.length}
|
|
202
|
+
title={translate("table.empty.title", "No data to display")}
|
|
203
|
+
description={translate(
|
|
204
|
+
"table.empty.description",
|
|
205
|
+
"This table is empty for the time being."
|
|
206
|
+
)}
|
|
201
207
|
/>
|
|
202
208
|
)}
|
|
203
209
|
</TableBody>
|
|
@@ -220,9 +226,13 @@ export function DataTable<TData extends BaseRecord>({
|
|
|
220
226
|
function DataTableNoData({
|
|
221
227
|
isOverflowing,
|
|
222
228
|
columnsLength,
|
|
229
|
+
title,
|
|
230
|
+
description,
|
|
223
231
|
}: {
|
|
224
232
|
isOverflowing: { horizontal: boolean; vertical: boolean };
|
|
225
233
|
columnsLength: number;
|
|
234
|
+
title: string;
|
|
235
|
+
description: string;
|
|
226
236
|
}) {
|
|
227
237
|
return (
|
|
228
238
|
<TableRow className="hover:bg-transparent">
|
|
@@ -252,10 +262,10 @@ function DataTableNoData({
|
|
|
252
262
|
}}
|
|
253
263
|
>
|
|
254
264
|
<div className={cn("text-lg", "font-semibold", "text-foreground")}>
|
|
255
|
-
|
|
265
|
+
{title}
|
|
256
266
|
</div>
|
|
257
267
|
<div className={cn("text-sm", "text-muted-foreground")}>
|
|
258
|
-
|
|
268
|
+
{description}
|
|
259
269
|
</div>
|
|
260
270
|
</div>
|
|
261
271
|
</TableCell>
|
|
@@ -263,59 +273,4 @@ function DataTableNoData({
|
|
|
263
273
|
);
|
|
264
274
|
}
|
|
265
275
|
|
|
266
|
-
export function getCommonStyles<TData>({
|
|
267
|
-
column,
|
|
268
|
-
isOverflowing,
|
|
269
|
-
}: {
|
|
270
|
-
column: Column<TData>;
|
|
271
|
-
isOverflowing: {
|
|
272
|
-
horizontal: boolean;
|
|
273
|
-
vertical: boolean;
|
|
274
|
-
};
|
|
275
|
-
}): React.CSSProperties {
|
|
276
|
-
const isPinned = column.getIsPinned();
|
|
277
|
-
const isLastLeftPinnedColumn =
|
|
278
|
-
isPinned === "left" && column.getIsLastColumn("left");
|
|
279
|
-
const isFirstRightPinnedColumn =
|
|
280
|
-
isPinned === "right" && column.getIsFirstColumn("right");
|
|
281
|
-
|
|
282
|
-
return {
|
|
283
|
-
boxShadow:
|
|
284
|
-
isOverflowing.horizontal && isLastLeftPinnedColumn
|
|
285
|
-
? "-4px 0 4px -4px var(--border) inset"
|
|
286
|
-
: isOverflowing.horizontal && isFirstRightPinnedColumn
|
|
287
|
-
? "4px 0 4px -4px var(--border) inset"
|
|
288
|
-
: undefined,
|
|
289
|
-
left:
|
|
290
|
-
isOverflowing.horizontal && isPinned === "left"
|
|
291
|
-
? `${column.getStart("left")}px`
|
|
292
|
-
: undefined,
|
|
293
|
-
right:
|
|
294
|
-
isOverflowing.horizontal && isPinned === "right"
|
|
295
|
-
? `${column.getAfter("right")}px`
|
|
296
|
-
: undefined,
|
|
297
|
-
opacity: 1,
|
|
298
|
-
position: isOverflowing.horizontal && isPinned ? "sticky" : "relative",
|
|
299
|
-
background: isOverflowing.horizontal && isPinned ? "var(--background)" : "",
|
|
300
|
-
borderTopRightRadius:
|
|
301
|
-
isOverflowing.horizontal && isPinned === "right"
|
|
302
|
-
? "var(--radius)"
|
|
303
|
-
: undefined,
|
|
304
|
-
borderBottomRightRadius:
|
|
305
|
-
isOverflowing.horizontal && isPinned === "right"
|
|
306
|
-
? "var(--radius)"
|
|
307
|
-
: undefined,
|
|
308
|
-
borderTopLeftRadius:
|
|
309
|
-
isOverflowing.horizontal && isPinned === "left"
|
|
310
|
-
? "var(--radius)"
|
|
311
|
-
: undefined,
|
|
312
|
-
borderBottomLeftRadius:
|
|
313
|
-
isOverflowing.horizontal && isPinned === "left"
|
|
314
|
-
? "var(--radius)"
|
|
315
|
-
: undefined,
|
|
316
|
-
width: column.getSize(),
|
|
317
|
-
zIndex: isOverflowing.horizontal && isPinned ? 1 : 0,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
|
|
321
276
|
DataTable.displayName = "DataTable";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IResourceItem,
|
|
3
|
+
useTranslate,
|
|
4
|
+
useUserFriendlyName,
|
|
5
|
+
} from "@refinedev/core";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
parseTranslationExpression,
|
|
9
|
+
type TranslationOptions,
|
|
10
|
+
} from "@/lib/i18n";
|
|
11
|
+
|
|
12
|
+
export type ResourceLabelForm = "plural" | "singular";
|
|
13
|
+
|
|
14
|
+
type ResourceLabelMeta = {
|
|
15
|
+
i18nKey?: string;
|
|
16
|
+
i18nOptions?: TranslationOptions;
|
|
17
|
+
i18nSingularKey?: string;
|
|
18
|
+
label?: unknown;
|
|
19
|
+
singularLabel?: unknown;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Translate = ReturnType<typeof useTranslate>;
|
|
23
|
+
type UserFriendlyName = ReturnType<typeof useUserFriendlyName>;
|
|
24
|
+
|
|
25
|
+
function translateWithFallback(
|
|
26
|
+
translate: Translate,
|
|
27
|
+
key: string,
|
|
28
|
+
fallback: string,
|
|
29
|
+
options?: TranslationOptions
|
|
30
|
+
) {
|
|
31
|
+
const translated = options
|
|
32
|
+
? translate(key, options, fallback)
|
|
33
|
+
: translate(key, fallback);
|
|
34
|
+
|
|
35
|
+
return !translated || translated === key ? fallback : translated;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveExplicitLabel(value: unknown, translate: Translate) {
|
|
39
|
+
if (typeof value !== "string") {
|
|
40
|
+
return value == null ? undefined : String(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const expression = parseTranslationExpression(value);
|
|
44
|
+
if (!expression) return value;
|
|
45
|
+
|
|
46
|
+
const fallback = expression.options?.defaultValue ?? expression.key;
|
|
47
|
+
return translateWithFallback(
|
|
48
|
+
translate,
|
|
49
|
+
expression.key,
|
|
50
|
+
fallback,
|
|
51
|
+
expression.options
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getResourceLabel(
|
|
56
|
+
resource: IResourceItem | undefined,
|
|
57
|
+
form: ResourceLabelForm,
|
|
58
|
+
translate: Translate,
|
|
59
|
+
getUserFriendlyName: UserFriendlyName,
|
|
60
|
+
fallbackIdentifier?: string
|
|
61
|
+
) {
|
|
62
|
+
const meta = resource?.meta as ResourceLabelMeta | undefined;
|
|
63
|
+
const topLevelLabel = (
|
|
64
|
+
resource as (IResourceItem & { label?: unknown }) | undefined
|
|
65
|
+
)?.label;
|
|
66
|
+
const explicitLabel =
|
|
67
|
+
form === "singular"
|
|
68
|
+
? meta?.singularLabel ?? meta?.label ?? topLevelLabel
|
|
69
|
+
: meta?.label ?? topLevelLabel;
|
|
70
|
+
const fallback =
|
|
71
|
+
resolveExplicitLabel(explicitLabel, translate) ??
|
|
72
|
+
getUserFriendlyName(
|
|
73
|
+
fallbackIdentifier ?? resource?.identifier ?? resource?.name ?? "",
|
|
74
|
+
form
|
|
75
|
+
);
|
|
76
|
+
const i18nKey =
|
|
77
|
+
form === "singular"
|
|
78
|
+
? meta?.i18nSingularKey ?? meta?.i18nKey
|
|
79
|
+
: meta?.i18nKey;
|
|
80
|
+
|
|
81
|
+
return i18nKey
|
|
82
|
+
? translateWithFallback(translate, i18nKey, fallback, meta?.i18nOptions)
|
|
83
|
+
: fallback;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useResourceLabel(
|
|
87
|
+
resource: IResourceItem | undefined,
|
|
88
|
+
form: ResourceLabelForm,
|
|
89
|
+
fallbackIdentifier?: string
|
|
90
|
+
) {
|
|
91
|
+
const translate = useTranslate();
|
|
92
|
+
const getUserFriendlyName = useUserFriendlyName();
|
|
93
|
+
|
|
94
|
+
return getResourceLabel(
|
|
95
|
+
resource,
|
|
96
|
+
form,
|
|
97
|
+
translate,
|
|
98
|
+
getUserFriendlyName,
|
|
99
|
+
fallbackIdentifier
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
|
-
import {
|
|
5
|
-
useBack,
|
|
6
|
-
useResourceParams,
|
|
7
|
-
useUserFriendlyName,
|
|
8
|
-
} from "@refinedev/core";
|
|
4
|
+
import { useBack, useResourceParams, useTranslate } from "@refinedev/core";
|
|
9
5
|
import type { PropsWithChildren } from "react";
|
|
10
6
|
import { Breadcrumb } from "@/components/app-shell/breadcrumb";
|
|
11
7
|
import { Button } from "@/components/ui/button";
|
|
12
8
|
import { ArrowLeftIcon } from "lucide-react";
|
|
9
|
+
import { useResourceLabel } from "@/components/resources/resource-label";
|
|
13
10
|
|
|
14
11
|
type CreateViewProps = PropsWithChildren<{
|
|
15
12
|
className?: string;
|
|
@@ -38,18 +35,25 @@ export const CreateViewHeader = ({
|
|
|
38
35
|
headerClassName,
|
|
39
36
|
}: CreateHeaderProps) => {
|
|
40
37
|
const back = useBack();
|
|
41
|
-
|
|
42
|
-
const getUserFriendlyName = useUserFriendlyName();
|
|
38
|
+
const translate = useTranslate();
|
|
43
39
|
|
|
44
40
|
const { resource, identifier } = useResourceParams({
|
|
45
41
|
resource: resourceFromProps,
|
|
46
42
|
});
|
|
47
43
|
|
|
48
|
-
const resourceTitle =
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
const resourceTitle = useResourceLabel(resource, "singular", identifier);
|
|
45
|
+
const title =
|
|
46
|
+
titleFromProps ??
|
|
47
|
+
translate(
|
|
48
|
+
"views.create.title",
|
|
49
|
+
{ resource: resourceTitle },
|
|
50
|
+
`Create ${resourceTitle}`
|
|
51
|
+
);
|
|
52
|
+
const description = translate(
|
|
53
|
+
"views.create.description",
|
|
54
|
+
{ resource: resourceTitle.toLocaleLowerCase() },
|
|
55
|
+
`Add a new ${resourceTitle.toLocaleLowerCase()} to your NocoBase workspace.`
|
|
51
56
|
);
|
|
52
|
-
const title = titleFromProps ?? `Create ${resourceTitle}`;
|
|
53
57
|
|
|
54
58
|
return (
|
|
55
59
|
<div className={cn("flex flex-col", "gap-3", wrapperClassName)}>
|
|
@@ -62,6 +66,7 @@ export const CreateViewHeader = ({
|
|
|
62
66
|
size="icon"
|
|
63
67
|
className="mt-0.5 rounded-lg"
|
|
64
68
|
onClick={back}
|
|
69
|
+
aria-label={translate("buttons.cancel", "Cancel")}
|
|
65
70
|
>
|
|
66
71
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
67
72
|
</Button>
|
|
@@ -69,9 +74,7 @@ export const CreateViewHeader = ({
|
|
|
69
74
|
<h2 className="text-3xl font-semibold tracking-[-0.035em]">
|
|
70
75
|
{title}
|
|
71
76
|
</h2>
|
|
72
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
73
|
-
Add a new {resourceTitle.toLowerCase()} to your NocoBase workspace.
|
|
74
|
-
</p>
|
|
77
|
+
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
|
75
78
|
</div>
|
|
76
79
|
</div>
|
|
77
80
|
</div>
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
|
-
import {
|
|
5
|
-
useBack,
|
|
6
|
-
useResourceParams,
|
|
7
|
-
useUserFriendlyName,
|
|
8
|
-
} from "@refinedev/core";
|
|
4
|
+
import { useBack, useResourceParams, useTranslate } from "@refinedev/core";
|
|
9
5
|
import type { PropsWithChildren } from "react";
|
|
10
6
|
import { Breadcrumb } from "@/components/app-shell/breadcrumb";
|
|
11
7
|
import { Button } from "@/components/ui/button";
|
|
12
8
|
import { RefreshButton } from "@/components/resources/buttons/refresh";
|
|
13
9
|
import { ArrowLeftIcon } from "lucide-react";
|
|
10
|
+
import { useResourceLabel } from "@/components/resources/resource-label";
|
|
14
11
|
|
|
15
12
|
type EditViewProps = PropsWithChildren<{
|
|
16
13
|
className?: string;
|
|
@@ -41,8 +38,7 @@ export const EditViewHeader = ({
|
|
|
41
38
|
headerClassName,
|
|
42
39
|
}: EditViewHeaderProps) => {
|
|
43
40
|
const back = useBack();
|
|
44
|
-
|
|
45
|
-
const getUserFriendlyName = useUserFriendlyName();
|
|
41
|
+
const translate = useTranslate();
|
|
46
42
|
|
|
47
43
|
const { resource, identifier } = useResourceParams({
|
|
48
44
|
resource: resourceFromProps,
|
|
@@ -51,11 +47,19 @@ export const EditViewHeader = ({
|
|
|
51
47
|
|
|
52
48
|
const resourceName = resource?.name ?? identifier;
|
|
53
49
|
|
|
54
|
-
const resourceTitle =
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const resourceTitle = useResourceLabel(resource, "singular", identifier);
|
|
51
|
+
const title =
|
|
52
|
+
titleFromProps ??
|
|
53
|
+
translate(
|
|
54
|
+
"views.edit.title",
|
|
55
|
+
{ resource: resourceTitle },
|
|
56
|
+
`Edit ${resourceTitle}`
|
|
57
|
+
);
|
|
58
|
+
const description = translate(
|
|
59
|
+
"views.edit.description",
|
|
60
|
+
{ resource: resourceTitle.toLocaleLowerCase() },
|
|
61
|
+
`Update this ${resourceTitle.toLocaleLowerCase()} while NocoBase keeps the data consistent.`
|
|
57
62
|
);
|
|
58
|
-
const title = titleFromProps ?? `Edit ${resourceTitle}`;
|
|
59
63
|
|
|
60
64
|
return (
|
|
61
65
|
<div className={cn("flex flex-col", "gap-3", wrapperClassName)}>
|
|
@@ -74,6 +78,7 @@ export const EditViewHeader = ({
|
|
|
74
78
|
size="icon"
|
|
75
79
|
className="mt-0.5 rounded-lg"
|
|
76
80
|
onClick={back}
|
|
81
|
+
aria-label={translate("buttons.cancel", "Cancel")}
|
|
77
82
|
>
|
|
78
83
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
79
84
|
</Button>
|
|
@@ -81,10 +86,7 @@ export const EditViewHeader = ({
|
|
|
81
86
|
<h2 className="text-3xl font-semibold tracking-[-0.035em]">
|
|
82
87
|
{title}
|
|
83
88
|
</h2>
|
|
84
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
85
|
-
Update this {resourceTitle.toLowerCase()} while NocoBase keeps
|
|
86
|
-
the data consistent.
|
|
87
|
-
</p>
|
|
89
|
+
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
|
88
90
|
</div>
|
|
89
91
|
</div>
|
|
90
92
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import type { PropsWithChildren } from "react";
|
|
4
4
|
|
|
5
|
-
import { useResourceParams,
|
|
5
|
+
import { useResourceParams, useTranslate } from "@refinedev/core";
|
|
6
6
|
import { Breadcrumb } from "@/components/app-shell/breadcrumb";
|
|
7
7
|
import { CreateButton } from "@/components/resources/buttons/create";
|
|
8
|
+
import { useResourceLabel } from "@/components/resources/resource-label";
|
|
9
|
+
import { resolveTranslatableText, type TranslationOptions } from "@/lib/i18n";
|
|
8
10
|
import { cn } from "@/lib/utils";
|
|
9
11
|
|
|
10
12
|
type ListViewProps = PropsWithChildren<{
|
|
@@ -35,22 +37,30 @@ export const ListViewHeader = ({
|
|
|
35
37
|
wrapperClassName,
|
|
36
38
|
headerClassName,
|
|
37
39
|
}: ListHeaderProps) => {
|
|
38
|
-
const getUserFriendlyName = useUserFriendlyName();
|
|
39
|
-
|
|
40
40
|
const { resource, identifier } = useResourceParams({
|
|
41
41
|
resource: resourceFromProps,
|
|
42
42
|
});
|
|
43
|
+
const translate = useTranslate();
|
|
43
44
|
const resourceName = identifier ?? resource?.name;
|
|
44
45
|
|
|
45
46
|
const isCreateButtonVisible = canCreate ?? !!resource?.create;
|
|
46
47
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
const resourceTitle = useResourceLabel(resource, "plural", identifier);
|
|
49
|
+
const title = titleFromProps ?? resourceTitle;
|
|
50
|
+
const meta = resource?.meta as
|
|
51
|
+
| {
|
|
52
|
+
description?: string;
|
|
53
|
+
descriptionI18nKey?: string;
|
|
54
|
+
i18nOptions?: TranslationOptions;
|
|
55
|
+
}
|
|
56
|
+
| undefined;
|
|
57
|
+
const description = meta?.descriptionI18nKey
|
|
58
|
+
? translate(
|
|
59
|
+
meta.descriptionI18nKey,
|
|
60
|
+
meta.i18nOptions,
|
|
61
|
+
meta.description ?? meta.descriptionI18nKey
|
|
62
|
+
)
|
|
63
|
+
: resolveTranslatableText(meta?.description);
|
|
54
64
|
|
|
55
65
|
return (
|
|
56
66
|
<div className={cn("flex flex-col", "gap-3", wrapperClassName)}>
|
|
@@ -3,25 +3,29 @@
|
|
|
3
3
|
import type { PropsWithChildren } from "react";
|
|
4
4
|
|
|
5
5
|
import { ArrowLeftIcon } from "lucide-react";
|
|
6
|
-
import {
|
|
7
|
-
useBack,
|
|
8
|
-
useResourceParams,
|
|
9
|
-
useUserFriendlyName,
|
|
10
|
-
} from "@refinedev/core";
|
|
6
|
+
import { useBack, useResourceParams, useTranslate } from "@refinedev/core";
|
|
11
7
|
import { Breadcrumb } from "@/components/app-shell/breadcrumb";
|
|
12
8
|
import { Button } from "@/components/ui/button";
|
|
13
9
|
import { RefreshButton } from "@/components/resources/buttons/refresh";
|
|
14
10
|
import { cn } from "@/lib/utils";
|
|
15
11
|
import { EditButton } from "../buttons/edit";
|
|
12
|
+
import { useResourceLabel } from "@/components/resources/resource-label";
|
|
16
13
|
|
|
17
14
|
type ShowViewProps = PropsWithChildren<{
|
|
18
15
|
className?: string;
|
|
16
|
+
resource?: string;
|
|
17
|
+
title?: string;
|
|
19
18
|
}>;
|
|
20
19
|
|
|
21
|
-
export function ShowView({
|
|
20
|
+
export function ShowView({
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
resource,
|
|
24
|
+
title,
|
|
25
|
+
}: ShowViewProps) {
|
|
22
26
|
return (
|
|
23
27
|
<div className={cn("flex flex-col", "gap-6", className)}>
|
|
24
|
-
<ShowViewHeader />
|
|
28
|
+
<ShowViewHeader resource={resource} title={title} />
|
|
25
29
|
{children}
|
|
26
30
|
</div>
|
|
27
31
|
);
|
|
@@ -41,8 +45,7 @@ export const ShowViewHeader = ({
|
|
|
41
45
|
headerClassName,
|
|
42
46
|
}: ShowViewHeaderProps) => {
|
|
43
47
|
const back = useBack();
|
|
44
|
-
|
|
45
|
-
const getUserFriendlyName = useUserFriendlyName();
|
|
48
|
+
const translate = useTranslate();
|
|
46
49
|
|
|
47
50
|
const { resource, identifier } = useResourceParams({
|
|
48
51
|
resource: resourceFromProps,
|
|
@@ -51,12 +54,12 @@ export const ShowViewHeader = ({
|
|
|
51
54
|
|
|
52
55
|
const resourceName = resource?.name ?? identifier;
|
|
53
56
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const resourceTitle = useResourceLabel(resource, "singular", identifier);
|
|
58
|
+
const title = titleFromProps ?? resourceTitle;
|
|
59
|
+
const description = translate(
|
|
60
|
+
"views.show.description",
|
|
61
|
+
"Review the record and its NocoBase-managed data."
|
|
62
|
+
);
|
|
60
63
|
|
|
61
64
|
return (
|
|
62
65
|
<div className={cn("flex flex-col", "gap-3", wrapperClassName)}>
|
|
@@ -75,6 +78,7 @@ export const ShowViewHeader = ({
|
|
|
75
78
|
size="icon"
|
|
76
79
|
className="mt-0.5 rounded-lg"
|
|
77
80
|
onClick={back}
|
|
81
|
+
aria-label={translate("buttons.cancel", "Cancel")}
|
|
78
82
|
>
|
|
79
83
|
<ArrowLeftIcon className="h-4 w-4" />
|
|
80
84
|
</Button>
|
|
@@ -82,9 +86,7 @@ export const ShowViewHeader = ({
|
|
|
82
86
|
<h2 className="text-3xl font-semibold tracking-[-0.035em]">
|
|
83
87
|
{title}
|
|
84
88
|
</h2>
|
|
85
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
86
|
-
Review the record and its NocoBase-managed data.
|
|
87
|
-
</p>
|
|
89
|
+
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
|
88
90
|
</div>
|
|
89
91
|
</div>
|
|
90
92
|
|
package/src/index.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createRoot } from "react-dom/client";
|
|
3
3
|
|
|
4
|
+
import "./locales";
|
|
4
5
|
import App from "./App";
|
|
5
6
|
|
|
6
7
|
const container = document.getElementById("root") as HTMLElement;
|
|
@@ -12,6 +13,4 @@ const root = createRoot(container);
|
|
|
12
13
|
// </React.StrictMode>
|
|
13
14
|
// );
|
|
14
15
|
|
|
15
|
-
root.render(
|
|
16
|
-
<App />
|
|
17
|
-
);
|
|
16
|
+
root.render(<App />);
|
package/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export type TranslationOptions = Record<string, unknown> & {
|
|
2
|
+
defaultValue?: string;
|
|
3
|
+
ns?: string | string[];
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type TranslationResolver = (
|
|
7
|
+
key: string,
|
|
8
|
+
options?: TranslationOptions,
|
|
9
|
+
defaultMessage?: string
|
|
10
|
+
) => string;
|
|
11
|
+
|
|
12
|
+
export type TranslationExpression = {
|
|
13
|
+
key: string;
|
|
14
|
+
options?: TranslationOptions;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TranslationResource = Record<string, string | number | boolean>;
|
|
18
|
+
|
|
19
|
+
export type TranslationResourceBundle = Record<string, TranslationResource>;
|
|
20
|
+
|
|
21
|
+
type TranslationResourceListener = (
|
|
22
|
+
namespace: string,
|
|
23
|
+
resources: TranslationResourceBundle
|
|
24
|
+
) => void;
|
|
25
|
+
|
|
26
|
+
let translationResolver: TranslationResolver | undefined;
|
|
27
|
+
const translationResources = new Map<string, TranslationResourceBundle>();
|
|
28
|
+
const translationResourceListeners = new Set<TranslationResourceListener>();
|
|
29
|
+
|
|
30
|
+
const quotedValuePattern = `(?:"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*')`;
|
|
31
|
+
const translationExpressionPattern = new RegExp(
|
|
32
|
+
`^\\s*\\{\\{\\s*t\\(\\s*(${quotedValuePattern})\\s*(?:,\\s*(\\{[\\s\\S]*\\}))?\\s*\\)\\s*\\}\\}\\s*$`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
function decodeQuotedValue(value: string) {
|
|
36
|
+
if (value.startsWith('"')) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(value) as string;
|
|
39
|
+
} catch {
|
|
40
|
+
return value.slice(1, -1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return value
|
|
45
|
+
.slice(1, -1)
|
|
46
|
+
.replace(/\\'/g, "'")
|
|
47
|
+
.replace(/\\"/g, '"')
|
|
48
|
+
.replace(/\\n/g, "\n")
|
|
49
|
+
.replace(/\\r/g, "\r")
|
|
50
|
+
.replace(/\\t/g, "\t")
|
|
51
|
+
.replace(/\\\\/g, "\\");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePrimitive(value: string): unknown {
|
|
55
|
+
const normalized = value.trim();
|
|
56
|
+
if (normalized.startsWith('"') || normalized.startsWith("'")) {
|
|
57
|
+
return decodeQuotedValue(normalized);
|
|
58
|
+
}
|
|
59
|
+
if (normalized === "true") return true;
|
|
60
|
+
if (normalized === "false") return false;
|
|
61
|
+
if (normalized === "null") return null;
|
|
62
|
+
if (/^-?\d+(?:\.\d+)?$/.test(normalized)) return Number(normalized);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseOptions(source?: string): TranslationOptions | undefined {
|
|
67
|
+
if (!source) return undefined;
|
|
68
|
+
|
|
69
|
+
const options: TranslationOptions = {};
|
|
70
|
+
const propertyPattern = new RegExp(
|
|
71
|
+
`([A-Za-z_$][\\w$]*)\\s*:\\s*(${quotedValuePattern}|-?\\d+(?:\\.\\d+)?|true|false|null)`,
|
|
72
|
+
"g"
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
for (const match of source.matchAll(propertyPattern)) {
|
|
76
|
+
const value = parsePrimitive(match[2]);
|
|
77
|
+
if (typeof value !== "undefined") options[match[1]] = value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const namespaceArray = source.match(/\bns\s*:\s*\[([^\]]*)\]/);
|
|
81
|
+
if (namespaceArray) {
|
|
82
|
+
const namespaces = [
|
|
83
|
+
...namespaceArray[1].matchAll(new RegExp(quotedValuePattern, "g")),
|
|
84
|
+
].map((match) => decodeQuotedValue(match[0]));
|
|
85
|
+
if (namespaces.length) options.ns = namespaces;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Object.keys(options).length ? options : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseTranslationExpression(
|
|
92
|
+
value: string
|
|
93
|
+
): TranslationExpression | undefined {
|
|
94
|
+
const match = translationExpressionPattern.exec(value);
|
|
95
|
+
if (!match) return undefined;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
key: decodeQuotedValue(match[1]),
|
|
99
|
+
options: parseOptions(match[2]),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function setTranslationResolver(resolver?: TranslationResolver) {
|
|
104
|
+
translationResolver = resolver;
|
|
105
|
+
return () => {
|
|
106
|
+
if (translationResolver === resolver) translationResolver = undefined;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function registerTranslationResources(
|
|
111
|
+
namespace: string,
|
|
112
|
+
resources: TranslationResourceBundle
|
|
113
|
+
) {
|
|
114
|
+
const current = translationResources.get(namespace) ?? {};
|
|
115
|
+
const merged = Object.fromEntries(
|
|
116
|
+
[...new Set([...Object.keys(current), ...Object.keys(resources)])].map(
|
|
117
|
+
(locale) => [
|
|
118
|
+
locale,
|
|
119
|
+
{
|
|
120
|
+
...(current[locale] ?? {}),
|
|
121
|
+
...(resources[locale] ?? {}),
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
translationResources.set(namespace, merged);
|
|
128
|
+
translationResourceListeners.forEach((listener) =>
|
|
129
|
+
listener(namespace, resources)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getTranslationResources() {
|
|
134
|
+
return [...translationResources.entries()];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function subscribeTranslationResources(
|
|
138
|
+
listener: TranslationResourceListener
|
|
139
|
+
) {
|
|
140
|
+
translationResourceListeners.add(listener);
|
|
141
|
+
return () => translationResourceListeners.delete(listener);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolveTranslatableText(
|
|
145
|
+
value: unknown,
|
|
146
|
+
options?: TranslationOptions
|
|
147
|
+
): string {
|
|
148
|
+
if (typeof value !== "string") return value == null ? "" : String(value);
|
|
149
|
+
|
|
150
|
+
const expression = parseTranslationExpression(value);
|
|
151
|
+
if (!expression) {
|
|
152
|
+
return translationResolver && options
|
|
153
|
+
? translationResolver(value, options, value)
|
|
154
|
+
: value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const defaultMessage = expression.options?.defaultValue ?? expression.key;
|
|
158
|
+
return translationResolver
|
|
159
|
+
? translationResolver(expression.key, expression.options, defaultMessage)
|
|
160
|
+
: defaultMessage;
|
|
161
|
+
}
|