@gadmin2n/schematics 0.0.86 → 0.0.87

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.
@@ -74,11 +74,6 @@ function generateProject(options, path) {
74
74
  }
75
75
  function generateServer(options, path) {
76
76
  const framework = options['server-framework'];
77
- if (framework === 'iris') {
78
- return (0, schematics_1.apply)((0, schematics_1.url)((0, core_1.join)('./files/customize/server', framework)), [
79
- (0, schematics_1.move)((0, core_1.join)(path, 'server')),
80
- ]);
81
- }
82
77
  return (0, schematics_1.apply)((0, schematics_1.url)((0, core_1.join)('./files/customize/server', framework)), [
83
78
  (0, schematics_1.template)(Object.assign(Object.assign({}, core_1.strings), options)),
84
79
  (0, schematics_1.move)((0, core_1.join)(path, 'server')),
@@ -0,0 +1,205 @@
1
+ import React, {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useState,
7
+ } from 'react';
8
+ import { Alert, Input, message } from 'antd';
9
+ import type { WorkflowDSL } from '../types';
10
+
11
+ const { TextArea } = Input;
12
+
13
+ const EMPTY_TEMPLATE = '{\n "nodes": [],\n "edges": []\n}';
14
+
15
+ interface DslViewProps {
16
+ dsl: WorkflowDSL | null;
17
+ readonly?: boolean;
18
+ onApply?: (newDsl: WorkflowDSL) => void;
19
+ onDirtyChange?: (dirty: boolean) => void;
20
+ }
21
+
22
+ export interface DslViewHandle {
23
+ apply: () => void;
24
+ copy: () => Promise<void>;
25
+ }
26
+
27
+ function stringifyDsl(dsl: WorkflowDSL | null): string {
28
+ if (!dsl) return EMPTY_TEMPLATE;
29
+ return JSON.stringify(dsl, null, 2);
30
+ }
31
+
32
+ /** 形状级校验:只检查必需字段,深层字段(position/config 等)由画布或保存时暴露 */
33
+ function validateDsl(
34
+ parsed: any,
35
+ ): { ok: true; dsl: WorkflowDSL } | { ok: false; error: string } {
36
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
37
+ return { ok: false, error: '根必须是对象 { nodes, edges }' };
38
+ }
39
+ if (!Array.isArray(parsed.nodes)) {
40
+ return { ok: false, error: '字段 nodes 必须是数组' };
41
+ }
42
+ if (!Array.isArray(parsed.edges)) {
43
+ return { ok: false, error: '字段 edges 必须是数组' };
44
+ }
45
+ for (let i = 0; i < parsed.nodes.length; i++) {
46
+ const n = parsed.nodes[i];
47
+ if (!n || typeof n !== 'object') {
48
+ return { ok: false, error: `节点 [${i}] 必须是对象` };
49
+ }
50
+ if (typeof n.id !== 'string' || !n.id) {
51
+ return { ok: false, error: `节点 [${i}] 缺少必需字段:id` };
52
+ }
53
+ if (typeof n.type !== 'string' || !n.type) {
54
+ return { ok: false, error: `节点 [${i}] 缺少必需字段:type` };
55
+ }
56
+ }
57
+ for (let i = 0; i < parsed.edges.length; i++) {
58
+ const e = parsed.edges[i];
59
+ if (!e || typeof e !== 'object') {
60
+ return { ok: false, error: `连线 [${i}] 必须是对象` };
61
+ }
62
+ if (typeof e.id !== 'string' || !e.id) {
63
+ return { ok: false, error: `连线 [${i}] 缺少必需字段:id` };
64
+ }
65
+ if (typeof e.source !== 'string' || !e.source) {
66
+ return { ok: false, error: `连线 [${i}] 缺少必需字段:source` };
67
+ }
68
+ if (typeof e.target !== 'string' || !e.target) {
69
+ return { ok: false, error: `连线 [${i}] 缺少必需字段:target` };
70
+ }
71
+ }
72
+ return { ok: true, dsl: parsed as WorkflowDSL };
73
+ }
74
+
75
+ async function copyToClipboard(text: string): Promise<boolean> {
76
+ try {
77
+ if (navigator.clipboard?.writeText) {
78
+ await navigator.clipboard.writeText(text);
79
+ return true;
80
+ }
81
+ } catch {
82
+ // fallthrough
83
+ }
84
+ // Fallback for non-secure context
85
+ try {
86
+ const ta = document.createElement('textarea');
87
+ ta.value = text;
88
+ ta.style.position = 'fixed';
89
+ ta.style.opacity = '0';
90
+ document.body.appendChild(ta);
91
+ ta.select();
92
+ const ok = document.execCommand('copy');
93
+ document.body.removeChild(ta);
94
+ return ok;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ export const DslView = forwardRef<DslViewHandle, DslViewProps>(function DslView(
101
+ { dsl, readonly = false, onApply, onDirtyChange },
102
+ ref,
103
+ ) {
104
+ const initial = useMemo(() => stringifyDsl(dsl), [dsl]);
105
+ const [text, setText] = useState(initial);
106
+ const [error, setError] = useState<string | null>(null);
107
+
108
+ // dsl prop 变化时重置 text(应用、版本切换、AI 生成等场景)
109
+ useEffect(() => {
110
+ setText(initial);
111
+ setError(null);
112
+ onDirtyChange?.(false);
113
+ // onDirtyChange 故意不放进依赖数组:调用方传不稳定函数会导致死循环
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [initial]);
116
+
117
+ function handleTextChange(next: string) {
118
+ setText(next);
119
+ if (error) setError(null);
120
+ if (!readonly) {
121
+ const dirty = next !== initial;
122
+ onDirtyChange?.(dirty);
123
+ }
124
+ }
125
+
126
+ function handleApply() {
127
+ let parsed: any;
128
+ try {
129
+ parsed = JSON.parse(text);
130
+ } catch (e: any) {
131
+ setError(`JSON 解析失败:${e?.message ?? String(e)}`);
132
+ return;
133
+ }
134
+ const result = validateDsl(parsed);
135
+ if (!result.ok) {
136
+ setError(result.error);
137
+ return;
138
+ }
139
+ const normalized = JSON.stringify(result.dsl, null, 2);
140
+ setText(normalized);
141
+ setError(null);
142
+ onApply?.(result.dsl);
143
+ onDirtyChange?.(false);
144
+ message.success('已应用到画布');
145
+ }
146
+
147
+ async function handleCopy() {
148
+ const ok = await copyToClipboard(text);
149
+ if (ok) {
150
+ message.success('已复制到剪贴板');
151
+ } else {
152
+ message.error('复制失败,请手动选择文本复制');
153
+ }
154
+ }
155
+
156
+ useImperativeHandle(
157
+ ref,
158
+ () => ({
159
+ apply: handleApply,
160
+ copy: handleCopy,
161
+ }),
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ [text, onApply, onDirtyChange],
164
+ );
165
+
166
+ return (
167
+ <div
168
+ style={{
169
+ flex: 1,
170
+ display: 'flex',
171
+ flexDirection: 'column',
172
+ minHeight: 0,
173
+ background: '#fff',
174
+ padding: 16,
175
+ }}
176
+ >
177
+ {error && (
178
+ <Alert
179
+ type="error"
180
+ message={error}
181
+ showIcon
182
+ closable
183
+ onClose={() => setError(null)}
184
+ style={{ marginBottom: 8, flexShrink: 0 }}
185
+ />
186
+ )}
187
+
188
+ <TextArea
189
+ value={text}
190
+ onChange={(e) => handleTextChange(e.target.value)}
191
+ readOnly={readonly}
192
+ spellCheck={false}
193
+ autoSize={false}
194
+ style={{
195
+ flex: 1,
196
+ fontFamily:
197
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
198
+ fontSize: 13,
199
+ lineHeight: 1.5,
200
+ resize: 'none',
201
+ }}
202
+ />
203
+ </div>
204
+ );
205
+ });
@@ -11,6 +11,7 @@ import {
11
11
  Input,
12
12
  message,
13
13
  Modal,
14
+ Segmented,
14
15
  Select,
15
16
  Space,
16
17
  Spin,
@@ -19,6 +20,7 @@ import {
19
20
  } from 'antd';
20
21
  import {
21
22
  ArrowLeftOutlined,
23
+ CheckOutlined,
22
24
  EditOutlined,
23
25
  RobotOutlined,
24
26
  RocketOutlined,
@@ -28,6 +30,7 @@ import { useNavigate, useParams } from 'react-router-dom';
28
30
  import { customRequest } from 'helpers/http';
29
31
  import { FlowRenderer } from './components/FlowRenderer';
30
32
  import { NodePropertyPanel } from './components/NodePropertyPanel';
33
+ import { DslView, type DslViewHandle } from './components/DslView';
31
34
  import { useWorkflowAgent } from './hooks/useWorkflowAgent';
32
35
  import type {
33
36
  Workflow,
@@ -59,6 +62,9 @@ export default function WorkflowEditorPage() {
59
62
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
60
63
 
61
64
  const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
65
+ const [viewMode, setViewMode] = useState<'canvas' | 'dsl'>('canvas');
66
+ const [dslDirty, setDslDirty] = useState(false);
67
+ const dslViewRef = useRef<DslViewHandle>(null);
62
68
 
63
69
  // Title editing state
64
70
  const [title, setTitle] = useState(isNewMode ? 'Untitled Workflow' : '');
@@ -334,10 +340,12 @@ export default function WorkflowEditorPage() {
334
340
  async function handleSelectVersion(version: number) {
335
341
  if (!id) return;
336
342
 
337
- if (hasUnsavedChanges) {
343
+ if (hasUnsavedChanges || (viewMode === 'dsl' && dslDirty)) {
338
344
  Modal.confirm({
339
- title: '未保存的修改',
340
- content: '当前有未保存的修改,切换版本将丢失这些更改。确定要继续吗?',
345
+ title: hasUnsavedChanges ? '未保存的修改' : 'DSL 有未应用的编辑',
346
+ content: hasUnsavedChanges
347
+ ? '当前有未保存的修改,切换版本将丢失这些更改。确定要继续吗?'
348
+ : 'DSL 视图中有未应用到画布的编辑,切换版本将丢弃这些内容。确定要继续吗?',
341
349
  okText: '确定切换',
342
350
  cancelText: '取消',
343
351
  onOk: () => doLoadVersion(version),
@@ -369,6 +377,30 @@ export default function WorkflowEditorPage() {
369
377
  setSelectedNodeId(nodeId || null);
370
378
  }
371
379
 
380
+ function handleViewModeChange(next: 'canvas' | 'dsl') {
381
+ if (next === 'canvas' && dslDirty) {
382
+ Modal.confirm({
383
+ title: '未应用的修改',
384
+ content:
385
+ 'DSL 视图中有未应用到画布的修改,切换到 Canvas 将丢弃这些修改。确定要继续吗?',
386
+ okText: '确定切换',
387
+ cancelText: '取消',
388
+ onOk: () => {
389
+ setDslDirty(false);
390
+ setViewMode('canvas');
391
+ },
392
+ });
393
+ return;
394
+ }
395
+ setViewMode(next);
396
+ }
397
+
398
+ function handleDslApply(newDsl: WorkflowDSL) {
399
+ setDsl(newDsl);
400
+ setHasUnsavedChanges(true);
401
+ setSelectedNodeId(null);
402
+ }
403
+
372
404
  if (loading) {
373
405
  return (
374
406
  <div
@@ -446,7 +478,7 @@ export default function WorkflowEditorPage() {
446
478
  }))}
447
479
  />
448
480
  )}
449
- {isDev && (
481
+ {isDev && viewMode === 'canvas' && (
450
482
  <Button
451
483
  type="primary"
452
484
  icon={<RobotOutlined />}
@@ -468,30 +500,73 @@ export default function WorkflowEditorPage() {
468
500
  </div>
469
501
  </Card>
470
502
 
503
+ {/* View toggle row + DSL actions */}
504
+ <div
505
+ style={{
506
+ marginBottom: 12,
507
+ flexShrink: 0,
508
+ display: 'flex',
509
+ alignItems: 'center',
510
+ justifyContent: 'space-between',
511
+ }}
512
+ >
513
+ <Segmented
514
+ value={viewMode}
515
+ onChange={(v) => handleViewModeChange(v as 'canvas' | 'dsl')}
516
+ options={[
517
+ { label: 'Canvas', value: 'canvas' },
518
+ { label: 'DSL', value: 'dsl' },
519
+ ]}
520
+ />
521
+ {viewMode === 'dsl' && (
522
+ <Space>
523
+ {dslDirty && <Tag color="orange">未应用</Tag>}
524
+ <Button
525
+ type="primary"
526
+ icon={<CheckOutlined />}
527
+ onClick={() => dslViewRef.current?.apply()}
528
+ >
529
+ 应用到画布
530
+ </Button>
531
+ </Space>
532
+ )}
533
+ </div>
534
+
471
535
  {/* Main area: Flow + Property Panel */}
472
536
  <div
473
537
  style={{ flex: 1, display: 'flex', overflow: 'hidden', minHeight: 0 }}
474
538
  >
475
- <div style={{ flex: 1, position: 'relative' }}>
476
- <FlowRenderer
539
+ {viewMode === 'canvas' ? (
540
+ <>
541
+ <div style={{ flex: 1, position: 'relative' }}>
542
+ <FlowRenderer
543
+ dsl={dsl}
544
+ nodeTypeMap={nodeTypeMap}
545
+ selectedNodeId={selectedNodeId}
546
+ onNodeClick={handleNodeClick}
547
+ onDslChange={handleDslChange}
548
+ />
549
+ </div>
550
+ <NodePropertyPanel
551
+ node={selectedNode}
552
+ nodeTypes={nodeTypes}
553
+ currentWorkflowId={id}
554
+ onSave={handleNodePropertySave}
555
+ onLabelChange={handleNodeLabelChange}
556
+ onAiEdit={handleNodeAiEdit}
557
+ onDelete={handleNodeDelete}
558
+ onAbort={abortAgent}
559
+ aiLoading={agentLoading}
560
+ />
561
+ </>
562
+ ) : (
563
+ <DslView
564
+ ref={dslViewRef}
477
565
  dsl={dsl}
478
- nodeTypeMap={nodeTypeMap}
479
- selectedNodeId={selectedNodeId}
480
- onNodeClick={handleNodeClick}
481
- onDslChange={handleDslChange}
566
+ onApply={handleDslApply}
567
+ onDirtyChange={setDslDirty}
482
568
  />
483
- </div>
484
- <NodePropertyPanel
485
- node={selectedNode}
486
- nodeTypes={nodeTypes}
487
- currentWorkflowId={id}
488
- onSave={handleNodePropertySave}
489
- onLabelChange={handleNodeLabelChange}
490
- onAiEdit={handleNodeAiEdit}
491
- onDelete={handleNodeDelete}
492
- onAbort={abortAgent}
493
- aiLoading={agentLoading}
494
- />
569
+ )}
495
570
  </div>
496
571
 
497
572
  {/* Name modal for untitled workflows */}
@@ -1,4 +1,10 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
2
8
  import {
3
9
  Button,
4
10
  Card,
@@ -6,6 +12,7 @@ import {
6
12
  Input,
7
13
  message,
8
14
  Modal,
15
+ Segmented,
9
16
  Select,
10
17
  Space,
11
18
  Spin,
@@ -14,6 +21,7 @@ import {
14
21
  } from 'antd';
15
22
  import {
16
23
  ArrowLeftOutlined,
24
+ CopyOutlined,
17
25
  DiffOutlined,
18
26
  EditOutlined,
19
27
  HistoryOutlined,
@@ -24,6 +32,7 @@ import { useNavigate, useParams } from 'react-router-dom';
24
32
  import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
25
33
  import { customRequest } from 'helpers/http';
26
34
  import { FlowRenderer } from './components/FlowRenderer';
35
+ import { DslView, type DslViewHandle } from './components/DslView';
27
36
  import { RunWorkflowModal } from './components/RunWorkflowModal';
28
37
  import type {
29
38
  Workflow,
@@ -52,6 +61,8 @@ export default function WorkflowShowPage() {
52
61
 
53
62
  const [dsl, setDsl] = useState<WorkflowDSL | null>(null);
54
63
  const [currentVersion, setCurrentVersion] = useState<number | null>(null);
64
+ const [viewMode, setViewMode] = useState<'canvas' | 'dsl'>('canvas');
65
+ const dslViewRef = useRef<DslViewHandle>(null);
55
66
 
56
67
  // Publish modal state
57
68
  const [publishModalOpen, setPublishModalOpen] = useState(false);
@@ -260,9 +271,7 @@ ${diffRightDsl}
260
271
  type="text"
261
272
  icon={<ArrowLeftOutlined />}
262
273
  onClick={() => navigate('/admin/workflow')}
263
- >
264
- Back
265
- </Button>
274
+ ></Button>
266
275
  <Title level={4} style={{ margin: 0 }}>
267
276
  {workflow?.name || 'Workflow'}
268
277
  </Title>
@@ -324,69 +333,105 @@ ${diffRightDsl}
324
333
  </div>
325
334
  </Card>
326
335
 
336
+ {/* View toggle row + DSL actions */}
337
+ <div
338
+ style={{
339
+ marginBottom: 12,
340
+ flexShrink: 0,
341
+ display: 'flex',
342
+ alignItems: 'center',
343
+ justifyContent: 'space-between',
344
+ }}
345
+ >
346
+ <Segmented
347
+ value={viewMode}
348
+ onChange={(v) => setViewMode(v as 'canvas' | 'dsl')}
349
+ options={[
350
+ { label: 'Canvas', value: 'canvas' },
351
+ { label: 'DSL', value: 'dsl' },
352
+ ]}
353
+ />
354
+ {viewMode === 'dsl' && (
355
+ <Button
356
+ icon={<CopyOutlined />}
357
+ onClick={() => dslViewRef.current?.copy()}
358
+ >
359
+ Copy
360
+ </Button>
361
+ )}
362
+ </div>
363
+
327
364
  {/* Canvas + Node Detail Panel */}
328
365
  <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
329
- <div style={{ flex: 1, position: 'relative' }}>
330
- <FlowRenderer
331
- dsl={dsl}
332
- nodeTypeMap={nodeTypeMap}
333
- selectedNodeId={selectedNodeId}
334
- readonly
335
- onNodeClick={(nodeId) => setSelectedNodeId(nodeId || null)}
336
- />
337
- </div>
338
- {selectedNode && (
339
- <div
340
- style={{
341
- width: 320,
342
- flexShrink: 0,
343
- borderLeft: '1px solid #f0f0f0',
344
- background: '#fff',
345
- overflowY: 'auto',
346
- padding: 16,
347
- }}
348
- >
349
- <Title level={5} style={{ marginTop: 0 }}>
350
- {selectedNode.label}
351
- </Title>
352
- <Descriptions column={1} size="small" bordered>
353
- <Descriptions.Item label="ID">
354
- <Typography.Text copyable style={{ fontSize: 12 }}>
355
- {selectedNode.id}
356
- </Typography.Text>
357
- </Descriptions.Item>
358
- <Descriptions.Item label="类型">
359
- {selectedNode.type}
360
- </Descriptions.Item>
361
- <Descriptions.Item label="分类">
362
- <Tag>
363
- {nodeTypeMap[selectedNode.type]?.category || 'ACTION'}
364
- </Tag>
365
- </Descriptions.Item>
366
- {selectedNode.instanceRef && (
367
- <Descriptions.Item label="节点实例">
368
- {selectedNode.instanceRef.instanceName}
369
- </Descriptions.Item>
370
- )}
371
- </Descriptions>
372
- {selectedNode.config &&
373
- Object.keys(selectedNode.config).length > 0 && (
374
- <>
375
- <Title level={5} style={{ marginTop: 16 }}>
376
- 配置
377
- </Title>
378
- <Descriptions column={1} size="small" bordered>
379
- {Object.entries(selectedNode.config).map(([key, value]) => (
380
- <Descriptions.Item key={key} label={key}>
381
- {typeof value === 'object'
382
- ? JSON.stringify(value, null, 2)
383
- : String(value ?? '')}
384
- </Descriptions.Item>
385
- ))}
386
- </Descriptions>
387
- </>
388
- )}
389
- </div>
366
+ {viewMode === 'canvas' ? (
367
+ <>
368
+ <div style={{ flex: 1, position: 'relative' }}>
369
+ <FlowRenderer
370
+ dsl={dsl}
371
+ nodeTypeMap={nodeTypeMap}
372
+ selectedNodeId={selectedNodeId}
373
+ readonly
374
+ onNodeClick={(nodeId) => setSelectedNodeId(nodeId || null)}
375
+ />
376
+ </div>
377
+ {selectedNode && (
378
+ <div
379
+ style={{
380
+ width: 320,
381
+ flexShrink: 0,
382
+ borderLeft: '1px solid #f0f0f0',
383
+ background: '#fff',
384
+ overflowY: 'auto',
385
+ padding: 16,
386
+ }}
387
+ >
388
+ <Title level={5} style={{ marginTop: 0 }}>
389
+ {selectedNode.label}
390
+ </Title>
391
+ <Descriptions column={1} size="small" bordered>
392
+ <Descriptions.Item label="ID">
393
+ <Typography.Text copyable style={{ fontSize: 12 }}>
394
+ {selectedNode.id}
395
+ </Typography.Text>
396
+ </Descriptions.Item>
397
+ <Descriptions.Item label="类型">
398
+ {selectedNode.type}
399
+ </Descriptions.Item>
400
+ <Descriptions.Item label="分类">
401
+ <Tag>
402
+ {nodeTypeMap[selectedNode.type]?.category || 'ACTION'}
403
+ </Tag>
404
+ </Descriptions.Item>
405
+ {selectedNode.instanceRef && (
406
+ <Descriptions.Item label="节点实例">
407
+ {selectedNode.instanceRef.instanceName}
408
+ </Descriptions.Item>
409
+ )}
410
+ </Descriptions>
411
+ {selectedNode.config &&
412
+ Object.keys(selectedNode.config).length > 0 && (
413
+ <>
414
+ <Title level={5} style={{ marginTop: 16 }}>
415
+ 配置
416
+ </Title>
417
+ <Descriptions column={1} size="small" bordered>
418
+ {Object.entries(selectedNode.config).map(
419
+ ([key, value]) => (
420
+ <Descriptions.Item key={key} label={key}>
421
+ {typeof value === 'object'
422
+ ? JSON.stringify(value, null, 2)
423
+ : String(value ?? '')}
424
+ </Descriptions.Item>
425
+ ),
426
+ )}
427
+ </Descriptions>
428
+ </>
429
+ )}
430
+ </div>
431
+ )}
432
+ </>
433
+ ) : (
434
+ <DslView ref={dslViewRef} dsl={dsl} readonly />
390
435
  )}
391
436
  </div>
392
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gadmin2n/schematics",
3
- "version": "0.0.86",
3
+ "version": "0.0.87",
4
4
  "description": "Gadmin - modern, fast, powerful node.js web framework (@schematics)",
5
5
  "main": "dist/index.js",
6
6
  "files": [