@zhin.js/client 1.0.2 → 1.0.4
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 +117 -3
- package/app/src/components/PluginConfigForm/BasicFieldRenderers.tsx +253 -0
- package/app/src/components/PluginConfigForm/CollectionFieldRenderers.tsx +261 -0
- package/app/src/components/PluginConfigForm/CompositeFieldRenderers.tsx +105 -0
- package/app/src/components/PluginConfigForm/FieldRenderer.tsx +110 -0
- package/app/src/components/PluginConfigForm/NestedFieldRenderer.tsx +95 -0
- package/app/src/components/PluginConfigForm/index.tsx +237 -0
- package/app/src/components/PluginConfigForm/types.ts +46 -0
- package/app/src/main.tsx +1 -1
- package/app/src/pages/dashboard-plugin-detail.tsx +22 -22
- package/dist/index.js +0 -1
- package/package.json +6 -4
- package/src/index.ts +0 -1
- package/src/store/index.ts +22 -1
- package/src/store/reducers/config.ts +135 -0
- package/src/store/reducers/index.ts +4 -1
- package/src/websocket/hooks.ts +280 -0
- package/src/websocket/index.ts +45 -190
- package/src/websocket/instance.ts +46 -0
- package/src/websocket/manager.ts +412 -0
- package/src/websocket/messageHandler.ts +166 -0
- package/src/websocket/types.ts +208 -0
- package/src/websocket/useWebSocket.ts +0 -42
package/README.md
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
# Zhin Client -
|
|
1
|
+
# Zhin Client - 动态页面路由与插件配置系统
|
|
2
2
|
|
|
3
|
-
基于 React Router 7.0
|
|
3
|
+
基于 React Router 7.0 的动态页面管理系统,集成插件配置功能,支持基于 Schema 的自动表单生成。
|
|
4
4
|
|
|
5
5
|
## 特性
|
|
6
6
|
|
|
7
|
+
### 路由系统
|
|
7
8
|
- 🌳 **树形路由结构** - 使用树形结构管理页面路由,支持任意深度的嵌套
|
|
8
9
|
- ✅ **动态页面管理** - 运行时添加、删除、更新页面
|
|
9
10
|
- ✅ **React Router 7.0** - 使用最新的 React Router
|
|
10
11
|
- ✅ **TypeScript 支持** - 完整的类型定义
|
|
11
12
|
- ✅ **WebSocket 集成** - 支持动态加载插件入口脚本
|
|
12
13
|
- ✅ **Redux 状态管理** - 集成 Redux 持久化
|
|
13
|
-
|
|
14
|
+
|
|
15
|
+
### 配置系统
|
|
16
|
+
- 🔧 **Schema 驱动** - 支持 15 种 Schema 数据类型
|
|
17
|
+
- 📝 **自动表单生成** - 根据 Schema 自动生成配置表单
|
|
18
|
+
- 🎨 **智能 UI 组件** - 针对不同类型自动选择最佳 UI 控件
|
|
19
|
+
- 🔄 **实时配置更新** - 支持配置文件的实时读取和保存
|
|
20
|
+
- 🧩 **模块化设计** - 17 个独立的字段渲染器,易于扩展
|
|
14
21
|
|
|
15
22
|
## 安装
|
|
16
23
|
|
|
@@ -361,6 +368,113 @@ routerManager.onRouteAdd((route) => {
|
|
|
361
368
|
3. **性能考虑** - 大量路由时考虑使用懒加载
|
|
362
369
|
4. **类型安全** - 使用 TypeScript 确保类型安全
|
|
363
370
|
|
|
371
|
+
## 插件配置系统
|
|
372
|
+
|
|
373
|
+
### Schema 支持的数据类型
|
|
374
|
+
|
|
375
|
+
Zhin Client 配置系统完整支持所有 15 种 Schema 数据类型:
|
|
376
|
+
|
|
377
|
+
#### 基础类型
|
|
378
|
+
- `string` - 字符串(支持枚举/多行/单行)
|
|
379
|
+
- `number` / `integer` - 数字(支持 min/max 限制)
|
|
380
|
+
- `boolean` - 布尔值(开关控件)
|
|
381
|
+
|
|
382
|
+
#### 特殊类型
|
|
383
|
+
- `percent` - 百分比(滑块 + 数字输入)
|
|
384
|
+
- `date` - 日期(日期选择器)
|
|
385
|
+
- `regexp` - 正则表达式(带验证)
|
|
386
|
+
- `const` - 常量(只读显示)
|
|
387
|
+
|
|
388
|
+
#### 集合类型
|
|
389
|
+
- `list` - 列表(支持嵌套)
|
|
390
|
+
- `tuple` - 元组(固定字段)
|
|
391
|
+
- `object` - 对象(嵌套结构)
|
|
392
|
+
- `dict` - 字典(JSON 编辑器)
|
|
393
|
+
|
|
394
|
+
#### 组合类型
|
|
395
|
+
- `union` - 联合类型(多选一)
|
|
396
|
+
- `intersect` - 交叉类型(满足所有)
|
|
397
|
+
|
|
398
|
+
#### 通用类型
|
|
399
|
+
- `any` - 任意类型(JSON 编辑器)
|
|
400
|
+
- `never` - 永不类型(警告提示)
|
|
401
|
+
|
|
402
|
+
### 使用插件配置组件
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
import PluginConfigForm from '@zhin.js/client/components/PluginConfigForm'
|
|
406
|
+
|
|
407
|
+
<PluginConfigForm
|
|
408
|
+
pluginName="my-plugin"
|
|
409
|
+
onClose={() => setDialogOpen(false)}
|
|
410
|
+
onSuccess={() => refetchPlugin()}
|
|
411
|
+
/>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### 定义插件配置 Schema
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
import { Schema } from 'zhin.js'
|
|
418
|
+
|
|
419
|
+
export const config = Schema.object({
|
|
420
|
+
// 基础类型
|
|
421
|
+
name: Schema.string('插件名称').required(),
|
|
422
|
+
enabled: Schema.boolean('是否启用').default(true),
|
|
423
|
+
port: Schema.number('端口').min(1).max(65535).default(3000),
|
|
424
|
+
|
|
425
|
+
// 特殊类型
|
|
426
|
+
opacity: Schema.percent('透明度').default(0.8),
|
|
427
|
+
startDate: Schema.date('开始日期'),
|
|
428
|
+
pattern: Schema.regexp('匹配模式'),
|
|
429
|
+
|
|
430
|
+
// 集合类型
|
|
431
|
+
tags: Schema.list(Schema.string(), '标签'),
|
|
432
|
+
server: Schema.object({
|
|
433
|
+
host: Schema.string().default('localhost'),
|
|
434
|
+
port: Schema.number().default(3000)
|
|
435
|
+
}),
|
|
436
|
+
|
|
437
|
+
// 组合类型
|
|
438
|
+
mode: Schema.union([
|
|
439
|
+
Schema.const('auto'),
|
|
440
|
+
Schema.const('manual')
|
|
441
|
+
])
|
|
442
|
+
})
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### 配置表单特性
|
|
446
|
+
|
|
447
|
+
- **自动渲染**: 根据 Schema 类型自动选择合适的 UI 组件
|
|
448
|
+
- **智能分组**: 简单字段直接展示,复杂字段可折叠
|
|
449
|
+
- **嵌套支持**: 完整支持任意深度的嵌套结构
|
|
450
|
+
- **实时验证**: 输入时进行类型验证和格式检查
|
|
451
|
+
- **紧凑布局**: 使用 ScrollArea 和 Accordion 优化空间使用
|
|
452
|
+
|
|
453
|
+
## 组件架构
|
|
454
|
+
|
|
455
|
+
### PluginConfigForm 模块结构
|
|
456
|
+
|
|
457
|
+
```
|
|
458
|
+
PluginConfigForm/
|
|
459
|
+
├── types.ts - 类型定义
|
|
460
|
+
├── BasicFieldRenderers.tsx - 基础类型渲染器 (9个)
|
|
461
|
+
├── CollectionFieldRenderers.tsx - 集合类型渲染器 (5个)
|
|
462
|
+
├── CompositeFieldRenderers.tsx - 组合类型渲染器 (2个)
|
|
463
|
+
├── FieldRenderer.tsx - 字段渲染器主入口
|
|
464
|
+
├── NestedFieldRenderer.tsx - 嵌套字段渲染器
|
|
465
|
+
└── index.tsx - 主组件
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
17 个独立渲染器,职责单一,易于测试和扩展。
|
|
469
|
+
|
|
470
|
+
## 注意事项
|
|
471
|
+
|
|
472
|
+
1. **路由路径唯一性** - 确保路由路径的唯一性,避免冲突
|
|
473
|
+
2. **事件清理** - 记得清理事件监听器,避免内存泄漏
|
|
474
|
+
3. **性能考虑** - 大量路由时考虑使用懒加载
|
|
475
|
+
4. **类型安全** - 使用 TypeScript 确保类型安全
|
|
476
|
+
5. **Schema 定义** - 为插件配置定义清晰的 Schema,提供友好的描述信息
|
|
477
|
+
|
|
364
478
|
## 示例项目
|
|
365
479
|
|
|
366
480
|
查看 `app/src/main.tsx` 中的完整示例。
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 集合类型字段渲染器
|
|
3
|
+
* 处理: list, tuple, object, dict
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Flex, Box, Text, TextArea, Button, Badge, Card, Separator } from '@radix-ui/themes'
|
|
7
|
+
import { Icons } from '@zhin.js/client'
|
|
8
|
+
import type { FieldRendererProps, SchemaField } from './types.js'
|
|
9
|
+
|
|
10
|
+
interface CollectionFieldProps extends FieldRendererProps {
|
|
11
|
+
renderNestedField: (
|
|
12
|
+
fieldName: string,
|
|
13
|
+
field: SchemaField,
|
|
14
|
+
value: any,
|
|
15
|
+
onChange: (val: any) => void
|
|
16
|
+
) => React.ReactElement
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ListFieldRenderer({
|
|
20
|
+
fieldName,
|
|
21
|
+
field,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
onArrayItemChange,
|
|
25
|
+
renderNestedField
|
|
26
|
+
}: CollectionFieldProps) {
|
|
27
|
+
const arrayValue = Array.isArray(value) ? value : []
|
|
28
|
+
const innerField = field.inner || field.items
|
|
29
|
+
|
|
30
|
+
// 简单类型 - 使用多行文本 - 优化样式
|
|
31
|
+
if (innerField && ['string', 'number'].includes(innerField.type)) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
|
|
34
|
+
<Flex direction="column" gap="2">
|
|
35
|
+
<Flex align="center" gap="2">
|
|
36
|
+
<Icons.List className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
37
|
+
<Text size="1" weight="bold" className="text-green-700 dark:text-green-300">
|
|
38
|
+
列表输入 (每行一个值)
|
|
39
|
+
</Text>
|
|
40
|
+
</Flex>
|
|
41
|
+
<TextArea
|
|
42
|
+
size="2"
|
|
43
|
+
value={arrayValue.join('\n')}
|
|
44
|
+
onChange={(e) => {
|
|
45
|
+
const lines = e.target.value.split('\n').filter(Boolean)
|
|
46
|
+
const parsed = innerField.type === 'number'
|
|
47
|
+
? lines.map(l => parseFloat(l) || 0)
|
|
48
|
+
: lines
|
|
49
|
+
onChange(parsed)
|
|
50
|
+
}}
|
|
51
|
+
placeholder={`每行一个值\n${field.description || ''}`}
|
|
52
|
+
rows={4}
|
|
53
|
+
className="font-mono text-sm bg-white dark:bg-gray-950 hover:border-green-500 dark:hover:border-green-400 transition-colors"
|
|
54
|
+
/>
|
|
55
|
+
</Flex>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 复杂类型 - Card 列表 - 优化样式
|
|
61
|
+
return (
|
|
62
|
+
<Flex direction="column" gap="2">
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
{arrayValue.map((item, index) => (
|
|
65
|
+
<Card
|
|
66
|
+
key={index}
|
|
67
|
+
size="1"
|
|
68
|
+
variant="surface"
|
|
69
|
+
className="border border-gray-200 dark:border-gray-800 hover:border-blue-300 dark:hover:border-blue-700 transition-colors group"
|
|
70
|
+
>
|
|
71
|
+
<Flex direction="column" gap="2" className="p-3">
|
|
72
|
+
<Flex justify="between" align="center">
|
|
73
|
+
<Flex align="center" gap="2">
|
|
74
|
+
<Badge size="1" variant="soft" color="blue" className="font-mono">
|
|
75
|
+
{index + 1}
|
|
76
|
+
</Badge>
|
|
77
|
+
</Flex>
|
|
78
|
+
<Button
|
|
79
|
+
size="1"
|
|
80
|
+
variant="ghost"
|
|
81
|
+
color="red"
|
|
82
|
+
onClick={() => {
|
|
83
|
+
const newArr = arrayValue.filter((_, i) => i !== index)
|
|
84
|
+
onChange(newArr)
|
|
85
|
+
}}
|
|
86
|
+
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
87
|
+
>
|
|
88
|
+
<Icons.Trash2 className="w-3 h-3" />
|
|
89
|
+
</Button>
|
|
90
|
+
</Flex>
|
|
91
|
+
<div className="pl-2 border-l-2 border-blue-200 dark:border-blue-800">
|
|
92
|
+
{innerField && renderNestedField(`${fieldName}[${index}]`, innerField, item, (val) => {
|
|
93
|
+
onArrayItemChange?.(fieldName, index, val)
|
|
94
|
+
})}
|
|
95
|
+
</div>
|
|
96
|
+
</Flex>
|
|
97
|
+
</Card>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
<Button
|
|
101
|
+
size="2"
|
|
102
|
+
variant="soft"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
const newItem = innerField?.default || (innerField?.type === 'object' ? {} : '')
|
|
105
|
+
onChange([...arrayValue, newItem])
|
|
106
|
+
}}
|
|
107
|
+
className="w-full hover:bg-blue-100 dark:hover:bg-blue-900/30 border-2 border-dashed border-blue-300 dark:border-blue-700 hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
|
|
108
|
+
>
|
|
109
|
+
<Icons.Plus className="w-4 h-4" />
|
|
110
|
+
添加项
|
|
111
|
+
</Button>
|
|
112
|
+
</Flex>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function ArrayFieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
117
|
+
// 兼容旧格式 - 优化样式
|
|
118
|
+
return (
|
|
119
|
+
<div className="p-3 rounded-lg bg-cyan-50 dark:bg-cyan-900/20 border border-cyan-200 dark:border-cyan-800">
|
|
120
|
+
<Flex direction="column" gap="2">
|
|
121
|
+
<Flex align="center" gap="2">
|
|
122
|
+
<Icons.List className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
|
123
|
+
<Text size="1" weight="bold" className="text-cyan-700 dark:text-cyan-300">
|
|
124
|
+
数组输入 (每行一个值)
|
|
125
|
+
</Text>
|
|
126
|
+
</Flex>
|
|
127
|
+
<TextArea
|
|
128
|
+
size="2"
|
|
129
|
+
value={Array.isArray(value) ? value.join('\n') : ''}
|
|
130
|
+
onChange={(e) => onChange(e.target.value.split('\n').filter(Boolean))}
|
|
131
|
+
placeholder={`每行一个值\n${field.description || ''}`}
|
|
132
|
+
rows={3}
|
|
133
|
+
className="font-mono text-sm bg-white dark:bg-gray-950 hover:border-cyan-500 dark:hover:border-cyan-400 transition-colors"
|
|
134
|
+
/>
|
|
135
|
+
</Flex>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function TupleFieldRenderer({
|
|
141
|
+
fieldName,
|
|
142
|
+
field,
|
|
143
|
+
value,
|
|
144
|
+
onArrayItemChange,
|
|
145
|
+
renderNestedField
|
|
146
|
+
}: CollectionFieldProps) {
|
|
147
|
+
const tupleValue = Array.isArray(value) ? value : []
|
|
148
|
+
const tupleFields = field.list || []
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div className="space-y-3">
|
|
152
|
+
{tupleFields.map((tupleField, index) => (
|
|
153
|
+
<div
|
|
154
|
+
key={index}
|
|
155
|
+
className="p-3 rounded-lg bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 hover:border-indigo-300 dark:hover:border-indigo-700 transition-colors"
|
|
156
|
+
>
|
|
157
|
+
<Flex direction="column" gap="2">
|
|
158
|
+
<Flex align="center" gap="2">
|
|
159
|
+
<Badge size="1" variant="soft" color="indigo" className="font-mono">
|
|
160
|
+
#{index + 1}
|
|
161
|
+
</Badge>
|
|
162
|
+
</Flex>
|
|
163
|
+
{tupleField.description && (
|
|
164
|
+
<Text size="1" className="text-indigo-700 dark:text-indigo-300">
|
|
165
|
+
{tupleField.description}
|
|
166
|
+
</Text>
|
|
167
|
+
)}
|
|
168
|
+
<div className="mt-1">
|
|
169
|
+
{renderNestedField(`${fieldName}[${index}]`, tupleField, tupleValue[index], (val) => {
|
|
170
|
+
onArrayItemChange?.(fieldName, index, val)
|
|
171
|
+
})}
|
|
172
|
+
</div>
|
|
173
|
+
</Flex>
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function ObjectFieldRenderer({
|
|
181
|
+
fieldName,
|
|
182
|
+
field,
|
|
183
|
+
value,
|
|
184
|
+
renderField
|
|
185
|
+
}: CollectionFieldProps & { renderField: (fieldName: string, field: SchemaField, parentPath?: string) => React.ReactElement }) {
|
|
186
|
+
const objectFields = field.dict || field.properties || {}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="rounded-lg border-2 border-blue-200 dark:border-blue-800 bg-gradient-to-br from-blue-50/50 to-cyan-50/50 dark:from-blue-900/10 dark:to-cyan-900/10 overflow-hidden">
|
|
190
|
+
<div className="px-4 py-2 bg-blue-100 dark:bg-blue-900/30 border-b border-blue-200 dark:border-blue-800">
|
|
191
|
+
<Flex align="center" gap="2">
|
|
192
|
+
<Icons.Package className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
193
|
+
</Flex>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="p-4 space-y-3">
|
|
196
|
+
{Object.entries(objectFields).map(([key, nestedField]: [string, any], index) => (
|
|
197
|
+
<div key={key}>
|
|
198
|
+
<div className="p-3 rounded-md bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800">
|
|
199
|
+
<Flex direction="column" gap="2">
|
|
200
|
+
<Flex align="center" gap="1">
|
|
201
|
+
<Text size="2" weight="bold" className="text-gray-900 dark:text-gray-100">
|
|
202
|
+
{nestedField.key || key}
|
|
203
|
+
</Text>
|
|
204
|
+
{nestedField.required && (
|
|
205
|
+
<Text size="2" weight="bold" color="red" className="leading-none">
|
|
206
|
+
*
|
|
207
|
+
</Text>
|
|
208
|
+
)}
|
|
209
|
+
</Flex>
|
|
210
|
+
{nestedField.description && (
|
|
211
|
+
<Text size="1" color="gray">
|
|
212
|
+
{nestedField.description}
|
|
213
|
+
</Text>
|
|
214
|
+
)}
|
|
215
|
+
<div className="mt-1">
|
|
216
|
+
{renderField(key, nestedField, fieldName)}
|
|
217
|
+
</div>
|
|
218
|
+
</Flex>
|
|
219
|
+
</div>
|
|
220
|
+
{index < Object.entries(objectFields).length - 1 && (
|
|
221
|
+
<Separator size="4" className="my-2" />
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function DictFieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="p-3 rounded-lg bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-800">
|
|
233
|
+
<Flex direction="column" gap="3">
|
|
234
|
+
<Flex align="center" gap="2">
|
|
235
|
+
<Icons.Code className="w-4 h-4 text-violet-600 dark:text-violet-400" />
|
|
236
|
+
</Flex>
|
|
237
|
+
<TextArea
|
|
238
|
+
size="2"
|
|
239
|
+
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : value || '{}'}
|
|
240
|
+
onChange={(e) => {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(e.target.value)
|
|
243
|
+
onChange(parsed)
|
|
244
|
+
} catch {
|
|
245
|
+
// 忽略解析错误,继续编辑
|
|
246
|
+
}
|
|
247
|
+
}}
|
|
248
|
+
placeholder={field.description || '请输入 JSON 格式'}
|
|
249
|
+
rows={6}
|
|
250
|
+
className="font-mono text-sm bg-white dark:bg-gray-950 hover:border-violet-500 dark:hover:border-violet-400 transition-colors"
|
|
251
|
+
/>
|
|
252
|
+
<Flex align="center" gap="2">
|
|
253
|
+
<Icons.Info className="w-3 h-3 text-violet-600 dark:text-violet-400" />
|
|
254
|
+
<Text size="1" className="text-violet-700 dark:text-violet-300">
|
|
255
|
+
键值对格式: {"key": "value"}
|
|
256
|
+
</Text>
|
|
257
|
+
</Flex>
|
|
258
|
+
</Flex>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|