@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.
@@ -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": "setValue",
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
- "schemaApi": {
170
+ "api": {
70
171
  "method": "get",
71
- "url": "${context.rootUrl}/api/${appId}/workflow/nav?reload=${nav_reload_token}",
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);\n// return payload;\n\n\nreturn {\n \"type\": \"service\",\n \"data\": payload.data,\n \"body\": [{\n \"type\": \"input-tree\",\n \"name\": \"tree_\" + new Date().getTime(),\n \"treeContainerClassName\": \"h-full bg-white\",\n \"className\": \"instance-box-tree h-full w-full p-0 bg-white\",\n \"id\": \"u:9f3dd961ca12_\" + new Date().getTime(),\n \"stacked\": true,\n \"multiple\": false,\n \"enableNodePath\": false,\n \"hideRoot\": true,\n \"showIcon\": true,\n \"initiallyOpen\": false,\n \"virtualThreshold\": 100000,\n \"value\": \"${value}\",\n \"size\": \"md\",\n \"onEvent\": {\n \"change\": {\n \"actions\": [\n {\n \"actionType\": \"setValue\",\n \"componentId\": \"instances_list_service\",\n \"args\": {\n \"value\": {\n \"isFlowDataDone\": false\n }\n }\n },\n {\n \"actionType\": \"custom\",\n \"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}\"\n },\n {\n \"actionType\": \"setValue\",\n \"componentId\": \"instances_list_service\",\n \"args\": {\n \"value\": {\n \"additionalFilters\": [\n \"${event.data.options.name}\",\n \"=\",\n \"${event.data.options.value}\"\n ]\n }\n },\n \"expression\": \"${event.data.options.level>=10}\"\n },\n {\n \"args\": {\n \"link\": \"${event.data.value}\",\n \"blank\": false\n },\n \"actionType\": \"link\"\n }\n ]\n }\n },\n \"menuTpl\": {\n \"type\": \"wrapper\",\n \"className\": \"flex items-center py-1.5 px-3 m-0 rounded-md transition-colors duration-150\",\n \"body\": [\n {\n \"type\": \"tpl\",\n \"className\": \"flex-1 leading-6 truncate instance-menu-label\",\n \"tpl\": \"${label}\",\n \"id\": \"u:9dee51f00db4\"\n },\n {\n \"type\": \"tpl\",\n \"className\": \"ml-auto\",\n \"tpl\": \"\",\n \"badge\": {\n \"className\": \"h-0\",\n \"offset\": [\n -5,\n 0\n ],\n \"mode\": \"text\",\n \"text\": \"${tag | toInt}\",\n \"overflowCount\": 999\n },\n \"id\": \"u:2329cd1fecc2\"\n }\n ],\n \"id\": \"u:545154bcc334\"\n },\n \"unfoldedLevel\": 2,\n \"options\": payload.data.options\n }]\n}"
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.$(".instance-nav-reload").click()
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;