@zhin.js/console 1.0.5 → 1.0.7

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/app/index.ts ADDED
@@ -0,0 +1,419 @@
1
+ import { register, useContext, useApp } from "@zhin.js/core";
2
+ import react from "@vitejs/plugin-react";
3
+ import WebSocket, { WebSocketServer } from "ws";
4
+ import { createServer, ViteDevServer, searchForWorkspaceRoot } from "vite";
5
+ import connect from "koa-connect";
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { fileURLToPath } from "node:url";
9
+ import tailwindcss from "@tailwindcss/vite";
10
+
11
+ declare module "@zhin.js/types" {
12
+ interface GlobalContext {
13
+ web: WebServer;
14
+ }
15
+ }
16
+ export type WebEntry =
17
+ | string
18
+ | {
19
+ production: string;
20
+ development: string;
21
+ };
22
+ export type WebServer = {
23
+ vite: ViteDevServer;
24
+ addEntry(entry: WebEntry): () => void;
25
+ entries: Record<string, string>;
26
+ ws: WebSocketServer;
27
+ };
28
+ const createSyncMsg = (key: string, value: any) => {
29
+ return {
30
+ type: "sync",
31
+ data: {
32
+ key,
33
+ value,
34
+ },
35
+ };
36
+ };
37
+ const createAddMsg = (key: string, value: any) => {
38
+ return {
39
+ type: "add",
40
+ data: {
41
+ key,
42
+ value,
43
+ },
44
+ };
45
+ };
46
+ const createDeleteMsg = (key: string, value: any) => {
47
+ return {
48
+ type: "delete",
49
+ data: {
50
+ key,
51
+ value,
52
+ },
53
+ };
54
+ };
55
+ useContext("router", async (router) => {
56
+ const root = path.join(import.meta.dirname, "../client");
57
+ const base = "/vite/";
58
+
59
+ const vite = await createServer({
60
+ root,
61
+ base,
62
+ plugins: [react(), tailwindcss()],
63
+ server: {
64
+ middlewareMode: true,
65
+ fs: {
66
+ strict: false,
67
+ },
68
+ },
69
+ resolve: {
70
+ dedupe: [
71
+ "react",
72
+ "react-dom",
73
+ "clsx",
74
+ "tailwind-merge",
75
+ "@reduxjs/toolkit",
76
+ "react-router",
77
+ "react-redux",
78
+ "redux-persist",
79
+ ],
80
+ alias: {
81
+ "@zhin.js/client": path.resolve(root, "../../client/client"),
82
+ "@": path.resolve(root, "../client/src"),
83
+ },
84
+ },
85
+ optimizeDeps: {
86
+ include: ["react", "react-dom"],
87
+ },
88
+ build: {
89
+ rollupOptions: {
90
+ input: root + "/index.html",
91
+ },
92
+ },
93
+ });
94
+
95
+ // Vite 中间件 - 必须在其他路由之前
96
+ router.use((ctx: any, next: any) => {
97
+ if (ctx.request.originalUrl.startsWith("/api")) return next();
98
+ return connect(vite.middlewares)(ctx, next);
99
+ });
100
+
101
+ // SPA 回退路由 - 处理所有未匹配的路由
102
+ router.all("*all", async (ctx, next) => {
103
+ const url = ctx.request.originalUrl.replace(base, "");
104
+ const name = ctx.path.slice(1);
105
+
106
+ const sendFile = (filename: string) => {
107
+ ctx.type = path.extname(filename);
108
+ if (filename.endsWith(".ts")) ctx.type = "text/javascript";
109
+ return (ctx.body = fs.createReadStream(filename));
110
+ };
111
+
112
+ // 1. 检查是否是动态入口
113
+ if (Object.keys(webServer.entries).includes(name)) {
114
+ return sendFile(path.resolve(process.cwd(), webServer.entries[name]));
115
+ }
116
+
117
+ // 2. 检查是否是静态文件
118
+ const filename = path.resolve(root, name);
119
+ if (filename.startsWith(root) || filename.includes("node_modules")) {
120
+ if (fs.existsSync(filename)) {
121
+ const fileState = fs.statSync(filename);
122
+ if (fileState.isFile()) {
123
+ return sendFile(filename);
124
+ }
125
+ }
126
+ } else {
127
+ // 安全检查:路径不在允许范围内
128
+ return (ctx.status = 403);
129
+ }
130
+
131
+ // 3. 所有其他路径(包括 SPA 路由)都返回 index.html
132
+ // 这样前端路由可以正确处理
133
+ const template = fs.readFileSync(path.resolve(root, "index.html"), "utf8");
134
+ ctx.type = "html";
135
+ ctx.body = await vite.transformIndexHtml(url, template);
136
+ });
137
+
138
+ const webServer: WebServer = {
139
+ vite,
140
+ entries: {},
141
+ addEntry(entry) {
142
+ const hash =
143
+ Date.now().toString(16) + Math.random().toString(16).slice(2, 8);
144
+ const entryFile =
145
+ typeof entry === "string"
146
+ ? entry
147
+ : entry[
148
+ (process.env.NODE_ENV as "development" | "production") ||
149
+ "development"
150
+ ];
151
+ this.entries[hash] = `/vite/@fs/${entryFile}`;
152
+ for (const ws of this.ws.clients || []) {
153
+ ws.send(JSON.stringify(createAddMsg("entries", this.entries[hash])));
154
+ }
155
+ return () => {
156
+ for (const ws of this.ws.clients || []) {
157
+ ws.send(
158
+ JSON.stringify(createDeleteMsg("entries", this.entries[hash]))
159
+ );
160
+ }
161
+ delete this.entries[hash];
162
+ };
163
+ },
164
+ ws: router.ws("/server"),
165
+ };
166
+ // 数据推送函数
167
+ const broadcastToAll = (message: any) => {
168
+ for (const ws of webServer.ws.clients || []) {
169
+ if (ws.readyState === WebSocket.OPEN) {
170
+ ws.send(JSON.stringify(message));
171
+ }
172
+ }
173
+ };
174
+
175
+ // 推送数据更新通知
176
+ const notifyDataUpdate = () => {
177
+ broadcastToAll({
178
+ type: "data-update",
179
+ timestamp: Date.now(),
180
+ });
181
+ };
182
+
183
+ // WebSocket 连接处理
184
+ webServer.ws.on("connection", (ws: WebSocket) => {
185
+ // 发送初始数据
186
+ ws.send(
187
+ JSON.stringify(
188
+ createSyncMsg(
189
+ "entries",
190
+ Array.from(new Set(Object.values(webServer.entries)))
191
+ )
192
+ )
193
+ );
194
+
195
+ // 通知客户端进行数据初始化
196
+ ws.send(
197
+ JSON.stringify({
198
+ type: "init-data",
199
+ timestamp: Date.now(),
200
+ })
201
+ );
202
+
203
+ // 处理 WebSocket 消息
204
+ ws.on("message", async (data) => {
205
+ try {
206
+ const message = JSON.parse(data.toString());
207
+ const { type, pluginName, requestId } = message;
208
+
209
+ // 获取应用实例
210
+ const app = useApp();
211
+
212
+ switch (type) {
213
+ case "config:get":
214
+ try {
215
+ let config;
216
+ if (pluginName === "app") {
217
+ config = app.getConfig();
218
+ } else {
219
+ const plugin = app.findPluginByName(pluginName);
220
+ if (!plugin) {
221
+ throw new Error(`Plugin ${pluginName} not found`);
222
+ }
223
+ config = plugin.config;
224
+ }
225
+
226
+ ws.send(
227
+ JSON.stringify({
228
+ requestId,
229
+ data: config,
230
+ })
231
+ );
232
+ } catch (error) {
233
+ ws.send(
234
+ JSON.stringify({
235
+ requestId,
236
+ error: (error as Error).message,
237
+ })
238
+ );
239
+ }
240
+ break;
241
+
242
+ case "config:set":
243
+ try {
244
+ const { data: newConfig } = message;
245
+
246
+ if (pluginName === "app") {
247
+ app.config = newConfig;
248
+ } else {
249
+ const plugin = app.findPluginByName(pluginName);
250
+ if (!plugin) {
251
+ throw new Error(`Plugin ${pluginName} not found`);
252
+ }
253
+ plugin.config = newConfig;
254
+ }
255
+
256
+ // 响应成功
257
+ ws.send(
258
+ JSON.stringify({
259
+ requestId,
260
+ data: "success",
261
+ })
262
+ );
263
+
264
+ // 广播配置更新
265
+ webServer.ws.clients.forEach((client) => {
266
+ if (client.readyState === 1) {
267
+ // WebSocket.OPEN
268
+ client.send(
269
+ JSON.stringify({
270
+ type: "config:updated",
271
+ pluginName,
272
+ data: newConfig,
273
+ })
274
+ );
275
+ }
276
+ });
277
+ } catch (error) {
278
+ ws.send(
279
+ JSON.stringify({
280
+ requestId,
281
+ error: (error as Error).message,
282
+ })
283
+ );
284
+ }
285
+ break;
286
+
287
+ case "schema:get":
288
+ try {
289
+ let schema;
290
+ if (pluginName === "app") {
291
+ schema = app.schema?.toJSON();
292
+ } else {
293
+ const plugin = app.findPluginByName(pluginName);
294
+ if (!plugin) {
295
+ throw new Error(`Plugin ${pluginName} not found`);
296
+ }
297
+ schema = plugin.schema?.toJSON();
298
+ }
299
+
300
+ ws.send(
301
+ JSON.stringify({
302
+ requestId,
303
+ data: schema,
304
+ })
305
+ );
306
+ } catch (error) {
307
+ ws.send(
308
+ JSON.stringify({
309
+ requestId,
310
+ error: (error as Error).message,
311
+ })
312
+ );
313
+ }
314
+ break;
315
+
316
+ case "config:get-all":
317
+ try {
318
+ const configs: Record<string, any> = {};
319
+
320
+ // 获取 App 配置
321
+ configs["app"] = app.getConfig();
322
+
323
+ // 获取所有插件配置
324
+ for (const plugin of app.dependencyList) {
325
+ if (plugin.config && Object.keys(plugin.config).length > 0) {
326
+ configs[plugin.name] = plugin.config;
327
+ }
328
+ }
329
+
330
+ ws.send(
331
+ JSON.stringify({
332
+ requestId,
333
+ data: configs,
334
+ })
335
+ );
336
+ } catch (error) {
337
+ ws.send(
338
+ JSON.stringify({
339
+ requestId,
340
+ error: (error as Error).message,
341
+ })
342
+ );
343
+ }
344
+ break;
345
+
346
+ case "schema:get-all":
347
+ try {
348
+ const schemas: Record<string, any> = {};
349
+
350
+ // 获取 App Schema
351
+ const appSchema = app.schema?.toJSON();
352
+ if (appSchema) {
353
+ schemas["app"] = appSchema;
354
+ }
355
+
356
+ // 获取所有插件 Schema
357
+ for (const plugin of app.dependencyList) {
358
+ const schema = plugin.schema?.toJSON();
359
+ if (schema) {
360
+ schemas[plugin.name] = schema;
361
+ }
362
+ }
363
+
364
+ ws.send(
365
+ JSON.stringify({
366
+ requestId,
367
+ data: schemas,
368
+ })
369
+ );
370
+ } catch (error) {
371
+ ws.send(
372
+ JSON.stringify({
373
+ requestId,
374
+ error: (error as Error).message,
375
+ })
376
+ );
377
+ }
378
+ break;
379
+
380
+ // 其他消息类型保持不变,让 console 插件自己处理
381
+ }
382
+ } catch (error) {
383
+ console.error("WebSocket 消息处理错误:", error);
384
+ ws.send(
385
+ JSON.stringify({
386
+ error: "Invalid message format",
387
+ })
388
+ );
389
+ }
390
+ });
391
+
392
+ ws.on("close", () => {});
393
+
394
+ ws.on("error", (error) => {
395
+ // console.error 已替换为注释
396
+ });
397
+ });
398
+
399
+ // 定时通知客户端更新数据
400
+ const dataUpdateInterval = setInterval(() => {
401
+ notifyDataUpdate();
402
+ }, 5000); // 每5秒通知一次更新
403
+
404
+ // 插件卸载时清理定时器
405
+ process.on("exit", () => {
406
+ clearInterval(dataUpdateInterval);
407
+ });
408
+ register({
409
+ name: "web",
410
+ description: "web服务",
411
+ async mounted() {
412
+ return webServer;
413
+ },
414
+ async dispose(server) {
415
+ await server.vite.close();
416
+ server.ws.close();
417
+ },
418
+ });
419
+ });
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang='en'>
3
+ <head>
4
+ <meta charset='UTF-8'>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Zhin</title>
7
+ </head>
8
+ <body>
9
+ <noscript>You need to enable JavaScript to run this app.</noscript>
10
+ <div id="root"></div>
11
+ <script src='/src/main.tsx' type='module'></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * 基础类型字段渲染器
3
+ * 处理: string, number, boolean, percent, date, regexp, const
4
+ */
5
+
6
+ import { Flex, Box, Text, TextField, TextArea, Switch, Select, Badge, Callout } from '@radix-ui/themes'
7
+ import { Icons } from '@zhin.js/client'
8
+ import type { FieldRendererProps } from './types.js'
9
+
10
+ export function StringFieldRenderer({ field, value, onChange }: FieldRendererProps) {
11
+ // 枚举类型 - 下拉选择 - 优化样式
12
+ if (field.enum) {
13
+ return (
14
+ <Select.Root
15
+ size="2"
16
+ value={value?.toString() || ''}
17
+ onValueChange={onChange}
18
+ >
19
+ <Select.Trigger className="w-full hover:border-blue-500 dark:hover:border-blue-400 transition-colors" />
20
+ <Select.Content className="shadow-lg">
21
+ {field.enum.map((option) => (
22
+ <Select.Item
23
+ key={option}
24
+ value={option.toString()}
25
+ className="hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
26
+ >
27
+ {option.toString()}
28
+ </Select.Item>
29
+ ))}
30
+ </Select.Content>
31
+ </Select.Root>
32
+ )
33
+ }
34
+
35
+ // 多行文本 - 优化样式
36
+ if (field.description?.includes('多行') || field.key?.includes('description')) {
37
+ return (
38
+ <TextArea
39
+ size="2"
40
+ value={value || ''}
41
+ onChange={(e) => onChange(e.target.value)}
42
+ placeholder={field.description || `请输入`}
43
+ rows={3}
44
+ className="font-sans resize-y hover:border-blue-500 dark:hover:border-blue-400 transition-colors focus:ring-2 focus:ring-blue-500/20"
45
+ />
46
+ )
47
+ }
48
+
49
+ // 单行文本 - 优化样式
50
+ return (
51
+ <TextField.Root
52
+ size="2"
53
+ value={value || ''}
54
+ onChange={(e) => onChange(e.target.value)}
55
+ placeholder={field.description || `请输入`}
56
+ className="hover:border-blue-500 dark:hover:border-blue-400 transition-colors focus-within:ring-2 focus-within:ring-blue-500/20"
57
+ />
58
+ )
59
+ }
60
+
61
+ export function NumberFieldRenderer({ field, value, onChange }: FieldRendererProps) {
62
+ return (
63
+ <TextField.Root
64
+ size="2"
65
+ type="number"
66
+ value={value?.toString() || ''}
67
+ onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
68
+ placeholder={field.description || `请输入数字`}
69
+ min={field.min}
70
+ max={field.max}
71
+ className="hover:border-blue-500 dark:hover:border-blue-400 transition-colors focus-within:ring-2 focus-within:ring-blue-500/20"
72
+ />
73
+ )
74
+ }
75
+
76
+ export function BooleanFieldRenderer({ value, onChange }: FieldRendererProps) {
77
+ return (
78
+ <Flex align="center" gap="3" className="p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800">
79
+ <Switch
80
+ size="2"
81
+ checked={value === true}
82
+ onCheckedChange={onChange}
83
+ className="cursor-pointer"
84
+ />
85
+ <Flex direction="column" gap="1">
86
+ <Text size="2" weight="bold" color={value ? 'green' : 'gray'}>
87
+ {value ? '已启用' : '已禁用'}
88
+ </Text>
89
+ <Text size="1" color="gray">
90
+ {value ? '功能当前处于开启状态' : '功能当前处于关闭状态'}
91
+ </Text>
92
+ </Flex>
93
+ </Flex>
94
+ )
95
+ }
96
+
97
+ export function PercentFieldRenderer({ field, value, onChange }: FieldRendererProps) {
98
+ const percentValue = typeof value === 'number' ? value : (field.default || 0)
99
+
100
+ return (
101
+ <div className="p-4 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 border border-blue-200 dark:border-blue-800">
102
+ <Flex direction="column" gap="3">
103
+ <Flex align="center" gap="3">
104
+ <input
105
+ type="range"
106
+ min={field.min || 0}
107
+ max={field.max || 1}
108
+ step={field.step || 0.01}
109
+ value={percentValue}
110
+ onChange={(e) => onChange(parseFloat(e.target.value))}
111
+ className="flex-1 h-2 rounded-lg appearance-none cursor-pointer bg-blue-200 dark:bg-blue-800 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 dark:[&::-webkit-slider-thumb]:bg-blue-400 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700 dark:hover:[&::-webkit-slider-thumb]:bg-blue-300 transition-all"
112
+ />
113
+ <TextField.Root
114
+ size="2"
115
+ type="number"
116
+ value={(percentValue * 100).toFixed(0)}
117
+ onChange={(e) => onChange(parseFloat(e.target.value) / 100)}
118
+ min={(field.min || 0) * 100}
119
+ max={(field.max || 1) * 100}
120
+ className="w-24"
121
+ >
122
+ <TextField.Slot side="right">
123
+ <Text size="1" weight="bold" className="text-blue-600 dark:text-blue-400">%</Text>
124
+ </TextField.Slot>
125
+ </TextField.Root>
126
+ </Flex>
127
+ <Flex align="center" justify="between">
128
+ <Text size="1" color="gray">
129
+ 当前值
130
+ </Text>
131
+ <Badge color="blue" size="2" variant="soft" className="font-mono">
132
+ {(percentValue * 100).toFixed(1)}%
133
+ </Badge>
134
+ </Flex>
135
+ </Flex>
136
+ </div>
137
+ )
138
+ }
139
+
140
+ export function DateFieldRenderer({ field, value, onChange }: FieldRendererProps) {
141
+ const dateValue = value instanceof Date
142
+ ? value.toISOString().split('T')[0]
143
+ : value || ''
144
+
145
+ return (
146
+ <div className="relative">
147
+ <TextField.Root
148
+ size="2"
149
+ type="date"
150
+ value={dateValue}
151
+ onChange={(e) => onChange(new Date(e.target.value))}
152
+ placeholder={field.description || '选择日期'}
153
+ className="hover:border-blue-500 dark:hover:border-blue-400 transition-colors focus-within:ring-2 focus-within:ring-blue-500/20"
154
+ >
155
+ <TextField.Slot side="left">
156
+ <Icons.Calendar className="w-4 h-4 text-gray-500" />
157
+ </TextField.Slot>
158
+ </TextField.Root>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ export function RegexpFieldRenderer({ field, value, onChange }: FieldRendererProps) {
164
+ const regexpValue = value instanceof RegExp
165
+ ? value.source
166
+ : (typeof value === 'string' ? value : '')
167
+
168
+ return (
169
+ <div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
170
+ <Flex direction="column" gap="2">
171
+ <TextField.Root
172
+ size="2"
173
+ value={regexpValue}
174
+ onChange={(e) => {
175
+ try {
176
+ onChange(new RegExp(e.target.value))
177
+ } catch {
178
+ onChange(e.target.value)
179
+ }
180
+ }}
181
+ placeholder={field.description || '请输入正则表达式'}
182
+ className="font-mono text-sm hover:border-amber-500 dark:hover:border-amber-400 transition-colors"
183
+ >
184
+ <TextField.Slot side="left">
185
+ <Text size="1" className="text-amber-600 dark:text-amber-400 font-bold">/</Text>
186
+ </TextField.Slot>
187
+ <TextField.Slot side="right">
188
+ <Text size="1" className="text-amber-600 dark:text-amber-400 font-bold">/</Text>
189
+ </TextField.Slot>
190
+ </TextField.Root>
191
+ <Flex align="center" gap="2">
192
+ <Icons.Info className="w-3 h-3 text-amber-600 dark:text-amber-400" />
193
+ <Text size="1" className="text-amber-700 dark:text-amber-300">
194
+ 输入正则表达式模式 (省略斜杠)
195
+ </Text>
196
+ </Flex>
197
+ </Flex>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ export function ConstFieldRenderer({ field, value }: FieldRendererProps) {
203
+ const constValue = field.default || value
204
+
205
+ return (
206
+ <div className="p-3 rounded-lg bg-gray-100 dark:bg-gray-900 border border-gray-300 dark:border-gray-700">
207
+ <Flex align="center" gap="3">
208
+ <Icons.Lock className="w-4 h-4 text-gray-500 dark:text-gray-400" />
209
+ <Badge variant="soft" size="2" className="font-mono">
210
+ {String(constValue)}
211
+ </Badge>
212
+ <Text size="1" color="gray" className="ml-auto">
213
+ (常量,不可修改)
214
+ </Text>
215
+ </Flex>
216
+ </div>
217
+ )
218
+ }
219
+
220
+ export function AnyFieldRenderer({ field, value, onChange }: FieldRendererProps) {
221
+ return (
222
+ <div className="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
223
+ <Flex direction="column" gap="3">
224
+ <Flex align="center" gap="2">
225
+ <Icons.Code className="w-4 h-4 text-purple-600 dark:text-purple-400" />
226
+ <Text size="1" weight="bold" className="text-purple-700 dark:text-purple-300">
227
+ JSON 格式输入
228
+ </Text>
229
+ </Flex>
230
+ <TextArea
231
+ size="2"
232
+ value={typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value || '')}
233
+ onChange={(e) => {
234
+ try {
235
+ onChange(JSON.parse(e.target.value))
236
+ } catch {
237
+ onChange(e.target.value)
238
+ }
239
+ }}
240
+ placeholder={field.description || '支持任意类型 (JSON 格式)'}
241
+ rows={4}
242
+ className="font-mono text-sm bg-white dark:bg-gray-950 hover:border-purple-500 dark:hover:border-purple-400 transition-colors"
243
+ />
244
+ <Flex align="center" gap="2">
245
+ <Icons.Info className="w-3 h-3 text-purple-600 dark:text-purple-400" />
246
+ <Text size="1" className="text-purple-700 dark:text-purple-300">
247
+ 支持: 字符串、数字、布尔值、对象、数组
248
+ </Text>
249
+ </Flex>
250
+ </Flex>
251
+ </div>
252
+ )
253
+ }