@steedos-labs/plugin-workflow 3.0.26 → 3.0.28
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/designer/README.md +110 -0
- package/main/default/applications/approve_workflow.app.yml +115 -14
- package/main/default/client/socket.client.js +5 -1
- package/main/default/objects/flows/flows.object.yml +18 -0
- package/main/default/pages/flow_selector.page.amis.json +1 -1
- package/main/default/routes/am.router.js +28 -0
- package/main/default/routes/api_workflow_ai_design.router.js +184 -0
- package/main/default/routes/designer-v2.router.js +62 -0
- package/main/default/test/test_badge_draft.js +171 -0
- package/main/default/test/test_badge_update.js +332 -0
- package/package.json +13 -1
- package/AI_PLUGIN_GUIDE.md +0 -939
- package/APPROVAL_COMMENTS_OPERATIONS.md +0 -673
- package/APPROVAL_COMMENTS_UPGRADE.md +0 -854
- package/README_TEMPLATE.md +0 -222
- package/convert-templates.js +0 -51
- package/run.js +0 -388
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Workflow Designer (React + shadcn/ui)
|
|
2
|
+
|
|
3
|
+
Modern workflow designer built with React 18, TypeScript, and shadcn/ui.
|
|
4
|
+
|
|
5
|
+
## 🚀 Quick Start
|
|
6
|
+
|
|
7
|
+
### Development
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Open http://localhost:5173/
|
|
15
|
+
|
|
16
|
+
### Production Build
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm run build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Built files will be in `dist/` directory.
|
|
23
|
+
|
|
24
|
+
## 🛠️ Tech Stack
|
|
25
|
+
|
|
26
|
+
- **React 19** - UI framework
|
|
27
|
+
- **TypeScript** - Type safety
|
|
28
|
+
- **Vite** - Build tool
|
|
29
|
+
- **Tailwind CSS** - Styling
|
|
30
|
+
- **shadcn/ui** - UI components
|
|
31
|
+
- **Zustand** - State management
|
|
32
|
+
- **Axios** - HTTP client
|
|
33
|
+
|
|
34
|
+
## 📁 Project Structure
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
designer/
|
|
38
|
+
├── src/
|
|
39
|
+
│ ├── components/
|
|
40
|
+
│ │ ├── ui/ # shadcn/ui components
|
|
41
|
+
│ │ ├── layout/ # Layout components
|
|
42
|
+
│ │ ├── workflow/ # Workflow-specific (TODO)
|
|
43
|
+
│ │ └── form/ # Form designer (TODO)
|
|
44
|
+
│ ├── lib/ # Utilities
|
|
45
|
+
│ ├── pages/ # Page components
|
|
46
|
+
│ │ └── WorkflowDesigner.tsx
|
|
47
|
+
│ ├── stores/ # Zustand stores (TODO)
|
|
48
|
+
│ ├── types/ # TypeScript types (TODO)
|
|
49
|
+
│ ├── App.tsx
|
|
50
|
+
│ └── main.tsx
|
|
51
|
+
├── public/
|
|
52
|
+
├── dist/ # Build output
|
|
53
|
+
└── package.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 🌐 Backend Integration
|
|
57
|
+
|
|
58
|
+
The new designer is served via:
|
|
59
|
+
- **Development**: http://localhost:5173/
|
|
60
|
+
- **Production**: /api/workflow/designer-v2
|
|
61
|
+
|
|
62
|
+
Backend route: `main/default/routes/designer-v2.router.js`
|
|
63
|
+
|
|
64
|
+
## 📝 Features
|
|
65
|
+
|
|
66
|
+
### Implemented ✅
|
|
67
|
+
- Modern UI with shadcn/ui
|
|
68
|
+
- Responsive layout
|
|
69
|
+
- TypeScript type safety
|
|
70
|
+
- Basic component structure
|
|
71
|
+
- Development server
|
|
72
|
+
- Production build
|
|
73
|
+
|
|
74
|
+
### TODO 🚧
|
|
75
|
+
- Workflow canvas with React Flow
|
|
76
|
+
- Form designer components
|
|
77
|
+
- State management with Zustand
|
|
78
|
+
- API integration
|
|
79
|
+
- Internationalization (i18n)
|
|
80
|
+
- Unit tests
|
|
81
|
+
- More shadcn/ui components
|
|
82
|
+
|
|
83
|
+
## 🎨 Customization
|
|
84
|
+
|
|
85
|
+
### Tailwind Configuration
|
|
86
|
+
|
|
87
|
+
Edit `tailwind.config.js` to customize theme.
|
|
88
|
+
|
|
89
|
+
### Path Aliases
|
|
90
|
+
|
|
91
|
+
TypeScript path aliases are configured in `tsconfig.app.json`:
|
|
92
|
+
- `@/` maps to `./src/`
|
|
93
|
+
|
|
94
|
+
## 📦 Scripts
|
|
95
|
+
|
|
96
|
+
- `npm run dev` - Start development server
|
|
97
|
+
- `npm run build` - Build for production
|
|
98
|
+
- `npm run lint` - Run ESLint
|
|
99
|
+
- `npm run preview` - Preview production build
|
|
100
|
+
|
|
101
|
+
## 🔗 Related Documentation
|
|
102
|
+
|
|
103
|
+
See parent directory for comprehensive planning docs:
|
|
104
|
+
- [Refactoring Plan](../DESIGNER_REFACTOR_PLAN.md)
|
|
105
|
+
- [URL Structure](../URL_STRUCTURE.md)
|
|
106
|
+
- [Quick Start Guide](../QUICK_START_GUIDE.md)
|
|
107
|
+
|
|
108
|
+
## 📄 License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -35,22 +35,28 @@ nav_schema: {
|
|
|
35
35
|
"click": {
|
|
36
36
|
"actions": [
|
|
37
37
|
{
|
|
38
|
-
"actionType": "
|
|
39
|
-
"componentId": "u:instanceNav",
|
|
40
|
-
"args": {
|
|
41
|
-
"value": {
|
|
42
|
-
"nav_reload_token": "${_|now|date:x}"
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
"actionType": "rebuild",
|
|
38
|
+
"actionType": "reload",
|
|
48
39
|
"componentId": "u:instanceNav"
|
|
49
40
|
}
|
|
50
41
|
]
|
|
51
42
|
}
|
|
52
43
|
}
|
|
53
44
|
},
|
|
45
|
+
{
|
|
46
|
+
"type": "button",
|
|
47
|
+
"label": "刷新树",
|
|
48
|
+
"className": "instance-tree-reload hidden",
|
|
49
|
+
"onEvent": {
|
|
50
|
+
"click": {
|
|
51
|
+
"actions": [
|
|
52
|
+
{
|
|
53
|
+
"actionType": "custom",
|
|
54
|
+
"script": "try{ var opts = window.__latestNavOptions; var data = window.__latestNavData; if(!Array.isArray(opts)){ console.log('[tree-reload] no global options'); return; } opts = JSON.parse(JSON.stringify(opts)); var tree = window.__navTreeComponent; if(!tree || !tree.props || !tree.props.formItem){ console.log('[tree-reload] no saved tree ref, trying amisScoped'); var s = window.amisScoped || (document.querySelector('[data-amisScoped]') && document.querySelector('[data-amisScoped]').__amisScoped); if(s){ tree = s.getComponentById('u:instanceNavTree'); } } if(tree && tree.props && tree.props.formItem){ tree.props.formItem.setOptions(opts, undefined, data || {}); console.log('[tree-reload] setOptions done, count:', opts.length); if(tree.forceUpdate){ tree.forceUpdate(); console.log('[tree-reload] forceUpdate called'); } } else { console.log('[tree-reload] FAIL: cannot find tree component'); } }catch(e){ console.warn('[tree-reload error]', e); }"
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
54
60
|
{
|
|
55
61
|
"type": "service",
|
|
56
62
|
"id": "u:instanceNav",
|
|
@@ -62,19 +68,114 @@ nav_schema: {
|
|
|
62
68
|
"actionType": "reload"
|
|
63
69
|
}
|
|
64
70
|
]
|
|
71
|
+
},
|
|
72
|
+
"fetchInited": {
|
|
73
|
+
"actions": [
|
|
74
|
+
{
|
|
75
|
+
"actionType": "custom",
|
|
76
|
+
"script": "try{ console.log('[fetchInited] fired, options:', event.data && event.data.options ? event.data.options.length : 'N/A'); var s = event.context && event.context.scoped; if(!s){ var amisScoped = window.amisScoped || (document.querySelector('[data-amisScoped]') && document.querySelector('[data-amisScoped]').__amisScoped); if(amisScoped){ s = amisScoped; } } if(s){ var tree = s.getComponentById('u:instanceNavTree'); if(tree && tree.props && tree.props.formItem){ window.__navTreeComponent = tree; console.log('[fetchInited] saved tree ref to global'); var opts = event.data && event.data.options; if(Array.isArray(opts)){ opts = JSON.parse(JSON.stringify(opts)); tree.props.formItem.setOptions(opts, undefined, event.data); console.log('[fetchInited] setOptions done, opts count:', opts.length); if(tree.forceUpdate){ setTimeout(function(){ tree.forceUpdate(); console.log('[fetchInited] forceUpdate called'); }, 100); } } } else { console.log('[fetchInited] tree found but no formItem, trying reload'); } } else { console.log('[fetchInited] no scoped context found'); } }catch(e){ console.warn('[fetchInited error]', e); }"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
65
79
|
}
|
|
66
80
|
},
|
|
67
81
|
"body": [
|
|
82
|
+
{
|
|
83
|
+
"type": "input-tree",
|
|
84
|
+
"name": "instanceNavTree",
|
|
85
|
+
"treeContainerClassName": "h-full bg-white",
|
|
86
|
+
"className": "instance-box-tree h-full w-full p-0 bg-white",
|
|
87
|
+
"id": "u:instanceNavTree",
|
|
88
|
+
"stacked": true,
|
|
89
|
+
"multiple": false,
|
|
90
|
+
"enableNodePath": false,
|
|
91
|
+
"hideRoot": true,
|
|
92
|
+
"showIcon": true,
|
|
93
|
+
"initiallyOpen": false,
|
|
94
|
+
"virtualThreshold": 100000,
|
|
95
|
+
"value": "${value}",
|
|
96
|
+
"size": "md",
|
|
97
|
+
"onEvent": {
|
|
98
|
+
"change": {
|
|
99
|
+
"actions": [
|
|
100
|
+
{
|
|
101
|
+
"actionType": "setValue",
|
|
102
|
+
"componentId": "instances_list_service",
|
|
103
|
+
"args": {
|
|
104
|
+
"value": {
|
|
105
|
+
"isFlowDataDone": false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"actionType": "custom",
|
|
111
|
+
"script": "//获取上一次的flowId和categoryId\nconst lastFlowId = event.data.flowId;\nconst lastCategoryId = event.data.categoryId;\n//从value中获取最新的flowId\nvar flowIdRegex = /&flowId=([^&]+)/;\nvar flowIdMatch = event.data.value.match(flowIdRegex);\nconst flowId = flowIdMatch && flowIdMatch.length > 0 ? flowIdMatch[1] : \"\";\n//从value中获取最新的categoryId\nvar categoryIdRegex = /&categoryId=([^&]+)/;\nvar categoryIdMatch = event.data.value.match(categoryIdRegex);\nconst categoryId = categoryIdMatch && categoryIdMatch.length > 0 ? categoryIdMatch[1] : \"\";\n//获取上一次的listname和最新的listname\nconst lastListName = event.data.listName;\nconst listName = event.data.value.split('?')[0].split('instances/grid/')[1];\n//切换流程时清除过滤条件\nif (lastListName == \"monitor\" && listName == \"monitor\" && (flowId != lastFlowId || categoryId != lastCategoryId)) {\n listViewPropsStoreKey = window.location.pathname + \"/crud\";\n sessionStorage.removeItem(listViewPropsStoreKey);\n sessionStorage.removeItem(listViewPropsStoreKey + \"/query\");\n}"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"actionType": "setValue",
|
|
115
|
+
"componentId": "instances_list_service",
|
|
116
|
+
"args": {
|
|
117
|
+
"value": {
|
|
118
|
+
"additionalFilters": [
|
|
119
|
+
"${event.data.options.name}",
|
|
120
|
+
"=",
|
|
121
|
+
"${event.data.options.value}"
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"expression": "${event.data.options.level>=10}"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"args": {
|
|
129
|
+
"link": "${event.data.value}",
|
|
130
|
+
"blank": false
|
|
131
|
+
},
|
|
132
|
+
"actionType": "link"
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"menuTpl": {
|
|
138
|
+
"type": "wrapper",
|
|
139
|
+
"className": "flex items-center py-1.5 px-3 m-0 rounded-md transition-colors duration-150",
|
|
140
|
+
"body": [
|
|
141
|
+
{
|
|
142
|
+
"type": "tpl",
|
|
143
|
+
"className": "flex-1 leading-6 truncate instance-menu-label",
|
|
144
|
+
"tpl": "${label}",
|
|
145
|
+
"id": "u:9dee51f00db4"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"type": "tpl",
|
|
149
|
+
"className": "ml-auto",
|
|
150
|
+
"tpl": "",
|
|
151
|
+
"badge": {
|
|
152
|
+
"className": "h-0",
|
|
153
|
+
"offset": [
|
|
154
|
+
-5,
|
|
155
|
+
0
|
|
156
|
+
],
|
|
157
|
+
"mode": "text",
|
|
158
|
+
"text": "${tag | toInt}",
|
|
159
|
+
"overflowCount": 999
|
|
160
|
+
},
|
|
161
|
+
"id": "u:2329cd1fecc2"
|
|
162
|
+
}
|
|
163
|
+
],
|
|
164
|
+
"id": "u:545154bcc334"
|
|
165
|
+
},
|
|
166
|
+
"unfoldedLevel": 2,
|
|
167
|
+
"source": "${options}"
|
|
168
|
+
}
|
|
68
169
|
],
|
|
69
|
-
"
|
|
170
|
+
"api": {
|
|
70
171
|
"method": "get",
|
|
71
|
-
"url": "${context.rootUrl}/api/${appId}/workflow/nav
|
|
172
|
+
"url": "${context.rootUrl}/api/${appId}/workflow/nav",
|
|
72
173
|
"headers": {
|
|
73
174
|
"Authorization": "Bearer ${context.tenantId},${context.authToken}"
|
|
74
175
|
},
|
|
176
|
+
"trackExpression": "false",
|
|
75
177
|
"messages": {},
|
|
76
|
-
"adaptor": "payload.data.value = window.location.pathname + decodeURIComponent(window.location.search)
|
|
77
|
-
# "adaptor": "payload.data.value = window.location.pathname + decodeURIComponent(window.location.search); return payload;"
|
|
178
|
+
"adaptor": "payload.data.value = window.location.pathname + decodeURIComponent(window.location.search); payload.data._ts = Date.now(); if(payload.data.options){ payload.data.options = JSON.parse(JSON.stringify(payload.data.options)); window.__latestNavOptions = payload.data.options; window.__latestNavData = payload.data; console.log('[adaptor] saved options to global, count:', payload.data.options.length); } return payload;"
|
|
78
179
|
},
|
|
79
180
|
"messages": {},
|
|
80
181
|
"dsType": "api"
|
|
@@ -45,7 +45,11 @@ window.waitForThing(window, 'socket').then(()=>{
|
|
|
45
45
|
};
|
|
46
46
|
if (isFirstRun === false && shouldReloadView()) {
|
|
47
47
|
window.$(".list-view-btn-reload").click()
|
|
48
|
-
window
|
|
48
|
+
clearTimeout(window.__navReloadTimer)
|
|
49
|
+
clearTimeout(window.__navRetryTimer)
|
|
50
|
+
window.__navReloadTimer = setTimeout(function(){ window.$(".instance-nav-reload").click() }, 500)
|
|
51
|
+
// Retry: reload tree only (no API request) to pick up data already in Service store
|
|
52
|
+
window.__navRetryTimer = setTimeout(function(){ window.$(".instance-tree-reload").click() }, 2000)
|
|
49
53
|
}
|
|
50
54
|
isFirstRun = false;
|
|
51
55
|
});
|
|
@@ -769,6 +769,24 @@ actions:
|
|
|
769
769
|
const iframe_url = `/api/workflow/designer?url=${url}&title=${title}`;
|
|
770
770
|
return Steedos.openWindow(Steedos.absoluteUrl(iframe_url));
|
|
771
771
|
}
|
|
772
|
+
designFlowV2:
|
|
773
|
+
label: 流程设计器V2(BETA)
|
|
774
|
+
visible: !<tag:yaml.org,2002:js/function> |-
|
|
775
|
+
function (object_name, record_id, record_permissions) {
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
'on': record_more
|
|
779
|
+
todo: !<tag:yaml.org,2002:js/function> |-
|
|
780
|
+
function (object_name, record_id, record_permissions, data) {
|
|
781
|
+
const flow = data && data.record;
|
|
782
|
+
const space = Builder.settings.context.user.spaceId;
|
|
783
|
+
const companyId = Builder.settings.context.user.company_id;
|
|
784
|
+
let url = `/api/workflow/designer-v2?flowId=${flow._id}`;
|
|
785
|
+
if (companyId && !Builder.settings.context.user.is_space_admin) {
|
|
786
|
+
url = url + `&companyId=${companyId}`;
|
|
787
|
+
}
|
|
788
|
+
return Steedos.openWindow(url);
|
|
789
|
+
}
|
|
772
790
|
enableFlow:
|
|
773
791
|
label: Enable Flow
|
|
774
792
|
visible: !<tag:yaml.org,2002:js/function> |-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "liquid",
|
|
3
|
-
"template": "<style>\n @keyframes fadeUpSpring {\n 0% { opacity: 0; transform: translateY(10px); }\n 100% { opacity: 1; transform: translateY(0); }\n }\n \n /* Make scrollbars standardized and visible */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.25); /* Darker for visibility on gray bg */\n border-radius: 4px;\n border: 2px solid transparent; /* Creates padding effect */\n background-clip: content-box;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: rgba(0, 0, 0, 0.4);\n }\n\n /* \n Fix outer modal scrollbar - SAFER VERSION \n Only apply these aggressive overrides (no padding, hidden overflow)\n to the specific modal that contains our component (identified by #steedosFlowSelectorSidebarList).\n This prevents breaking other stacked modals like 'Confirm Dialogs'.\n */\n .antd-Modal-body:has(#steedosFlowSelectorSidebarList) {\n overflow: hidden !important;\n padding: 0 !important; /* Optional: maximize space */\n display: flex;\n flex-direction: column;\n }\n\n /* Ensure the AMIS container fills height if needed */\n .antd-Service, .liquid-amis-container {\n height: 100%;\n }\n</style>\n\n<!-- Main Container: Fixed Height 70vh. -->\n<div class=\"flex h-[70vh] max-h-[800px] w-full overflow-hidden font-sans text-gray-900 bg-white\" style=\"min-height: 0;\">\n\n <!-- Left Sidebar -->\n <!-- flex-col, h-full, overflow-hidden -->\n <div class=\"flex flex-col w-[260px] h-full border-r border-gray-200 bg-[#F2F2F7] shrink-0 overflow-hidden\">\n <!-- Header -->\n <div class=\"shrink-0 pt-4 pb-2 px-3\">\n <div class=\"px-2 mb-3 text-2xl font-bold tracking-tight text-black\">流程</div>\n <div class=\"relative group\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-gray-500\">\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"></circle>\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>\n </svg>\n </div>\n <input type=\"text\" id=\"searchInput\" placeholder=\"搜索\" class=\"w-full rounded-[10px] border-none bg-[#767680]/10 py-1.5 pl-9 pr-3 text-[14px] text-gray-900 placeholder:text-gray-500 outline-none transition-all duration-200 focus:bg-white focus:shadow-sm focus:ring-2 focus:ring-blue-500/20\">\n </div>\n </div>\n \n <!-- List Container -->\n <!-- min-h-0 is CRITICAL for flex child scrolling -->\n <div class=\"flex-1 min-h-0 overflow-y-auto px-2 pb-4 space-y-0.5 scroll-smooth\" id=\"steedosFlowSelectorSidebarList\">\n </div>\n </div>\n\n <!-- Right Content -->\n <!-- flex-1 fills remaining width -->\n <div class=\"flex-1 h-full relative bg-white overflow-hidden\">\n <!-- Absolute inset-0 locks the scroll container size -->\n <div id=\"mainContentScroll\" class=\"absolute inset-0 overflow-y-auto scroll-smooth p-6\">\n <div id=\"contentContainer\" class=\"w-full h-auto min-h-full\">\n <div class=\"flex h-full w-full flex-col items-center justify-center pt-20\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path>\n </svg>\n <span>正在加载资源...</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n const WorkflowService = {\n apiBase: \"\", \n getHeaders: function() { return { 'Content-Type': 'application/json' }; },\n getData: async function() {\n try {\n const appId = (typeof data !== 'undefined' && data.context && data.context.app_id) ? data.context.app_id : \"\";\n const url = this.apiBase + \"/service/api/flows/getList?action=new&appId=\" + encodeURIComponent(appId);\n const res = await fetch(url, { headers: this.getHeaders() });\n const treeData = await res.json();\n const categories = [];\n const parsedFlows = [];\n if (Array.isArray(treeData)) {\n treeData.forEach(cat => {\n categories.push({ _id: cat._id, name: cat.name });\n if (Array.isArray(cat.flows)) {\n cat.flows.forEach(f => {\n parsedFlows.push({\n id: f._id, name: f.name, categoryId: cat._id, categoryName: cat.name || \"其他流程\" \n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) { \n console.error(\"WorkflowService Error:\", e);\n return { categories: [], flows: [] }; \n }\n },\n getFavorites: function() {\n const saved = localStorage.getItem('steedos_fav_ids');\n return saved ? JSON.parse(saved) : [];\n },\n toggleFavorite: function(flowId, isFav) {\n let favs = this.getFavorites();\n if (isFav) { if (!favs.includes(flowId)) favs.push(flowId); } \n else { favs = favs.filter(id => id !== flowId); }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const AppState = { allFlows: [], categories: [], favorites: [] };\n const sidebarEl = document.getElementById('steedosFlowSelectorSidebarList');\n const contentEl = document.getElementById('contentContainer');\n const searchInput = document.getElementById('searchInput');\n\n async function init() {\n try {\n const data = await WorkflowService.getData();\n AppState.allFlows = data.flows;\n AppState.categories = data.categories;\n AppState.favorites = WorkflowService.getFavorites();\n renderUI();\n } catch (e) {\n contentEl.innerHTML = `<div class=\"text-gray-400 text-sm\">加载失败,请检查网络</div>`;\n }\n }\n\n function renderUI(filterText = \"\") {\n sidebarEl.innerHTML = \"\";\n contentEl.innerHTML = \"\";\n\n const isSearching = filterText.length > 0;\n let groups = [];\n\n const favFlows = AppState.allFlows.filter(f => \n AppState.favorites.includes(f.id) && \n (isSearching ? f.name.includes(filterText) : true)\n );\n if (favFlows.length > 0) {\n groups.push({ id: 'fav', name: \"我的收藏\", items: favFlows, isFav: true });\n }\n\n AppState.categories.forEach(cat => {\n const items = AppState.allFlows.filter(f => \n f.categoryId === cat._id &&\n (isSearching ? f.name.includes(filterText) : true)\n );\n if (items.length > 0) {\n groups.push({ id: cat._id, name: cat.name, items: items, isFav: false });\n }\n });\n\n const otherItems = AppState.allFlows.filter(f => \n !AppState.categories.find(c => c._id === f.categoryId) &&\n (isSearching ? f.name.includes(filterText) : true)\n );\n if (otherItems.length > 0) {\n groups.push({ id: 'other', name: \"其他流程\", items: otherItems, isFav: false });\n }\n\n if (groups.length === 0) {\n contentEl.innerHTML = `<div class=\"animate-[fadeUpSpring_0.5s_ease-out] text-center pt-20\"><div class=\"text-gray-200 text-7xl mb-4\">∅</div><div class=\"text-gray-400 text-sm\">未找到匹配流程</div></div>`;\n return;\n }\n\n groups.forEach((group, index) => {\n const groupId = `group-\\${group.id}`;\n const navItem = document.createElement('div');\n let navBase = \"group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-[14px] transition-all duration-200 ease-out select-none\";\n let activeClass = \"bg-[#007AFF] text-white shadow-sm font-medium\";\n let inactiveClass = \"text-gray-700 hover:bg-black/5 active:bg-black/10\";\n \n navItem.className = `\\${navBase} \\${index === 0 ? activeClass : inactiveClass}`;\n const badgeClass = index === 0 ? \"text-white/80\" : \"text-gray-400 group-hover:text-gray-500\";\n \n navItem.innerHTML = `<span class=\"truncate\">\\${group.isFav ? '★ ' : ''}\\${group.name}</span><span class=\"\\${badgeClass} text-[12px] font-medium transition-colors\">\\${group.items.length}</span>`;\n \n navItem.onclick = () => {\n Array.from(sidebarEl.children).forEach(el => {\n el.className = `\\${navBase} \\${inactiveClass}`;\n el.querySelector('span:last-child').className = \"text-gray-400 group-hover:text-gray-500 text-[12px] font-medium transition-colors\";\n });\n navItem.className = `\\${navBase} \\${activeClass}`;\n navItem.querySelector('span:last-child').className = \"text-white/80 text-[12px] font-medium transition-colors\";\n \n const target = document.getElementById(groupId);\n const container = document.getElementById('mainContentScroll');\n if(target && container) {\n const targetTop = target.getBoundingClientRect().top; \n const containerTop = container.getBoundingClientRect().top; \n container.scrollTo({ top: container.scrollTop + targetTop - containerTop - 16, behavior: 'smooth' });\n }\n };\n sidebarEl.appendChild(navItem);\n\n const section = document.createElement('div');\n section.id = groupId;\n section.className = \"mb-10\";\n const headerColor = group.isFav ? 'text-amber-500' : 'text-gray-900';\n section.innerHTML = `<div class=\"sticky top-0 z-20 mb-4 bg-white/95 pb-2 text-xl font-bold tracking-tight backdrop-blur-xl text-left border-b border-gray-100 \\${headerColor}\">\\${group.name}</div>`;\n\n const grid = document.createElement('div');\n grid.className = 'grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4';\n\n group.items.forEach((flow, i) => {\n const isFav = AppState.favorites.includes(flow.id);\n const colorMap = ['bg-blue-50 text-blue-600', 'bg-orange-50 text-orange-600', 'bg-emerald-50 text-emerald-600', 'bg-indigo-50 text-indigo-600'];\n const colorClass = colorMap[(flow.name.length + i) % 4];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n const card = document.createElement('div');\n card.className = 'group relative flex h-auto min-h-[72px] cursor-pointer items-center rounded-2xl border border-gray-100 bg-white p-3 text-left shadow-[0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.02] transition-all duration-300 ease-out animate-[fadeUpSpring_0.6s_cubic-bezier(0.16,1,0.3,1)_forwards] hover:-translate-y-1 hover:border-gray-200 hover:shadow-[0_12px_24px_rgba(0,0,0,0.08)] active:scale-[0.98] active:bg-gray-50';\n card.style.animationDelay = `\\${Math.min(i * 0.04, 0.6)}s`;\n card.style.opacity = '0';\n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]';\n const btnBgClass = isFav ? 'opacity-100 hover:scale-110' : 'opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:scale-110';\n\n card.innerHTML = `\n <div class=\"star-btn group/btn absolute right-2 top-1/2 -translate-y-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200 \\${btnBgClass}\" title=\"\\${isFav ? '取消收藏' : '加入收藏'}\">\n <svg class=\"h-5 w-5 transition-colors duration-300 \\${iconClass}\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z\" />\n </svg>\n </div>\n <div class=\"mr-4 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[16px] font-bold \\${colorClass}\">\\${firstChar}</div>\n <div class=\"flex-1 pr-8 text-[15px] font-medium text-gray-900 line-clamp-3 leading-relaxed tracking-tight\" title=\"\\${flow.name}\">\\${flow.name}</div>\n `;\n card.onclick = () => {\n setTimeout(() => {\n data._scoped.doAction([\n { \"actionType\": \"broadcast\", \"args\": { \"eventName\": \"flows.selected\" }, \"data\": { \"value\": flow.id } }\n ])\n }, 50);\n };\n const starBtn = card.querySelector('.star-btn');\n const starIcon = starBtn.querySelector('svg');\n starBtn.onclick = (e) => {\n e.stopPropagation();\n const newFavState = !starBtn.classList.contains('active-fav');\n if (newFavState) {\n starBtn.classList.add('active-fav', 'opacity-100');\n starBtn.classList.add('animate-[starPop_0.4s_ease-out]');\n starIcon.setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-yellow-400 fill-current');\n } else {\n starBtn.classList.remove('active-fav', 'opacity-100');\n starBtn.classList.remove('animate-[starPop_0.4s_ease-out]');\n starIcon.setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]');\n }\n AppState.favorites = WorkflowService.toggleFavorite(flow.id, newFavState);\n setTimeout(() => renderUI(searchInput.value), 300);\n };\n if (isFav) starBtn.classList.add('active-fav');\n grid.appendChild(card);\n });\n section.appendChild(grid);\n contentEl.appendChild(section);\n });\n }\n\n searchInput.addEventListener('input', (e) => renderUI(e.target.value.trim()));\n init();\n</script>\n",
|
|
3
|
+
"template": "<style>\n @keyframes fadeUpSpring {\n 0% { opacity: 0; transform: translateY(10px); }\n 100% { opacity: 1; transform: translateY(0); }\n }\n \n /* Make scrollbars standardized and visible */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.25); /* Darker for visibility on gray bg */\n border-radius: 4px;\n border: 2px solid transparent; /* Creates padding effect */\n background-clip: content-box;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: rgba(0, 0, 0, 0.4);\n }\n\n /* \n Fix outer modal scrollbar - SAFER VERSION \n Only apply these aggressive overrides (no padding, hidden overflow)\n to the specific modal that contains our component (identified by #steedosFlowSelectorSidebarList).\n This prevents breaking other stacked modals like 'Confirm Dialogs'.\n */\n .antd-Modal-body:has(#steedosFlowSelectorSidebarList) {\n overflow: hidden !important;\n padding: 0 !important; /* Optional: maximize space */\n display: flex;\n flex-direction: column;\n }\n\n /* Ensure the AMIS container fills height if needed */\n .antd-Service, .liquid-amis-container {\n height: 100%;\n }\n</style>\n\n<!-- Main Container: Fixed Height 70vh. -->\n<div class=\"flex h-[70vh] max-h-[800px] w-full overflow-hidden font-sans text-gray-900 bg-white\" style=\"min-height: 0;\">\n\n <!-- Left Sidebar -->\n <!-- flex-col, h-full, overflow-hidden -->\n <div class=\"flex flex-col w-[260px] h-full border-r border-gray-200 bg-[#F2F2F7] shrink-0 overflow-hidden\">\n <!-- Header -->\n <div class=\"shrink-0 pt-4 pb-2 px-3\">\n <div class=\"relative group\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-gray-500\">\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"></circle>\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>\n </svg>\n </div>\n <input type=\"text\" id=\"searchInput\" placeholder=\"搜索流程名称...\" class=\"w-full rounded-[10px] border-none bg-[#767680]/10 py-1.5 pl-9 pr-3 text-[14px] text-gray-900 placeholder:text-gray-500 outline-none transition-all duration-200 focus:bg-white focus:shadow-sm focus:ring-2 focus:ring-blue-500/20\">\n </div>\n </div>\n \n <!-- List Container -->\n <!-- min-h-0 is CRITICAL for flex child scrolling -->\n <div class=\"flex-1 min-h-0 overflow-y-auto px-2 pb-4 space-y-0.5 scroll-smooth\" id=\"steedosFlowSelectorSidebarList\">\n </div>\n </div>\n\n <!-- Right Content -->\n <!-- flex-1 fills remaining width -->\n <div class=\"flex-1 h-full relative bg-white overflow-hidden\">\n <!-- Absolute inset-0 locks the scroll container size -->\n <div id=\"mainContentScroll\" class=\"absolute inset-0 overflow-y-auto scroll-smooth p-6\">\n <div id=\"contentContainer\" class=\"w-full h-auto min-h-full\">\n <div class=\"flex h-full w-full flex-col items-center justify-center pt-20\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path>\n </svg>\n <span>正在加载资源...</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n const WorkflowService = {\n apiBase: \"\", \n getHeaders: function() { return { 'Content-Type': 'application/json' }; },\n getData: async function() {\n try {\n const appId = (typeof data !== 'undefined' && data.context && data.context.app_id) ? data.context.app_id : \"\";\n const url = this.apiBase + \"/service/api/flows/getList?action=new&appId=\" + encodeURIComponent(appId);\n const res = await fetch(url, { headers: this.getHeaders() });\n const treeData = await res.json();\n const categories = [];\n const parsedFlows = [];\n if (Array.isArray(treeData)) {\n treeData.forEach(cat => {\n categories.push({ _id: cat._id, name: cat.name });\n if (Array.isArray(cat.flows)) {\n cat.flows.forEach(f => {\n parsedFlows.push({\n id: f._id, name: f.name, categoryId: cat._id, categoryName: cat.name || \"其他流程\" \n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) { \n console.error(\"WorkflowService Error:\", e);\n return { categories: [], flows: [] }; \n }\n },\n getFavorites: function() {\n const saved = localStorage.getItem('steedos_fav_ids');\n return saved ? JSON.parse(saved) : [];\n },\n toggleFavorite: function(flowId, isFav) {\n let favs = this.getFavorites();\n if (isFav) { if (!favs.includes(flowId)) favs.push(flowId); } \n else { favs = favs.filter(id => id !== flowId); }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const AppState = { allFlows: [], categories: [], favorites: [] };\n const sidebarEl = document.getElementById('steedosFlowSelectorSidebarList');\n const contentEl = document.getElementById('contentContainer');\n const searchInput = document.getElementById('searchInput');\n\n async function init() {\n try {\n const data = await WorkflowService.getData();\n AppState.allFlows = data.flows;\n AppState.categories = data.categories;\n AppState.favorites = WorkflowService.getFavorites();\n renderUI();\n } catch (e) {\n contentEl.innerHTML = `<div class=\"text-gray-400 text-sm\">加载失败,请检查网络</div>`;\n }\n }\n\n function renderUI(filterText = \"\") {\n sidebarEl.innerHTML = \"\";\n contentEl.innerHTML = \"\";\n\n const isSearching = filterText.length > 0;\n let groups = [];\n\n const favFlows = AppState.allFlows.filter(f => \n AppState.favorites.includes(f.id) && \n (isSearching ? f.name.includes(filterText) : true)\n );\n if (favFlows.length > 0) {\n groups.push({ id: 'fav', name: \"我的收藏\", items: favFlows, isFav: true });\n }\n\n AppState.categories.forEach(cat => {\n const items = AppState.allFlows.filter(f => \n f.categoryId === cat._id &&\n (isSearching ? f.name.includes(filterText) : true)\n );\n if (items.length > 0) {\n groups.push({ id: cat._id, name: cat.name, items: items, isFav: false });\n }\n });\n\n const otherItems = AppState.allFlows.filter(f => \n !AppState.categories.find(c => c._id === f.categoryId) &&\n (isSearching ? f.name.includes(filterText) : true)\n );\n if (otherItems.length > 0) {\n groups.push({ id: 'other', name: \"其他流程\", items: otherItems, isFav: false });\n }\n\n if (groups.length === 0) {\n contentEl.innerHTML = `<div class=\"animate-[fadeUpSpring_0.5s_ease-out] text-center pt-20\"><div class=\"text-gray-200 text-7xl mb-4\">∅</div><div class=\"text-gray-400 text-sm\">未找到匹配流程</div></div>`;\n return;\n }\n\n const contentFragment = document.createDocumentFragment();\n groups.forEach((group, index) => {\n const groupId = `group-\\${group.id}`;\n const navItem = document.createElement('div');\n let navBase = \"group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-[14px] transition-all duration-200 ease-out select-none\";\n let activeClass = \"bg-[#007AFF] text-white shadow-sm font-medium\";\n let inactiveClass = \"text-gray-700 hover:bg-black/5 active:bg-black/10\";\n \n navItem.className = `\\${navBase} \\${index === 0 ? activeClass : inactiveClass}`;\n const badgeClass = index === 0 ? \"text-white/80\" : \"text-gray-400 group-hover:text-gray-500\";\n \n navItem.innerHTML = `<span class=\"truncate\">\\${group.isFav ? '★ ' : ''}\\${group.name}</span><span class=\"\\${badgeClass} text-[12px] font-medium transition-colors\">\\${group.items.length}</span>`;\n \n navItem.onclick = () => {\n Array.from(sidebarEl.children).forEach(el => {\n el.className = `\\${navBase} \\${inactiveClass}`;\n el.querySelector('span:last-child').className = \"text-gray-400 group-hover:text-gray-500 text-[12px] font-medium transition-colors\";\n });\n navItem.className = `\\${navBase} \\${activeClass}`;\n navItem.querySelector('span:last-child').className = \"text-white/80 text-[12px] font-medium transition-colors\";\n \n const target = document.getElementById(groupId);\n const container = document.getElementById('mainContentScroll');\n if(target && container) {\n const targetTop = target.getBoundingClientRect().top; \n const containerTop = container.getBoundingClientRect().top; \n container.scrollTo({ top: container.scrollTop + targetTop - containerTop - 16, behavior: 'smooth' });\n }\n };\n sidebarEl.appendChild(navItem);\n\n const section = document.createElement('div');\n section.id = groupId;\n section.className = \"mb-10\";\n const headerColor = group.isFav ? 'text-amber-500' : 'text-gray-900';\n section.innerHTML = `<div class=\"sticky top-0 z-20 mb-4 bg-white pb-2 text-xl font-bold tracking-tight text-left border-b border-gray-100 \\${headerColor}\">\\${group.name}</div>`;\n\n const grid = document.createElement('div');\n grid.className = 'grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4';\n\n const gridFragment = document.createDocumentFragment();\n group.items.forEach((flow, i) => {\n const isFav = AppState.favorites.includes(flow.id);\n const colorMap = ['bg-blue-50 text-blue-600', 'bg-orange-50 text-orange-600', 'bg-emerald-50 text-emerald-600', 'bg-indigo-50 text-indigo-600'];\n const colorClass = colorMap[(flow.name.length + i) % 4];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n const card = document.createElement('div');\n card.className = 'group relative flex h-auto min-h-[72px] cursor-pointer items-center rounded-2xl border border-gray-100 bg-white p-3 text-left shadow-[0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.02] transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-1 hover:border-gray-200 hover:shadow-[0_12px_24px_rgba(0,0,0,0.08)] active:scale-[0.98] active:bg-gray-50' + (i < 12 ? ' animate-[fadeUpSpring_0.4s_cubic-bezier(0.16,1,0.3,1)_forwards]' : '');\n if (i < 12) { card.style.animationDelay = (i * 0.03) + 's'; card.style.opacity = '0'; }\n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]';\n const btnBgClass = isFav ? 'opacity-100 hover:scale-110' : 'opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:scale-110';\n\n card.innerHTML = `\n <div class=\"star-btn group/btn absolute right-2 top-1/2 -translate-y-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200 \\${btnBgClass}\" title=\"\\${isFav ? '取消收藏' : '加入收藏'}\">\n <svg class=\"h-5 w-5 transition-colors duration-300 \\${iconClass}\" viewBox=\"0 0 24 24\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z\" />\n </svg>\n </div>\n <div class=\"mr-4 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[16px] font-bold \\${colorClass}\">\\${firstChar}</div>\n <div class=\"flex-1 pr-8 text-[15px] font-medium text-gray-900 line-clamp-3 leading-relaxed tracking-tight\" title=\"\\${flow.name}\">\\${flow.name}</div>\n `;\n card.onclick = () => {\n if (card.dataset.loading === 'true') return;\n card.dataset.loading = 'true';\n card.style.pointerEvents = 'none';\n card.style.opacity = '0.6';\n card.innerHTML = '<div class=\"flex items-center justify-center w-full gap-2 text-gray-400 text-sm\"><svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle><path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg><span>正在创建...</span></div>';\n setTimeout(() => {\n data._scoped.doAction([\n { \"actionType\": \"broadcast\", \"args\": { \"eventName\": \"flows.selected\" }, \"data\": { \"value\": flow.id } }\n ])\n }, 50);\n };\n const starBtn = card.querySelector('.star-btn');\n const starIcon = starBtn.querySelector('svg');\n starBtn.onclick = (e) => {\n e.stopPropagation();\n const newFavState = !starBtn.classList.contains('active-fav');\n if (newFavState) {\n starBtn.classList.add('active-fav', 'opacity-100');\n starBtn.classList.add('animate-[starPop_0.4s_ease-out]');\n starIcon.setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-yellow-400 fill-current');\n } else {\n starBtn.classList.remove('active-fav', 'opacity-100');\n starBtn.classList.remove('animate-[starPop_0.4s_ease-out]');\n starIcon.setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]');\n }\n AppState.favorites = WorkflowService.toggleFavorite(flow.id, newFavState);\n setTimeout(() => renderUI(searchInput.value), 300);\n };\n if (isFav) starBtn.classList.add('active-fav');\n gridFragment.appendChild(card);\n });\n grid.appendChild(gridFragment);\n section.appendChild(grid);\n contentFragment.appendChild(section);\n });\n contentEl.appendChild(contentFragment);\n }\n\n let _searchTimer = null;\n searchInput.addEventListener('input', (e) => {\n clearTimeout(_searchTimer);\n _searchTimer = setTimeout(() => renderUI(e.target.value.trim()), 300);\n });\n init();\n</script>\n",
|
|
4
4
|
"className": "h-full"
|
|
5
5
|
}
|
|
@@ -81,6 +81,34 @@ router.get('/am/designer/startup', async function (req, res) {
|
|
|
81
81
|
}
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
// startup v2 - lightweight: only return the specified flow and its form
|
|
85
|
+
router.get('/am/designer/startup/v2', async function (req, res) {
|
|
86
|
+
try {
|
|
87
|
+
let flowId = req.query.flowId;
|
|
88
|
+
if (!flowId) {
|
|
89
|
+
return res.status(400).send({ status: 'error', message: 'flowId is required' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let flowCollection = await getCollection('flows');
|
|
93
|
+
let formCollection = await getCollection('forms');
|
|
94
|
+
|
|
95
|
+
let flow = await flowCollection.findOne({ _id: flowId }, { projection: { historys: 0 } });
|
|
96
|
+
if (!flow) {
|
|
97
|
+
return res.status(404).send({ status: 'error', message: 'Flow not found' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let form = await formCollection.findOne({ _id: flow.form }, { projection: { historys: 0 } });
|
|
101
|
+
|
|
102
|
+
res.send({
|
|
103
|
+
flow,
|
|
104
|
+
form,
|
|
105
|
+
sync_token: new Date().getTime() / 1000
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
res.status(500).send(error.message);
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
84
112
|
// 表单
|
|
85
113
|
router.post('/am/forms', async function (req, res) {
|
|
86
114
|
try {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const steedosAuth = require('@steedos/auth');
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const bodyParser = require('body-parser');
|
|
6
|
+
|
|
7
|
+
router.use(bodyParser.json({ limit: '2mb' }));
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* AI Workflow Designer endpoint
|
|
11
|
+
*
|
|
12
|
+
* Accepts natural language instructions and current workflow steps,
|
|
13
|
+
* calls an OpenAI-compatible LLM API to generate modified steps.
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* WORKFLOW_AI_API_KEY - API key for the LLM provider
|
|
17
|
+
* WORKFLOW_AI_BASE_URL - Base URL (default: https://api.openai.com/v1)
|
|
18
|
+
* WORKFLOW_AI_MODEL - Model name (default: gpt-4o)
|
|
19
|
+
*/
|
|
20
|
+
router.post('/am/ai/design', async function auth(req, res, next) {
|
|
21
|
+
try {
|
|
22
|
+
const result = await steedosAuth.auth(req, res);
|
|
23
|
+
if (result && result.userId) {
|
|
24
|
+
req.user = result;
|
|
25
|
+
next();
|
|
26
|
+
} else {
|
|
27
|
+
res.status(401).json({ error: '请先登录' });
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
res.status(401).json({ error: '认证失败' });
|
|
31
|
+
}
|
|
32
|
+
}, async function (req, res) {
|
|
33
|
+
try {
|
|
34
|
+
const apiKey = process.env.WORKFLOW_AI_API_KEY;
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
return res.status(500).json({ error: '请先配置环境变量 WORKFLOW_AI_API_KEY' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const baseURL = process.env.WORKFLOW_AI_BASE_URL || 'https://api.openai.com/v1';
|
|
40
|
+
const model = process.env.WORKFLOW_AI_MODEL || 'gpt-4o';
|
|
41
|
+
|
|
42
|
+
const { steps, formFields, userRequest } = req.body;
|
|
43
|
+
|
|
44
|
+
if (!userRequest) {
|
|
45
|
+
return res.status(400).json({ error: '请输入需求描述' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build form fields description
|
|
49
|
+
const fieldsDesc = (formFields || [])
|
|
50
|
+
.filter(f => f.code)
|
|
51
|
+
.map(f => ` - ${f.name || f.code} (编码: ${f.code}, 类型: ${f.type || 'text'})`)
|
|
52
|
+
.join('\n');
|
|
53
|
+
|
|
54
|
+
const systemPrompt = `你是一个专业的审批流程设计师。你的任务是根据用户的自然语言描述,修改审批流程的步骤定义(JSON 格式)。
|
|
55
|
+
|
|
56
|
+
## 步骤数据结构
|
|
57
|
+
|
|
58
|
+
每个步骤(step)是一个 JSON 对象,必须包含以下字段:
|
|
59
|
+
- _id: 字符串,唯一标识。已有步骤保留原 _id,新步骤使用 "step-" + 小写字母数字随机串(如 "step-a1b2c3d4")
|
|
60
|
+
- name: 字符串,步骤名称
|
|
61
|
+
- step_type: 字符串,类型,只能是以下之一:
|
|
62
|
+
- "start" — 开始节点(流程必须有且仅有一个)
|
|
63
|
+
- "end" — 结束节点(流程必须有且仅有一个)
|
|
64
|
+
- "submit" — 填写步骤
|
|
65
|
+
- "sign" — 审批步骤
|
|
66
|
+
- "counterSign" — 会签步骤(多人同时审批)
|
|
67
|
+
- "condition" — 条件分支节点
|
|
68
|
+
- deal_type: 字符串, 处理人身份(submit/sign/counterSign 必填),可选值:
|
|
69
|
+
- "pickupAtRuntime" — 审批时指定
|
|
70
|
+
- "specifyUser" — 指定人员
|
|
71
|
+
- "applicantSuperior" — 申请人的上级
|
|
72
|
+
- "applicant" — 申请人
|
|
73
|
+
- "hrRole" — 指定角色
|
|
74
|
+
- "applicantRole" — 指定审批岗位
|
|
75
|
+
- posx, posy: 数字,节点位置。纵向从上到下排列,开始节点 posx=200,posy=40,之后每步 y 增加约120
|
|
76
|
+
- timeout_hours: 数字,超时小时数,默认 168
|
|
77
|
+
- lines: 数组,从当前步骤出发的连线,每条连线包含:
|
|
78
|
+
- _id: 字符串,连线唯一标识,用 "line-" + 随机串
|
|
79
|
+
- name: 字符串,连线名称(可空)
|
|
80
|
+
- to_step: 字符串,目标步骤的 _id
|
|
81
|
+
- state: 字符串,"submitted"(提交)/ "approved"(核准)/ "rejected"(驳回)
|
|
82
|
+
- condition: 字符串,条件表达式(仅条件节点的出线需要),格式如 {字段编码} > 10000
|
|
83
|
+
|
|
84
|
+
## 规则
|
|
85
|
+
1. 流程必须有且仅有一个 start 和一个 end 节点
|
|
86
|
+
2. 所有步骤必须通过 lines 互相连通,不能有孤立节点
|
|
87
|
+
3. 从 start 开始必须能最终到达 end
|
|
88
|
+
4. 审批步骤(sign)通常有两条出线:approved(核准)到下一步,rejected(驳回)回到填写或上一步
|
|
89
|
+
5. 条件节点(condition)有多条出线,每条带不同的 condition 表达式
|
|
90
|
+
6. 不要添加 notify、concurrentStart、concurrentEnd 类型的步骤
|
|
91
|
+
7. 修改时尽可能保留用户已有步骤的 _id 和设置,只改需要改的部分
|
|
92
|
+
8. 返回完整的 steps 数组
|
|
93
|
+
|
|
94
|
+
## 输出格式
|
|
95
|
+
直接返回 JSON 数组,不要有任何其他文字、解释、markdown 标记。`;
|
|
96
|
+
|
|
97
|
+
const userContent = `## 当前表单字段
|
|
98
|
+
${fieldsDesc || '(无字段信息)'}
|
|
99
|
+
|
|
100
|
+
## 当前流程步骤
|
|
101
|
+
${JSON.stringify(steps || [], null, 2)}
|
|
102
|
+
|
|
103
|
+
## 用户需求
|
|
104
|
+
${userRequest}
|
|
105
|
+
|
|
106
|
+
请返回修改后的完整 steps JSON 数组。`;
|
|
107
|
+
|
|
108
|
+
const response = await axios.post(`${baseURL}/chat/completions`, {
|
|
109
|
+
model,
|
|
110
|
+
messages: [
|
|
111
|
+
{ role: 'system', content: systemPrompt },
|
|
112
|
+
{ role: 'user', content: userContent },
|
|
113
|
+
],
|
|
114
|
+
temperature: 0.3,
|
|
115
|
+
max_tokens: 8000,
|
|
116
|
+
}, {
|
|
117
|
+
headers: {
|
|
118
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
},
|
|
121
|
+
timeout: 120000,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const content = response.data?.choices?.[0]?.message?.content;
|
|
125
|
+
if (!content) {
|
|
126
|
+
return res.status(500).json({ error: 'AI 未返回有效内容' });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Try to parse the JSON from the response
|
|
130
|
+
let stepsResult;
|
|
131
|
+
try {
|
|
132
|
+
let text = content.trim();
|
|
133
|
+
// Strip markdown code block if present
|
|
134
|
+
if (text.startsWith('```json')) text = text.slice(7);
|
|
135
|
+
else if (text.startsWith('```')) text = text.slice(3);
|
|
136
|
+
if (text.endsWith('```')) text = text.slice(0, -3);
|
|
137
|
+
text = text.trim();
|
|
138
|
+
|
|
139
|
+
// Find JSON array
|
|
140
|
+
const arrStart = text.indexOf('[');
|
|
141
|
+
const arrEnd = text.lastIndexOf(']');
|
|
142
|
+
if (arrStart !== -1 && arrEnd > arrStart) {
|
|
143
|
+
stepsResult = JSON.parse(text.substring(arrStart, arrEnd + 1));
|
|
144
|
+
} else {
|
|
145
|
+
stepsResult = JSON.parse(text);
|
|
146
|
+
}
|
|
147
|
+
} catch (parseErr) {
|
|
148
|
+
return res.status(500).json({
|
|
149
|
+
error: 'AI 返回内容无法解析为 JSON',
|
|
150
|
+
raw: content,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!Array.isArray(stepsResult)) {
|
|
155
|
+
return res.status(500).json({ error: 'AI 返回的不是步骤数组' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Basic validation
|
|
159
|
+
const hasStart = stepsResult.some(s => s.step_type === 'start');
|
|
160
|
+
const hasEnd = stepsResult.some(s => s.step_type === 'end');
|
|
161
|
+
if (!hasStart || !hasEnd) {
|
|
162
|
+
return res.status(500).json({ error: 'AI 返回的流程缺少开始或结束节点' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return res.json({ steps: stepsResult });
|
|
166
|
+
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('AI workflow design error:', error?.response?.data || error?.message || error);
|
|
169
|
+
if (error.response) {
|
|
170
|
+
const status = error.response.status;
|
|
171
|
+
const data = error.response.data;
|
|
172
|
+
if (status === 401 || status === 403) {
|
|
173
|
+
return res.status(500).json({ error: 'AI API 密钥无效或权限不足,请检查 WORKFLOW_AI_API_KEY' });
|
|
174
|
+
}
|
|
175
|
+
if (status === 429) {
|
|
176
|
+
return res.status(500).json({ error: 'AI API 请求频率超限,请稍后重试' });
|
|
177
|
+
}
|
|
178
|
+
return res.status(500).json({ error: data?.error?.message || 'AI API 调用失败' });
|
|
179
|
+
}
|
|
180
|
+
return res.status(500).json({ error: error.message || 'AI 服务调用失败' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
exports.default = router;
|