create-cundi-app 1.0.17 → 1.0.19
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 +1 -0
- package/index.js +69 -2
- package/package.json +1 -1
- package/template/package.json.template +2 -1
- package/template/src/App.no-samples.tsx +25 -0
- package/template/src/App.tsx +47 -3
- package/template/src/components/ChatPanel.tsx +239 -0
- package/template/src/components/HeaderWithChat.tsx +181 -0
- package/template/src/i18n.no-samples.ts +16 -0
- package/template/src/i18n.ts +72 -0
- package/template/src/pages/samples/basic-type-test/create.tsx +14 -1
- package/template/src/pages/samples/basic-type-test/edit.tsx +14 -1
- package/template/src/pages/samples/basic-type-test/list.tsx +16 -0
- package/template/src/pages/samples/order/create.tsx +50 -0
- package/template/src/pages/samples/order/edit.tsx +139 -0
- package/template/src/pages/samples/order/index.ts +4 -0
- package/template/src/pages/samples/order/list.tsx +59 -0
- package/template/src/pages/samples/order/show.tsx +69 -0
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ The generated project includes:
|
|
|
39
39
|
- 🛡️ **RBAC**: Role-based Access Control
|
|
40
40
|
- 🌍 **i18n**: English and Traditional Chinese
|
|
41
41
|
- 🌙 **Dark Mode**: Theme toggle support
|
|
42
|
+
- 🤖 **AI Assistant**: Persistent right-side chat panel (Optional)
|
|
42
43
|
- 📊 **Dashboard**: Starter template
|
|
43
44
|
|
|
44
45
|
## After Creating
|
package/index.js
CHANGED
|
@@ -52,6 +52,14 @@ async function main() {
|
|
|
52
52
|
initial: false,
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
// Ask if chat widget should be included
|
|
56
|
+
const { includeChatWidget } = await prompts({
|
|
57
|
+
type: "confirm",
|
|
58
|
+
name: "includeChatWidget",
|
|
59
|
+
message: "Include Chat Widget? (@cundi/react-chat-widget)",
|
|
60
|
+
initial: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
55
63
|
const targetDir = path.resolve(process.cwd(), projectName);
|
|
56
64
|
|
|
57
65
|
// Check if directory already exists
|
|
@@ -76,6 +84,9 @@ async function main() {
|
|
|
76
84
|
if (includeSamples) {
|
|
77
85
|
console.log(kleur.gray("Including sample pages..."));
|
|
78
86
|
}
|
|
87
|
+
if (includeChatWidget) {
|
|
88
|
+
console.log(kleur.gray("Including Chat Widget..."));
|
|
89
|
+
}
|
|
79
90
|
console.log();
|
|
80
91
|
|
|
81
92
|
// Copy template files
|
|
@@ -93,6 +104,13 @@ async function main() {
|
|
|
93
104
|
if (!includeSamples && relativePath.includes("pages/samples")) {
|
|
94
105
|
return false;
|
|
95
106
|
}
|
|
107
|
+
// Skip chat components if not requested
|
|
108
|
+
if (!includeChatWidget && (
|
|
109
|
+
relativePath.includes("components/ChatPanel.tsx") ||
|
|
110
|
+
relativePath.includes("components/HeaderWithChat.tsx")
|
|
111
|
+
)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
96
114
|
// Skip the alternative version files (they will be handled separately)
|
|
97
115
|
if (relativePath.endsWith(".no-samples.tsx") || relativePath.endsWith(".no-samples.ts")) {
|
|
98
116
|
return false;
|
|
@@ -102,21 +120,55 @@ async function main() {
|
|
|
102
120
|
});
|
|
103
121
|
|
|
104
122
|
// Handle App.tsx and i18n.ts based on sample choice
|
|
123
|
+
const appPath = path.join(targetDir, "src/App.tsx");
|
|
124
|
+
const i18nPath = path.join(targetDir, "src/i18n.ts");
|
|
125
|
+
|
|
105
126
|
if (!includeSamples) {
|
|
106
127
|
// If not including samples, use the no-samples versions
|
|
107
128
|
const appNoSamplesPath = path.join(templateDir, "src/App.no-samples.tsx");
|
|
108
|
-
const appPath = path.join(targetDir, "src/App.tsx");
|
|
109
129
|
if (fs.existsSync(appNoSamplesPath)) {
|
|
110
130
|
await fs.copy(appNoSamplesPath, appPath, { overwrite: true });
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
const i18nNoSamplesPath = path.join(templateDir, "src/i18n.no-samples.ts");
|
|
114
|
-
const i18nPath = path.join(targetDir, "src/i18n.ts");
|
|
115
134
|
if (fs.existsSync(i18nNoSamplesPath)) {
|
|
116
135
|
await fs.copy(i18nNoSamplesPath, i18nPath, { overwrite: true });
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
// Process chat markers in App.tsx
|
|
140
|
+
if (fs.existsSync(appPath)) {
|
|
141
|
+
let appContent = await fs.readFile(appPath, "utf-8");
|
|
142
|
+
if (includeChatWidget) {
|
|
143
|
+
// Keep chat, remove markers and the "no chat alternative"
|
|
144
|
+
const markers = [
|
|
145
|
+
/\/\/\s*\{CHAT_START\}/g,
|
|
146
|
+
/\/\/\s*\{CHAT_END\}/g,
|
|
147
|
+
/\/\/\s*\{NO_CHAT_START\}/g,
|
|
148
|
+
/\/\/\s*\{NO_CHAT_END\}/g,
|
|
149
|
+
/\{\s*\/\*\s*\{CHAT_START\}\s*\*\/\s*\}/g,
|
|
150
|
+
/\{\s*\/\*\s*\{CHAT_END\}\s*\*\/\s*\}/g
|
|
151
|
+
];
|
|
152
|
+
markers.forEach(re => {
|
|
153
|
+
appContent = appContent.replace(re, "");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Remove the "no chat" blocks entirely
|
|
157
|
+
appContent = appContent.replace(/\{\s*\/\*\s*\{NO_CHAT_START\}[\s\S]*?\{NO_CHAT_END\}\s*\*\/\s*\}/g, "");
|
|
158
|
+
} else {
|
|
159
|
+
// Remove chat blocks, uncomment "no chat alternative"
|
|
160
|
+
appContent = appContent.replace(/\/\/\s*\{CHAT_START\}[\s\S]*?\/\/\s*\{CHAT_END\}\n?/g, "");
|
|
161
|
+
appContent = appContent.replace(/\{\s*\/\*\s*\{CHAT_START\}\s*\*\/\s*\}[\s\S]*?\{\s*\/\*\s*\{CHAT_END\}\s*\*\/\s*\}\n?/g, "");
|
|
162
|
+
|
|
163
|
+
// Uncomment {NO_CHAT_START} ... {NO_CHAT_END}
|
|
164
|
+
// Case 1: Multi-line JSX comment
|
|
165
|
+
appContent = appContent.replace(/\{\s*\/\*\s*\{NO_CHAT_START\}\n?|(?:\n?\s*)?\{NO_CHAT_END\}\s*\*\/\s*\}/g, "");
|
|
166
|
+
// Case 2: single-line JS comment
|
|
167
|
+
appContent = appContent.replace(/\/\/\s*\{NO_CHAT_START\}\n?|\/\/\s*\{NO_CHAT_END\}\n?/g, "");
|
|
168
|
+
}
|
|
169
|
+
await fs.writeFile(appPath, appContent);
|
|
170
|
+
}
|
|
171
|
+
|
|
120
172
|
// Generate package.json with project name
|
|
121
173
|
const pkgTemplatePath = path.join(targetDir, "package.json.template");
|
|
122
174
|
const pkgTargetPath = path.join(targetDir, "package.json");
|
|
@@ -124,6 +176,14 @@ async function main() {
|
|
|
124
176
|
if (fs.existsSync(pkgTemplatePath)) {
|
|
125
177
|
let pkgContent = await fs.readFile(pkgTemplatePath, "utf-8");
|
|
126
178
|
pkgContent = pkgContent.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
179
|
+
|
|
180
|
+
// Add chat widget dependency if requested
|
|
181
|
+
if (includeChatWidget) {
|
|
182
|
+
const pkg = JSON.parse(pkgContent);
|
|
183
|
+
pkg.dependencies["@cundi/react-chat-widget"] = "^1.0.0";
|
|
184
|
+
pkgContent = JSON.stringify(pkg, null, 2);
|
|
185
|
+
}
|
|
186
|
+
|
|
127
187
|
await fs.writeFile(pkgTargetPath, pkgContent);
|
|
128
188
|
await fs.remove(pkgTemplatePath);
|
|
129
189
|
}
|
|
@@ -141,6 +201,9 @@ VITE_KEYCLOAK_URL=http://localhost:8080
|
|
|
141
201
|
VITE_KEYCLOAK_REALM=your-realm
|
|
142
202
|
VITE_KEYCLOAK_CLIENT_ID=your-client-id
|
|
143
203
|
VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
|
204
|
+
|
|
205
|
+
# Chat Widget Configuration (Optional)
|
|
206
|
+
VITE_CHAT_WEBHOOK_URL=
|
|
144
207
|
`
|
|
145
208
|
);
|
|
146
209
|
}
|
|
@@ -171,6 +234,10 @@ VITE_REDIRECT_URI=http://localhost:5173/auth/callback
|
|
|
171
234
|
console.log(kleur.yellow("Note: Sample pages are included. Make sure your backend has the corresponding Business Objects."));
|
|
172
235
|
console.log();
|
|
173
236
|
}
|
|
237
|
+
if (includeChatWidget) {
|
|
238
|
+
console.log(kleur.yellow("Note: Chat Widget is included. See the Chat Demo page for usage examples."));
|
|
239
|
+
console.log();
|
|
240
|
+
}
|
|
174
241
|
console.log(kleur.gray("────────────────────────────────────"));
|
|
175
242
|
console.log(kleur.blue("Happy coding! 🎉"));
|
|
176
243
|
console.log();
|
package/package.json
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@ant-design/icons": "^5.5.1",
|
|
26
26
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
|
27
|
-
"@cundi/refine-xaf": "^1.0.
|
|
27
|
+
"@cundi/refine-xaf": "^1.0.15",
|
|
28
|
+
"@cundi/react-chat-widget": "^1.0.1",
|
|
28
29
|
"@refinedev/antd": "^6.0.3",
|
|
29
30
|
"@refinedev/cli": "^2.16.50",
|
|
30
31
|
"@refinedev/core": "^5.0.6",
|
|
@@ -34,7 +34,9 @@ import { DashboardPage } from "./pages/dashboard";
|
|
|
34
34
|
import {
|
|
35
35
|
authProvider,
|
|
36
36
|
dataProvider,
|
|
37
|
+
// {NO_CHAT_START}
|
|
37
38
|
Header,
|
|
39
|
+
// {NO_CHAT_END}
|
|
38
40
|
LoginPage,
|
|
39
41
|
KeycloakLoginPage,
|
|
40
42
|
AuthCallback,
|
|
@@ -57,6 +59,11 @@ import {
|
|
|
57
59
|
|
|
58
60
|
import { accessControlProvider } from "./accessControlProvider";
|
|
59
61
|
|
|
62
|
+
// {CHAT_START}
|
|
63
|
+
import { ChatProvider } from "./components/ChatPanel";
|
|
64
|
+
import { HeaderWithChat } from "./components/HeaderWithChat";
|
|
65
|
+
// {CHAT_END}
|
|
66
|
+
|
|
60
67
|
const API_URL = import.meta.env.VITE_API_URL + "/api/odata";
|
|
61
68
|
|
|
62
69
|
const InnerApp: React.FC = () => {
|
|
@@ -169,7 +176,12 @@ const InnerApp: React.FC = () => {
|
|
|
169
176
|
key="authenticated-routes"
|
|
170
177
|
fallback={<CatchAllNavigate to="/login" />}
|
|
171
178
|
>
|
|
179
|
+
{ /* {CHAT_START} */}
|
|
180
|
+
<ThemedLayout Header={HeaderWithChat}>
|
|
181
|
+
{ /* {CHAT_END} */}
|
|
182
|
+
{ /* {NO_CHAT_START}
|
|
172
183
|
<ThemedLayout Header={Header}>
|
|
184
|
+
{NO_CHAT_END} */ }
|
|
173
185
|
<Outlet />
|
|
174
186
|
</ThemedLayout>
|
|
175
187
|
</Authenticated>
|
|
@@ -221,7 +233,12 @@ const InnerApp: React.FC = () => {
|
|
|
221
233
|
<Route
|
|
222
234
|
element={
|
|
223
235
|
<Authenticated key="catch-all">
|
|
236
|
+
{ /* {CHAT_START} */}
|
|
237
|
+
<ThemedLayout Header={HeaderWithChat}>
|
|
238
|
+
{ /* {CHAT_END} */}
|
|
239
|
+
{ /* {NO_CHAT_START}
|
|
224
240
|
<ThemedLayout Header={Header}>
|
|
241
|
+
{NO_CHAT_END} */ }
|
|
225
242
|
<Outlet />
|
|
226
243
|
</ThemedLayout>
|
|
227
244
|
</Authenticated>
|
|
@@ -241,8 +258,16 @@ const InnerApp: React.FC = () => {
|
|
|
241
258
|
const App: React.FC = () => {
|
|
242
259
|
return (
|
|
243
260
|
<ColorModeContextProvider>
|
|
261
|
+
{/* {CHAT_START} */}
|
|
262
|
+
<ChatProvider>
|
|
263
|
+
<InnerApp />
|
|
264
|
+
</ChatProvider>
|
|
265
|
+
{/* {CHAT_END} */}
|
|
266
|
+
{/* {NO_CHAT_START}
|
|
244
267
|
<InnerApp />
|
|
268
|
+
{NO_CHAT_END} */}
|
|
245
269
|
</ColorModeContextProvider>
|
|
270
|
+
|
|
246
271
|
);
|
|
247
272
|
};
|
|
248
273
|
|
package/template/src/App.tsx
CHANGED
|
@@ -35,7 +35,9 @@ import { DashboardPage } from "./pages/dashboard";
|
|
|
35
35
|
import {
|
|
36
36
|
authProvider,
|
|
37
37
|
dataProvider,
|
|
38
|
+
// {NO_CHAT_START}
|
|
38
39
|
Header,
|
|
40
|
+
// {NO_CHAT_END}
|
|
39
41
|
LoginPage,
|
|
40
42
|
KeycloakLoginPage,
|
|
41
43
|
AuthCallback,
|
|
@@ -76,6 +78,12 @@ import { DrawioTestList } from "./pages/samples/drawio-test/list";
|
|
|
76
78
|
import { DrawioTestCreate } from "./pages/samples/drawio-test/create";
|
|
77
79
|
import { DrawioTestEdit } from "./pages/samples/drawio-test/edit";
|
|
78
80
|
import { DrawioTestShow } from "./pages/samples/drawio-test/show";
|
|
81
|
+
import { OrderList, OrderCreate, OrderEdit, OrderShow } from "./pages/samples/order";
|
|
82
|
+
|
|
83
|
+
// {CHAT_START}
|
|
84
|
+
import { ChatProvider } from "./components/ChatPanel";
|
|
85
|
+
import { HeaderWithChat } from "./components/HeaderWithChat";
|
|
86
|
+
// {CHAT_END}
|
|
79
87
|
|
|
80
88
|
const API_URL = import.meta.env.VITE_API_URL + "/api/odata";
|
|
81
89
|
|
|
@@ -131,6 +139,17 @@ const InnerApp: React.FC = () => {
|
|
|
131
139
|
parent: "Model",
|
|
132
140
|
},
|
|
133
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: "Order",
|
|
144
|
+
list: "/Orders",
|
|
145
|
+
create: "/Orders/create",
|
|
146
|
+
edit: "/Orders/edit/:id",
|
|
147
|
+
show: "/Orders/show/:id",
|
|
148
|
+
meta: {
|
|
149
|
+
label: t("sider.order"),
|
|
150
|
+
parent: "Model",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
134
153
|
{
|
|
135
154
|
name: "TiptapTest",
|
|
136
155
|
list: "/TiptapTests",
|
|
@@ -264,7 +283,12 @@ const InnerApp: React.FC = () => {
|
|
|
264
283
|
key="authenticated-routes"
|
|
265
284
|
fallback={<CatchAllNavigate to="/login" />}
|
|
266
285
|
>
|
|
286
|
+
{ /* {CHAT_START} */}
|
|
287
|
+
<ThemedLayout Header={HeaderWithChat}>
|
|
288
|
+
{ /* {CHAT_END} */}
|
|
289
|
+
{ /* {NO_CHAT_START}
|
|
267
290
|
<ThemedLayout Header={Header}>
|
|
291
|
+
{NO_CHAT_END} */ }
|
|
268
292
|
<Outlet />
|
|
269
293
|
</ThemedLayout>
|
|
270
294
|
</Authenticated>
|
|
@@ -305,6 +329,13 @@ const InnerApp: React.FC = () => {
|
|
|
305
329
|
<Route path="show/:id" element={<DrawioTestShow />} />
|
|
306
330
|
</Route>
|
|
307
331
|
|
|
332
|
+
<Route path="/Orders">
|
|
333
|
+
<Route index element={<OrderList />} />
|
|
334
|
+
<Route path="create" element={<OrderCreate />} />
|
|
335
|
+
<Route path="edit/:id" element={<OrderEdit />} />
|
|
336
|
+
<Route path="show/:id" element={<OrderShow />} />
|
|
337
|
+
</Route>
|
|
338
|
+
|
|
308
339
|
{/* Core routes */}
|
|
309
340
|
<Route path="/ApplicationUsers">
|
|
310
341
|
<Route index element={<ApplicationUserList />} />
|
|
@@ -350,7 +381,12 @@ const InnerApp: React.FC = () => {
|
|
|
350
381
|
<Route
|
|
351
382
|
element={
|
|
352
383
|
<Authenticated key="catch-all">
|
|
353
|
-
|
|
384
|
+
{ /* {CHAT_START} */}
|
|
385
|
+
<ThemedLayout Header={HeaderWithChat}>
|
|
386
|
+
{ /* {CHAT_END} */}
|
|
387
|
+
{ /* {NO_CHAT_START}
|
|
388
|
+
<ThemedLayout Header={Header}>
|
|
389
|
+
{NO_CHAT_END} */ }
|
|
354
390
|
<Outlet />
|
|
355
391
|
</ThemedLayout>
|
|
356
392
|
</Authenticated>
|
|
@@ -362,16 +398,24 @@ const InnerApp: React.FC = () => {
|
|
|
362
398
|
<UnsavedChangesNotifier />
|
|
363
399
|
<DocumentTitleHandler />
|
|
364
400
|
</Refine>
|
|
365
|
-
</AntdApp>
|
|
366
|
-
</BrowserRouter>
|
|
401
|
+
</AntdApp >
|
|
402
|
+
</BrowserRouter >
|
|
367
403
|
);
|
|
368
404
|
};
|
|
369
405
|
|
|
370
406
|
const App: React.FC = () => {
|
|
371
407
|
return (
|
|
372
408
|
<ColorModeContextProvider>
|
|
409
|
+
{/* {CHAT_START} */}
|
|
410
|
+
<ChatProvider>
|
|
411
|
+
<InnerApp />
|
|
412
|
+
</ChatProvider>
|
|
413
|
+
{/* {CHAT_END} */}
|
|
414
|
+
{/* {NO_CHAT_START}
|
|
373
415
|
<InnerApp />
|
|
416
|
+
{NO_CHAT_END} */}
|
|
374
417
|
</ColorModeContextProvider>
|
|
418
|
+
|
|
375
419
|
);
|
|
376
420
|
};
|
|
377
421
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, ReactNode } from "react";
|
|
2
|
+
import { Button, Tooltip, theme } from "antd";
|
|
3
|
+
import { MessageOutlined, CloseOutlined } from "@ant-design/icons";
|
|
4
|
+
import { ChatWidget } from "@cundi/react-chat-widget";
|
|
5
|
+
import "@cundi/react-chat-widget/styles.css";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
|
|
8
|
+
const { useToken } = theme;
|
|
9
|
+
|
|
10
|
+
// ============ Chat Context ============
|
|
11
|
+
interface ChatContextType {
|
|
12
|
+
isOpen: boolean;
|
|
13
|
+
open: () => void;
|
|
14
|
+
close: () => void;
|
|
15
|
+
toggle: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ChatContext = createContext<ChatContextType | null>(null);
|
|
19
|
+
|
|
20
|
+
export const useChatPanel = () => {
|
|
21
|
+
const context = useContext(ChatContext);
|
|
22
|
+
if (!context) {
|
|
23
|
+
throw new Error("useChatPanel must be used within ChatProvider");
|
|
24
|
+
}
|
|
25
|
+
return context;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============ Mock Data for Demo ============
|
|
29
|
+
const MOCK_CHAT_RESPONSES: Record<string, (message: string, t: any) => any> = {
|
|
30
|
+
help: (_, t) => ({
|
|
31
|
+
output: `## 測試指令說明
|
|
32
|
+
您可以輸入以下關鍵字來測試不同功能:
|
|
33
|
+
|
|
34
|
+
| 指令 | 說明 |
|
|
35
|
+
|------|------|
|
|
36
|
+
| \`markdown\` | 顯示 Markdown 格式範例 |
|
|
37
|
+
| \`table\` | 顯示表格範例 |
|
|
38
|
+
| \`image\` | 顯示圖片範例 |
|
|
39
|
+
| \`file\` | 顯示檔案下載連結 |
|
|
40
|
+
| \`code\` | 顯示程式碼區塊範例 |
|
|
41
|
+
| \`list\` | 顯示清單範例 |
|
|
42
|
+
| \`help\` | 顯示此說明 |`
|
|
43
|
+
}),
|
|
44
|
+
markdown: () => ({
|
|
45
|
+
output: `### Markdown 測試
|
|
46
|
+
這是一個 **Markdown** 格式的回應!
|
|
47
|
+
- 支援 **粗體** 和 *斜體*
|
|
48
|
+
- 支援 \`程式碼\` 區塊
|
|
49
|
+
- 支援 [連結](https://google.com)
|
|
50
|
+
> 這是一段引用文字`
|
|
51
|
+
}),
|
|
52
|
+
table: () => ({
|
|
53
|
+
output: `### 產品清單表格
|
|
54
|
+
| 產品名稱 | 價格 | 庫存 |
|
|
55
|
+
|---------|------|------|
|
|
56
|
+
| 筆記型電腦 | $999 | 15 |
|
|
57
|
+
| 無線滑鼠 | $29 | 150 |
|
|
58
|
+
| 機械鍵盤 | $149 | 0 |`
|
|
59
|
+
}),
|
|
60
|
+
image: () => ({
|
|
61
|
+
output: `### 圖片展示\n這是一張隨機範例圖片:`,
|
|
62
|
+
image: 'https://picsum.photos/400/300'
|
|
63
|
+
}),
|
|
64
|
+
file: () => ({
|
|
65
|
+
output: `### 檔案下載\n以下是可供下載的檔案:`,
|
|
66
|
+
files: [
|
|
67
|
+
{
|
|
68
|
+
name: '範例文件.pdf',
|
|
69
|
+
url: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
|
70
|
+
type: 'application/pdf',
|
|
71
|
+
size: 13264,
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}),
|
|
75
|
+
code: () => ({
|
|
76
|
+
output: `### 程式碼範例\n\`\`\`tsx
|
|
77
|
+
const HelloWorld = () => {
|
|
78
|
+
return <div>Hello Cundi!</div>;
|
|
79
|
+
};
|
|
80
|
+
\`\`\``
|
|
81
|
+
}),
|
|
82
|
+
list: () => ({
|
|
83
|
+
output: `### 開發進度\n- [x] 建立專案
|
|
84
|
+
- [x] 整合聊天面板
|
|
85
|
+
- [ ] 介面優化
|
|
86
|
+
- [ ] 發布正式版`
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ============ Chat Provider ============
|
|
91
|
+
interface ChatProviderProps {
|
|
92
|
+
children: ReactNode;
|
|
93
|
+
/** Width of chat panel */
|
|
94
|
+
panelWidth?: number;
|
|
95
|
+
/** Custom message handler for chat */
|
|
96
|
+
onSendMessage?: (message: string, files?: File[]) => Promise<{ output: string } | string>;
|
|
97
|
+
/** Webhook URL for webhook mode */
|
|
98
|
+
webhookUrl?: string;
|
|
99
|
+
/** Session ID for webhook mode */
|
|
100
|
+
sessionId?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
104
|
+
children,
|
|
105
|
+
panelWidth = 500,
|
|
106
|
+
onSendMessage,
|
|
107
|
+
webhookUrl,
|
|
108
|
+
sessionId,
|
|
109
|
+
}) => {
|
|
110
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
111
|
+
const { token } = useToken();
|
|
112
|
+
const { t } = useTranslation();
|
|
113
|
+
|
|
114
|
+
// Default message handler (mock response generator)
|
|
115
|
+
const defaultMessageHandler = async (message: string) => {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
117
|
+
const input = message.toLowerCase().trim();
|
|
118
|
+
|
|
119
|
+
for (const [keyword, generator] of Object.entries(MOCK_CHAT_RESPONSES)) {
|
|
120
|
+
if (input.includes(keyword)) {
|
|
121
|
+
return generator(message, t);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
output: t("chat.defaultResponse", `收到您的訊息:「${message}」\n\n這是一個示範回應。您可以輸入 \`help\` 來查看更多指令測試。`),
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const contextValue: ChatContextType = {
|
|
131
|
+
isOpen,
|
|
132
|
+
open: () => setIsOpen(true),
|
|
133
|
+
close: () => setIsOpen(false),
|
|
134
|
+
toggle: () => setIsOpen((prev) => !prev),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<ChatContext.Provider value={contextValue}>
|
|
139
|
+
{/* Main content with margin adjustment */}
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
marginRight: isOpen ? panelWidth : 0,
|
|
143
|
+
transition: "margin-right 0.3s ease-in-out",
|
|
144
|
+
minHeight: "100vh",
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{children}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Fixed Chat Panel */}
|
|
151
|
+
<div
|
|
152
|
+
style={{
|
|
153
|
+
position: "fixed",
|
|
154
|
+
top: 0,
|
|
155
|
+
right: 0,
|
|
156
|
+
bottom: 0,
|
|
157
|
+
width: isOpen ? panelWidth : 0,
|
|
158
|
+
backgroundColor: token.colorBgContainer,
|
|
159
|
+
borderLeft: isOpen ? `1px solid ${token.colorBorderSecondary}` : "none",
|
|
160
|
+
transition: "width 0.3s ease-in-out",
|
|
161
|
+
overflow: "hidden",
|
|
162
|
+
zIndex: 1000,
|
|
163
|
+
display: "flex",
|
|
164
|
+
flexDirection: "column",
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{isOpen && (
|
|
168
|
+
<>
|
|
169
|
+
{/* Panel Header */}
|
|
170
|
+
<div
|
|
171
|
+
style={{
|
|
172
|
+
display: "flex",
|
|
173
|
+
alignItems: "center",
|
|
174
|
+
justifyContent: "space-between",
|
|
175
|
+
padding: "12px 16px",
|
|
176
|
+
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
177
|
+
backgroundColor: token.colorBgElevated,
|
|
178
|
+
height: 64,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<span style={{ fontWeight: 600, fontSize: 16, color: token.colorText }}>
|
|
182
|
+
{t("chat.panelTitle", "AI Assistant")}
|
|
183
|
+
</span>
|
|
184
|
+
<Button
|
|
185
|
+
type="text"
|
|
186
|
+
icon={<CloseOutlined />}
|
|
187
|
+
onClick={() => setIsOpen(false)}
|
|
188
|
+
size="small"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Chat Widget */}
|
|
193
|
+
<div style={{ flex: 1, minHeight: 0 }}>
|
|
194
|
+
{webhookUrl ? (
|
|
195
|
+
<ChatWidget
|
|
196
|
+
webhookUrl={webhookUrl}
|
|
197
|
+
sessionId={sessionId || `session-${Date.now()}`}
|
|
198
|
+
showHeader={false}
|
|
199
|
+
placeholder={t("chat.placeholder", "Type a message...")}
|
|
200
|
+
user={{ name: t("chat.user", "You"), avatar: "👤" }}
|
|
201
|
+
assistant={{ name: t("chat.assistant", "AI"), avatar: "🤖" }}
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<ChatWidget
|
|
205
|
+
onSendMessage={onSendMessage || defaultMessageHandler}
|
|
206
|
+
showHeader={false}
|
|
207
|
+
placeholder={t("chat.placeholder", "Type a message...")}
|
|
208
|
+
user={{ name: t("chat.user", "You"), avatar: "👤" }}
|
|
209
|
+
assistant={{ name: t("chat.assistant", "AI"), avatar: "🤖" }}
|
|
210
|
+
allowFileUpload={true}
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</ChatContext.Provider>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ============ Chat Toggle Button ============
|
|
222
|
+
/**
|
|
223
|
+
* Button component to toggle the chat panel.
|
|
224
|
+
* Place this anywhere in your app (e.g., in the Header).
|
|
225
|
+
*/
|
|
226
|
+
export const ChatToggleButton: React.FC = () => {
|
|
227
|
+
const { isOpen, toggle } = useChatPanel();
|
|
228
|
+
const { t } = useTranslation();
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<Tooltip title={t("chat.toggleTooltip", "Toggle AI Chat")}>
|
|
232
|
+
<Button
|
|
233
|
+
type={isOpen ? "primary" : "text"}
|
|
234
|
+
icon={<MessageOutlined />}
|
|
235
|
+
onClick={toggle}
|
|
236
|
+
/>
|
|
237
|
+
</Tooltip>
|
|
238
|
+
);
|
|
239
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { useLogout, useGetIdentity, useUpdatePassword } from "@refinedev/core";
|
|
3
|
+
import { Layout, Button, Space, Typography, Avatar, theme, Dropdown, MenuProps, Tooltip } from "antd";
|
|
4
|
+
import { LogoutOutlined, UserOutlined, DownOutlined, SunOutlined, MoonOutlined, CameraOutlined, LockOutlined, GlobalOutlined, MessageOutlined } from "@ant-design/icons";
|
|
5
|
+
import { useColorMode, GlobalSearch, ChangePhotoModal, ChangePasswordModal } from "@cundi/refine-xaf";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
import { useChatPanel } from "./ChatPanel";
|
|
8
|
+
|
|
9
|
+
const { Text } = Typography;
|
|
10
|
+
const { useToken } = theme;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Custom Header that includes the chat toggle button
|
|
14
|
+
* This is a modified version of the Header from @cundi/refine-xaf
|
|
15
|
+
*/
|
|
16
|
+
export const HeaderWithChat: React.FC = () => {
|
|
17
|
+
const { mutate: logout } = useLogout();
|
|
18
|
+
const { mutate: updatePassword } = useUpdatePassword();
|
|
19
|
+
const { data: user } = useGetIdentity<any>();
|
|
20
|
+
const { mode, setMode } = useColorMode();
|
|
21
|
+
const { token } = useToken();
|
|
22
|
+
const { isOpen: isChatOpen, toggle: toggleChat } = useChatPanel();
|
|
23
|
+
|
|
24
|
+
const { t: translate, i18n } = useTranslation();
|
|
25
|
+
const currentLocale = i18n.language;
|
|
26
|
+
|
|
27
|
+
const handleLanguageChange = (lang: string) => {
|
|
28
|
+
i18n.changeLanguage(lang);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Track auth provider
|
|
32
|
+
const [authProvider, setAuthProvider] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (user) {
|
|
36
|
+
setAuthProvider(localStorage.getItem("auth_provider"));
|
|
37
|
+
}
|
|
38
|
+
}, [user]);
|
|
39
|
+
|
|
40
|
+
// Photo Modal State
|
|
41
|
+
const [isPhotoModalOpen, setIsPhotoModalOpen] = useState(false);
|
|
42
|
+
|
|
43
|
+
// Password Modal State
|
|
44
|
+
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
|
45
|
+
|
|
46
|
+
const menuItems: MenuProps["items"] = [
|
|
47
|
+
{
|
|
48
|
+
key: "user-info",
|
|
49
|
+
label: (
|
|
50
|
+
<Space direction="vertical" size={0}>
|
|
51
|
+
<Text strong>{user?.name}</Text>
|
|
52
|
+
</Space>
|
|
53
|
+
),
|
|
54
|
+
icon: <UserOutlined />,
|
|
55
|
+
disabled: true,
|
|
56
|
+
style: { cursor: "default", color: token.colorText },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "divider",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: "change-photo",
|
|
63
|
+
label: translate("components.header.menu.changePhoto", "Change Photo"),
|
|
64
|
+
icon: <CameraOutlined />,
|
|
65
|
+
onClick: () => {
|
|
66
|
+
setIsPhotoModalOpen(true);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: "change-password",
|
|
71
|
+
label: authProvider === "keycloak"
|
|
72
|
+
? translate("components.header.menu.manageAccount", "Manage Account in Keycloak")
|
|
73
|
+
: translate("components.header.menu.changePassword", "Change Password"),
|
|
74
|
+
icon: <LockOutlined />,
|
|
75
|
+
onClick: () => {
|
|
76
|
+
if (authProvider === "keycloak") {
|
|
77
|
+
const env = (import.meta as any).env;
|
|
78
|
+
const keycloakUrl = env?.VITE_KEYCLOAK_URL || "http://localhost:8080";
|
|
79
|
+
const realm = env?.VITE_KEYCLOAK_REALM || "cundi";
|
|
80
|
+
window.open(`${keycloakUrl}/realms/${realm}/account`, "_blank");
|
|
81
|
+
} else {
|
|
82
|
+
setIsPasswordModalOpen(true);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: "divider",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: "theme",
|
|
91
|
+
label: mode === "light"
|
|
92
|
+
? translate("components.header.theme.dark", "Dark Theme")
|
|
93
|
+
: translate("components.header.theme.light", "Light Theme"),
|
|
94
|
+
icon: mode === "light" ? <MoonOutlined /> : <SunOutlined />,
|
|
95
|
+
onClick: () => setMode(mode === "light" ? "dark" : "light"),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "logout",
|
|
99
|
+
label: translate("components.header.menu.logout", "Logout"),
|
|
100
|
+
icon: <LogoutOutlined />,
|
|
101
|
+
onClick: () => logout(),
|
|
102
|
+
}
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Layout.Header
|
|
107
|
+
style={{
|
|
108
|
+
display: "flex",
|
|
109
|
+
justifyContent: "flex-end",
|
|
110
|
+
alignItems: "center",
|
|
111
|
+
padding: "0 24px",
|
|
112
|
+
height: "64px",
|
|
113
|
+
backgroundColor: token.colorBgElevated,
|
|
114
|
+
position: "sticky",
|
|
115
|
+
top: 0,
|
|
116
|
+
zIndex: 1,
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<Space size="middle">
|
|
120
|
+
<div>
|
|
121
|
+
<GlobalSearch width={300} placeholder={translate("components.globalSearch.placeholder", "Search...")} />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Chat Toggle Button */}
|
|
125
|
+
<Tooltip title={translate("chat.toggleTooltip", "Toggle AI Chat")}>
|
|
126
|
+
<Button
|
|
127
|
+
type={isChatOpen ? "primary" : "text"}
|
|
128
|
+
icon={<MessageOutlined />}
|
|
129
|
+
onClick={toggleChat}
|
|
130
|
+
/>
|
|
131
|
+
</Tooltip>
|
|
132
|
+
|
|
133
|
+
<Dropdown
|
|
134
|
+
menu={{
|
|
135
|
+
items: [
|
|
136
|
+
{
|
|
137
|
+
key: "en",
|
|
138
|
+
label: translate("components.header.language.en", "English"),
|
|
139
|
+
onClick: () => handleLanguageChange("en"),
|
|
140
|
+
disabled: currentLocale === "en",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: "zh-TW",
|
|
144
|
+
label: translate("components.header.language.zh-TW", "繁體中文"),
|
|
145
|
+
onClick: () => handleLanguageChange("zh-TW"),
|
|
146
|
+
disabled: currentLocale === "zh-TW",
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
}}
|
|
150
|
+
trigger={["click"]}
|
|
151
|
+
>
|
|
152
|
+
<Button type="text" icon={<GlobalOutlined />}>
|
|
153
|
+
{currentLocale === "zh-TW" ? "繁體中文" : "English"} <DownOutlined />
|
|
154
|
+
</Button>
|
|
155
|
+
</Dropdown>
|
|
156
|
+
|
|
157
|
+
<Dropdown menu={{ items: menuItems }} trigger={["click"]}>
|
|
158
|
+
<Button type="text" style={{ height: 48 }}>
|
|
159
|
+
<Space>
|
|
160
|
+
<Avatar src={user?.avatar} alt={user?.name} icon={<UserOutlined />} />
|
|
161
|
+
<Text>{user?.name}</Text>
|
|
162
|
+
<DownOutlined style={{ fontSize: 12 }} />
|
|
163
|
+
</Space>
|
|
164
|
+
</Button>
|
|
165
|
+
</Dropdown>
|
|
166
|
+
</Space>
|
|
167
|
+
|
|
168
|
+
{/* Photo Modal */}
|
|
169
|
+
<ChangePhotoModal
|
|
170
|
+
open={isPhotoModalOpen}
|
|
171
|
+
onClose={() => setIsPhotoModalOpen(false)}
|
|
172
|
+
user={user}
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
<ChangePasswordModal
|
|
176
|
+
open={isPasswordModalOpen}
|
|
177
|
+
onClose={() => setIsPasswordModalOpen(false)}
|
|
178
|
+
/>
|
|
179
|
+
</Layout.Header>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
@@ -14,6 +14,14 @@ const enExtended = {
|
|
|
14
14
|
triggerLogs: "Trigger Logs",
|
|
15
15
|
mirrorConfigs: "Mirror Configs",
|
|
16
16
|
},
|
|
17
|
+
// Chat translations
|
|
18
|
+
chat: {
|
|
19
|
+
panelTitle: "AI Assistant",
|
|
20
|
+
toggleTooltip: "Toggle AI Chat",
|
|
21
|
+
placeholder: "Type a message...",
|
|
22
|
+
user: "You",
|
|
23
|
+
assistant: "AI",
|
|
24
|
+
},
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
const zhTWExtended = {
|
|
@@ -24,6 +32,14 @@ const zhTWExtended = {
|
|
|
24
32
|
triggerLogs: "觸發紀錄",
|
|
25
33
|
mirrorConfigs: "鏡射設定",
|
|
26
34
|
},
|
|
35
|
+
// Chat translations
|
|
36
|
+
chat: {
|
|
37
|
+
panelTitle: "AI 助手",
|
|
38
|
+
toggleTooltip: "開啟/關閉 AI 聊天",
|
|
39
|
+
placeholder: "輸入訊息...",
|
|
40
|
+
user: "您",
|
|
41
|
+
assistant: "AI",
|
|
42
|
+
},
|
|
27
43
|
};
|
|
28
44
|
|
|
29
45
|
i18n
|
package/template/src/i18n.ts
CHANGED
|
@@ -17,10 +17,19 @@ const enExtended = {
|
|
|
17
17
|
samples: "Samples",
|
|
18
18
|
model: "Model",
|
|
19
19
|
basicTypeTest: "Basic Type",
|
|
20
|
+
order: "Master-Details",
|
|
20
21
|
tiptapTest: "Tiptap Editor",
|
|
21
22
|
drawioTest: "Drawio Editor",
|
|
22
23
|
fullTextSearch: "Full Text Search",
|
|
23
24
|
},
|
|
25
|
+
// Chat translations
|
|
26
|
+
chat: {
|
|
27
|
+
panelTitle: "AI Assistant",
|
|
28
|
+
toggleTooltip: "Toggle AI Chat",
|
|
29
|
+
placeholder: "Type a message...",
|
|
30
|
+
user: "You",
|
|
31
|
+
assistant: "AI",
|
|
32
|
+
},
|
|
24
33
|
// Sample field translations
|
|
25
34
|
article: {
|
|
26
35
|
title: "Title",
|
|
@@ -45,12 +54,39 @@ const enExtended = {
|
|
|
45
54
|
doubleValue: "Double Value",
|
|
46
55
|
dateTimeValue: "DateTime Value",
|
|
47
56
|
boolValue: "Bool Value",
|
|
57
|
+
statusValue: "Status",
|
|
58
|
+
status: {
|
|
59
|
+
Draft: "Draft",
|
|
60
|
+
Active: "Active",
|
|
61
|
+
Completed: "Completed",
|
|
62
|
+
Cancelled: "Cancelled",
|
|
63
|
+
},
|
|
48
64
|
imageValue: "Image Value",
|
|
49
65
|
},
|
|
66
|
+
order: {
|
|
67
|
+
orderNumber: "Order Number",
|
|
68
|
+
orderDate: "Order Date",
|
|
69
|
+
customerName: "Customer Name",
|
|
70
|
+
notes: "Notes",
|
|
71
|
+
totalAmount: "Total Amount",
|
|
72
|
+
items: "Order Items",
|
|
73
|
+
},
|
|
74
|
+
orderItem: {
|
|
75
|
+
title: "Order Item",
|
|
76
|
+
productName: "Product Name",
|
|
77
|
+
quantity: "Quantity",
|
|
78
|
+
unitPrice: "Unit Price",
|
|
79
|
+
subtotal: "Subtotal",
|
|
80
|
+
},
|
|
81
|
+
validation: {
|
|
82
|
+
required: "This field is required",
|
|
83
|
+
},
|
|
50
84
|
types: {
|
|
51
85
|
Article: "Article",
|
|
52
86
|
Product: "Product",
|
|
53
87
|
BasicTypeTest: "Basic Type",
|
|
88
|
+
Order: "Order",
|
|
89
|
+
OrderItem: "Order Item",
|
|
54
90
|
},
|
|
55
91
|
};
|
|
56
92
|
|
|
@@ -65,10 +101,19 @@ const zhTWExtended = {
|
|
|
65
101
|
samples: "範例",
|
|
66
102
|
model: "資料模型",
|
|
67
103
|
basicTypeTest: "基本型別",
|
|
104
|
+
order: "主從式資料",
|
|
68
105
|
tiptapTest: "Tiptap 編輯器",
|
|
69
106
|
drawioTest: "Drawio 編輯器",
|
|
70
107
|
fullTextSearch: "全文檢索",
|
|
71
108
|
},
|
|
109
|
+
// Chat translations
|
|
110
|
+
chat: {
|
|
111
|
+
panelTitle: "AI 助手",
|
|
112
|
+
toggleTooltip: "開啟/關閉 AI 聊天",
|
|
113
|
+
placeholder: "輸入訊息...",
|
|
114
|
+
user: "您",
|
|
115
|
+
assistant: "AI",
|
|
116
|
+
},
|
|
72
117
|
// Sample field translations
|
|
73
118
|
article: {
|
|
74
119
|
title: "標題",
|
|
@@ -93,12 +138,39 @@ const zhTWExtended = {
|
|
|
93
138
|
doubleValue: "浮點數",
|
|
94
139
|
dateTimeValue: "日期時間",
|
|
95
140
|
boolValue: "布林值",
|
|
141
|
+
statusValue: "狀態",
|
|
142
|
+
status: {
|
|
143
|
+
Draft: "草稿",
|
|
144
|
+
Active: "啟用",
|
|
145
|
+
Completed: "完成",
|
|
146
|
+
Cancelled: "取消",
|
|
147
|
+
},
|
|
96
148
|
imageValue: "圖片",
|
|
97
149
|
},
|
|
150
|
+
order: {
|
|
151
|
+
orderNumber: "訂單編號",
|
|
152
|
+
orderDate: "訂單日期",
|
|
153
|
+
customerName: "客戶名稱",
|
|
154
|
+
notes: "備註",
|
|
155
|
+
totalAmount: "總金額",
|
|
156
|
+
items: "訂單明細",
|
|
157
|
+
},
|
|
158
|
+
orderItem: {
|
|
159
|
+
title: "訂單明細",
|
|
160
|
+
productName: "產品名稱",
|
|
161
|
+
quantity: "數量",
|
|
162
|
+
unitPrice: "單價",
|
|
163
|
+
subtotal: "小計",
|
|
164
|
+
},
|
|
165
|
+
validation: {
|
|
166
|
+
required: "此欄位為必填",
|
|
167
|
+
},
|
|
98
168
|
types: {
|
|
99
169
|
Article: "文章",
|
|
100
170
|
Product: "產品",
|
|
101
171
|
BasicTypeTest: "基本型別",
|
|
172
|
+
Order: "訂單",
|
|
173
|
+
OrderItem: "訂單明細",
|
|
102
174
|
},
|
|
103
175
|
};
|
|
104
176
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Create, useForm } from "@refinedev/antd";
|
|
3
|
-
import { Form, Input, InputNumber, DatePicker, Switch } from "antd";
|
|
3
|
+
import { Form, Input, InputNumber, DatePicker, Switch, Select } from "antd";
|
|
4
4
|
import { useTranslation } from "react-i18next";
|
|
5
5
|
import { Base64Upload } from "@cundi/refine-xaf";
|
|
6
6
|
import dayjs from "dayjs";
|
|
@@ -56,6 +56,19 @@ export const BasicTypeTestCreate: React.FC = () => {
|
|
|
56
56
|
>
|
|
57
57
|
<Switch />
|
|
58
58
|
</Form.Item>
|
|
59
|
+
<Form.Item
|
|
60
|
+
label={t("basicTypeTest.statusValue")}
|
|
61
|
+
name="StatusValue"
|
|
62
|
+
>
|
|
63
|
+
<Select
|
|
64
|
+
options={[
|
|
65
|
+
{ value: "Draft", label: t("basicTypeTest.status.Draft") },
|
|
66
|
+
{ value: "Active", label: t("basicTypeTest.status.Active") },
|
|
67
|
+
{ value: "Completed", label: t("basicTypeTest.status.Completed") },
|
|
68
|
+
{ value: "Cancelled", label: t("basicTypeTest.status.Cancelled") },
|
|
69
|
+
]}
|
|
70
|
+
/>
|
|
71
|
+
</Form.Item>
|
|
59
72
|
<Form.Item
|
|
60
73
|
label={t("basicTypeTest.imageValue")}
|
|
61
74
|
name="ImageValue"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Edit, useForm } from "@refinedev/antd";
|
|
3
|
-
import { Form, Input, InputNumber, DatePicker, Switch } from "antd";
|
|
3
|
+
import { Form, Input, InputNumber, DatePicker, Switch, Select } from "antd";
|
|
4
4
|
import { useTranslation } from "react-i18next";
|
|
5
5
|
import { Base64Upload } from "@cundi/refine-xaf";
|
|
6
6
|
import dayjs from "dayjs";
|
|
@@ -56,6 +56,19 @@ export const BasicTypeTestEdit: React.FC = () => {
|
|
|
56
56
|
>
|
|
57
57
|
<Switch />
|
|
58
58
|
</Form.Item>
|
|
59
|
+
<Form.Item
|
|
60
|
+
label={t("basicTypeTest.statusValue")}
|
|
61
|
+
name="StatusValue"
|
|
62
|
+
>
|
|
63
|
+
<Select
|
|
64
|
+
options={[
|
|
65
|
+
{ value: "Draft", label: t("basicTypeTest.status.Draft") },
|
|
66
|
+
{ value: "Active", label: t("basicTypeTest.status.Active") },
|
|
67
|
+
{ value: "Completed", label: t("basicTypeTest.status.Completed") },
|
|
68
|
+
{ value: "Cancelled", label: t("basicTypeTest.status.Cancelled") },
|
|
69
|
+
]}
|
|
70
|
+
/>
|
|
71
|
+
</Form.Item>
|
|
59
72
|
<Form.Item
|
|
60
73
|
label={t("basicTypeTest.imageValue")}
|
|
61
74
|
name="ImageValue"
|
|
@@ -46,6 +46,22 @@ export const BasicTypeTestList: React.FC = () => {
|
|
|
46
46
|
// @ts-ignore
|
|
47
47
|
defaultVisible
|
|
48
48
|
/>
|
|
49
|
+
<Table.Column
|
|
50
|
+
dataIndex="StatusValue"
|
|
51
|
+
title={t("basicTypeTest.statusValue")}
|
|
52
|
+
sorter
|
|
53
|
+
render={(value: string) => (
|
|
54
|
+
<Tag color={
|
|
55
|
+
value === "Active" ? "green" :
|
|
56
|
+
value === "Completed" ? "blue" :
|
|
57
|
+
value === "Cancelled" ? "red" : "default"
|
|
58
|
+
}>
|
|
59
|
+
{t(`basicTypeTest.status.${value}`, value)}
|
|
60
|
+
</Tag>
|
|
61
|
+
)}
|
|
62
|
+
// @ts-ignore
|
|
63
|
+
defaultVisible
|
|
64
|
+
/>
|
|
49
65
|
<Table.Column
|
|
50
66
|
dataIndex="ImageValue"
|
|
51
67
|
title={t("basicTypeTest.imageValue")}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Create, useForm } from "@refinedev/antd";
|
|
3
|
+
import { Form, Input, DatePicker } from "antd";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import dayjs from "dayjs";
|
|
6
|
+
|
|
7
|
+
export const OrderCreate: React.FC = () => {
|
|
8
|
+
const { t } = useTranslation();
|
|
9
|
+
const { formProps, saveButtonProps } = useForm({
|
|
10
|
+
redirect: "edit",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Create saveButtonProps={saveButtonProps}>
|
|
15
|
+
<Form {...formProps} layout="vertical">
|
|
16
|
+
<Form.Item
|
|
17
|
+
label={t("order.orderNumber")}
|
|
18
|
+
name="OrderNumber"
|
|
19
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
20
|
+
>
|
|
21
|
+
<Input />
|
|
22
|
+
</Form.Item>
|
|
23
|
+
<Form.Item
|
|
24
|
+
label={t("order.orderDate")}
|
|
25
|
+
name="OrderDate"
|
|
26
|
+
getValueProps={(value) => ({
|
|
27
|
+
value: value ? dayjs(value) : dayjs(),
|
|
28
|
+
})}
|
|
29
|
+
getValueFromEvent={(date) => date?.toISOString()}
|
|
30
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
31
|
+
>
|
|
32
|
+
<DatePicker style={{ width: "100%" }} />
|
|
33
|
+
</Form.Item>
|
|
34
|
+
<Form.Item
|
|
35
|
+
label={t("order.customerName")}
|
|
36
|
+
name="CustomerName"
|
|
37
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
38
|
+
>
|
|
39
|
+
<Input />
|
|
40
|
+
</Form.Item>
|
|
41
|
+
<Form.Item
|
|
42
|
+
label={t("order.notes")}
|
|
43
|
+
name="Notes"
|
|
44
|
+
>
|
|
45
|
+
<Input.TextArea rows={3} />
|
|
46
|
+
</Form.Item>
|
|
47
|
+
</Form>
|
|
48
|
+
</Create>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Edit, useForm } from "@refinedev/antd";
|
|
3
|
+
import { Form, Input, DatePicker, InputNumber, Card } from "antd";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import { RelatedList } from "@cundi/refine-xaf";
|
|
6
|
+
import { useTable } from "@refinedev/antd";
|
|
7
|
+
import { Table } from "antd";
|
|
8
|
+
import dayjs from "dayjs";
|
|
9
|
+
|
|
10
|
+
// OrderItem Form Fields Component
|
|
11
|
+
const OrderItemFormFields: React.FC<{ mode: "create" | "edit" }> = () => {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<Form.Item
|
|
17
|
+
label={t("orderItem.productName")}
|
|
18
|
+
name="ProductName"
|
|
19
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
20
|
+
>
|
|
21
|
+
<Input />
|
|
22
|
+
</Form.Item>
|
|
23
|
+
<Form.Item
|
|
24
|
+
label={t("orderItem.quantity")}
|
|
25
|
+
name="Quantity"
|
|
26
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
27
|
+
>
|
|
28
|
+
<InputNumber min={1} style={{ width: "100%" }} />
|
|
29
|
+
</Form.Item>
|
|
30
|
+
<Form.Item
|
|
31
|
+
label={t("orderItem.unitPrice")}
|
|
32
|
+
name="UnitPrice"
|
|
33
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
34
|
+
>
|
|
35
|
+
<InputNumber min={0} precision={2} style={{ width: "100%" }} prefix="$" />
|
|
36
|
+
</Form.Item>
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const OrderEdit: React.FC = () => {
|
|
42
|
+
const { t } = useTranslation();
|
|
43
|
+
const { formProps, saveButtonProps, query } = useForm({
|
|
44
|
+
meta: {
|
|
45
|
+
expand: ["Items"],
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const orderId = query?.data?.data?.Oid as string | undefined;
|
|
50
|
+
|
|
51
|
+
// Fetch order items for the RelatedList
|
|
52
|
+
const { tableProps } = useTable({
|
|
53
|
+
resource: "OrderItem",
|
|
54
|
+
filters: {
|
|
55
|
+
permanent: [
|
|
56
|
+
{ field: "Order/Oid", operator: "eq", value: orderId || "" }
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
syncWithLocation: false,
|
|
60
|
+
queryOptions: {
|
|
61
|
+
enabled: !!orderId,
|
|
62
|
+
},
|
|
63
|
+
pagination: {
|
|
64
|
+
mode: "off"
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Convert readonly array to mutable array for RelatedList
|
|
69
|
+
const dataSource = tableProps.dataSource ? [...tableProps.dataSource] : [];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Edit saveButtonProps={saveButtonProps}>
|
|
73
|
+
<Form {...formProps} layout="vertical">
|
|
74
|
+
<Form.Item
|
|
75
|
+
label={t("order.orderNumber")}
|
|
76
|
+
name="OrderNumber"
|
|
77
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
78
|
+
>
|
|
79
|
+
<Input />
|
|
80
|
+
</Form.Item>
|
|
81
|
+
<Form.Item
|
|
82
|
+
label={t("order.orderDate")}
|
|
83
|
+
name="OrderDate"
|
|
84
|
+
getValueProps={(value) => ({
|
|
85
|
+
value: value ? dayjs(value) : null,
|
|
86
|
+
})}
|
|
87
|
+
getValueFromEvent={(date) => date?.toISOString()}
|
|
88
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
89
|
+
>
|
|
90
|
+
<DatePicker style={{ width: "100%" }} />
|
|
91
|
+
</Form.Item>
|
|
92
|
+
<Form.Item
|
|
93
|
+
label={t("order.customerName")}
|
|
94
|
+
name="CustomerName"
|
|
95
|
+
rules={[{ required: true, message: t("validation.required") }]}
|
|
96
|
+
>
|
|
97
|
+
<Input />
|
|
98
|
+
</Form.Item>
|
|
99
|
+
<Form.Item
|
|
100
|
+
label={t("order.notes")}
|
|
101
|
+
name="Notes"
|
|
102
|
+
>
|
|
103
|
+
<Input.TextArea rows={3} />
|
|
104
|
+
</Form.Item>
|
|
105
|
+
</Form>
|
|
106
|
+
|
|
107
|
+
{/* Order Items (Details) */}
|
|
108
|
+
<Card title={t("order.items")} style={{ marginTop: 16 }}>
|
|
109
|
+
<RelatedList
|
|
110
|
+
resource="OrderItem"
|
|
111
|
+
masterField="Order"
|
|
112
|
+
masterId={orderId}
|
|
113
|
+
dataSource={dataSource}
|
|
114
|
+
FormFields={OrderItemFormFields}
|
|
115
|
+
modalTitle={t("orderItem.title")}
|
|
116
|
+
>
|
|
117
|
+
<Table.Column
|
|
118
|
+
dataIndex="ProductName"
|
|
119
|
+
title={t("orderItem.productName")}
|
|
120
|
+
/>
|
|
121
|
+
<Table.Column
|
|
122
|
+
dataIndex="Quantity"
|
|
123
|
+
title={t("orderItem.quantity")}
|
|
124
|
+
/>
|
|
125
|
+
<Table.Column
|
|
126
|
+
dataIndex="UnitPrice"
|
|
127
|
+
title={t("orderItem.unitPrice")}
|
|
128
|
+
render={(value: number) => value ? `$${value.toFixed(2)}` : "-"}
|
|
129
|
+
/>
|
|
130
|
+
<Table.Column
|
|
131
|
+
dataIndex="Subtotal"
|
|
132
|
+
title={t("orderItem.subtotal")}
|
|
133
|
+
render={(value: number) => value ? `$${value.toFixed(2)}` : "-"}
|
|
134
|
+
/>
|
|
135
|
+
</RelatedList>
|
|
136
|
+
</Card>
|
|
137
|
+
</Edit>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BaseRecord } from "@refinedev/core";
|
|
3
|
+
import { EditButton, ShowButton, DeleteButton } from "@refinedev/antd";
|
|
4
|
+
import { Table, Space, Tag } from "antd";
|
|
5
|
+
import { SmartList } from "@cundi/refine-xaf";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
|
|
8
|
+
export const OrderList: React.FC = () => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<SmartList
|
|
13
|
+
resource="Order"
|
|
14
|
+
searchFields={["OrderNumber", "CustomerName"]}
|
|
15
|
+
>
|
|
16
|
+
<Table.Column
|
|
17
|
+
dataIndex="OrderNumber"
|
|
18
|
+
title={t("order.orderNumber")}
|
|
19
|
+
sorter
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
defaultVisible
|
|
22
|
+
/>
|
|
23
|
+
<Table.Column
|
|
24
|
+
dataIndex="OrderDate"
|
|
25
|
+
title={t("order.orderDate")}
|
|
26
|
+
sorter
|
|
27
|
+
render={(value: string) => value ? new Date(value).toLocaleDateString() : "-"}
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
defaultVisible
|
|
30
|
+
/>
|
|
31
|
+
<Table.Column
|
|
32
|
+
dataIndex="CustomerName"
|
|
33
|
+
title={t("order.customerName")}
|
|
34
|
+
sorter
|
|
35
|
+
// @ts-ignore
|
|
36
|
+
defaultVisible
|
|
37
|
+
/>
|
|
38
|
+
<Table.Column
|
|
39
|
+
dataIndex="TotalAmount"
|
|
40
|
+
title={t("order.totalAmount")}
|
|
41
|
+
sorter
|
|
42
|
+
render={(value: number) => value ? `$${value.toFixed(2)}` : "$0.00"}
|
|
43
|
+
// @ts-ignore
|
|
44
|
+
defaultVisible
|
|
45
|
+
/>
|
|
46
|
+
<Table.Column
|
|
47
|
+
title={t("buttons.list")}
|
|
48
|
+
dataIndex="actions"
|
|
49
|
+
render={(_, record: BaseRecord) => (
|
|
50
|
+
<Space>
|
|
51
|
+
<EditButton hideText size="small" recordItemId={record.Oid} />
|
|
52
|
+
<ShowButton hideText size="small" recordItemId={record.Oid} />
|
|
53
|
+
<DeleteButton hideText size="small" recordItemId={record.Oid} />
|
|
54
|
+
</Space>
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
</SmartList>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Show } from "@refinedev/antd";
|
|
3
|
+
import { useShow } from "@refinedev/core";
|
|
4
|
+
import { Typography, Table, Card, Descriptions } from "antd";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
const { Title } = Typography;
|
|
8
|
+
|
|
9
|
+
export const OrderShow: React.FC = () => {
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
const { query } = useShow({
|
|
12
|
+
meta: {
|
|
13
|
+
expand: ["Items"],
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
const { data, isLoading } = query;
|
|
17
|
+
const record = data?.data;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Show isLoading={isLoading}>
|
|
21
|
+
<Descriptions bordered column={1}>
|
|
22
|
+
<Descriptions.Item label={t("order.orderNumber")}>
|
|
23
|
+
{record?.OrderNumber}
|
|
24
|
+
</Descriptions.Item>
|
|
25
|
+
<Descriptions.Item label={t("order.orderDate")}>
|
|
26
|
+
{record?.OrderDate ? new Date(record.OrderDate).toLocaleDateString() : "-"}
|
|
27
|
+
</Descriptions.Item>
|
|
28
|
+
<Descriptions.Item label={t("order.customerName")}>
|
|
29
|
+
{record?.CustomerName}
|
|
30
|
+
</Descriptions.Item>
|
|
31
|
+
<Descriptions.Item label={t("order.totalAmount")}>
|
|
32
|
+
${record?.TotalAmount?.toFixed(2) || "0.00"}
|
|
33
|
+
</Descriptions.Item>
|
|
34
|
+
<Descriptions.Item label={t("order.notes")}>
|
|
35
|
+
{record?.Notes || "-"}
|
|
36
|
+
</Descriptions.Item>
|
|
37
|
+
</Descriptions>
|
|
38
|
+
|
|
39
|
+
<Card title={t("order.items")} style={{ marginTop: 16 }}>
|
|
40
|
+
<Table
|
|
41
|
+
dataSource={record?.Items || []}
|
|
42
|
+
rowKey="Oid"
|
|
43
|
+
pagination={false}
|
|
44
|
+
bordered
|
|
45
|
+
size="small"
|
|
46
|
+
>
|
|
47
|
+
<Table.Column
|
|
48
|
+
dataIndex="ProductName"
|
|
49
|
+
title={t("orderItem.productName")}
|
|
50
|
+
/>
|
|
51
|
+
<Table.Column
|
|
52
|
+
dataIndex="Quantity"
|
|
53
|
+
title={t("orderItem.quantity")}
|
|
54
|
+
/>
|
|
55
|
+
<Table.Column
|
|
56
|
+
dataIndex="UnitPrice"
|
|
57
|
+
title={t("orderItem.unitPrice")}
|
|
58
|
+
render={(value: number) => value ? `$${value.toFixed(2)}` : "-"}
|
|
59
|
+
/>
|
|
60
|
+
<Table.Column
|
|
61
|
+
dataIndex="Subtotal"
|
|
62
|
+
title={t("orderItem.subtotal")}
|
|
63
|
+
render={(value: number) => value ? `$${value.toFixed(2)}` : "-"}
|
|
64
|
+
/>
|
|
65
|
+
</Table>
|
|
66
|
+
</Card>
|
|
67
|
+
</Show>
|
|
68
|
+
);
|
|
69
|
+
};
|