@steedos-labs/plugin-workflow 3.0.39 → 3.0.40

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.
Files changed (30) hide show
  1. package/designer/dist/amis-renderer/amis-renderer.css +1 -1
  2. package/designer/dist/amis-renderer/amis-renderer.js +1 -1
  3. package/designer/dist/assets/index-BIbXABqz.css +1 -0
  4. package/designer/dist/assets/index-DRUi3eGk.js +943 -0
  5. package/designer/dist/index.html +2 -2
  6. package/main/default/manager/instance_number_rules.js +1 -1
  7. package/main/default/manager/uuflow_manager.js +20 -2
  8. package/main/default/objects/instance_tasks/listviews/inbox.listview.yml +1 -1
  9. package/main/default/objects/instance_tasks/listviews/outbox.listview.yml +1 -1
  10. package/main/default/objects/instances/listviews/completed.listview.yml +1 -1
  11. package/main/default/objects/instances/listviews/draft.listview.yml +1 -1
  12. package/main/default/objects/instances/listviews/monitor.listview.yml +1 -1
  13. package/main/default/objects/instances/listviews/pending.listview.yml +1 -1
  14. package/main/default/pages/flow_selector.page.amis.json +2 -2
  15. package/main/default/pages/flow_selector_mobile.page.amis.json +2 -2
  16. package/main/default/pages/page_instance_print.page.amis.json +11 -1
  17. package/main/default/routes/am.router.js +3 -1
  18. package/main/default/routes/api_auto_number.router.js +166 -23
  19. package/main/default/routes/api_workflow_ai_form_design.router.js +116 -16
  20. package/main/default/routes/api_workflow_ai_form_design_stream.router.js +115 -17
  21. package/main/default/routes/api_workflow_box_filter.router.js +2 -2
  22. package/main/default/services/flows.service.js +20 -24
  23. package/main/default/services/instance.service.js +6 -4
  24. package/package.json +1 -1
  25. package/package.service.js +14 -0
  26. package/public/amis-renderer/amis-renderer.css +1 -1
  27. package/public/amis-renderer/amis-renderer.js +1 -1
  28. package/public/workflow/index.css +10 -3
  29. package/designer/dist/assets/index-CxYuhf9v.js +0 -757
  30. package/designer/dist/assets/index-Dve-EwQO.css +0 -1
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/api/workflow/designer-v2/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>designer</title>
8
- <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-CxYuhf9v.js"></script>
9
- <link rel="stylesheet" crossorigin href="/api/workflow/designer-v2/assets/index-Dve-EwQO.css">
8
+ <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-DRUi3eGk.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/api/workflow/designer-v2/assets/index-BIbXABqz.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -19,7 +19,7 @@ module.exports = {
19
19
  name: name
20
20
  });
21
21
  if (!numberRules) {
22
- throw new Meteor.Error('error!', `${name}`);
22
+ throw new Error('error!', `${name}`);
23
23
  }
24
24
  date = new Date();
25
25
  context = {};
@@ -1585,7 +1585,7 @@ UUFlowManager.workflow_engine = async function (approve_from_client, current_use
1585
1585
  };
1586
1586
 
1587
1587
  // Skip processed constants and helpers
1588
- const SKIP_DESCRIPTION = '滑步跳过:已在之前步骤处理过';
1588
+ const SKIP_DESCRIPTION = ''; //滑步跳过:已在之前步骤处理过
1589
1589
 
1590
1590
  function isNormalApprove(approve) {
1591
1591
  return !approve.type || approve.type === 'draft' || approve.type === 'reassign';
@@ -1662,7 +1662,7 @@ UUFlowManager.handleSkipProcessed = async function (instance_id, flow, maxDepth
1662
1662
  return;
1663
1663
  }
1664
1664
 
1665
- const next_step_id = lines[0].to_step;
1665
+ let next_step_id = lines[0].to_step;
1666
1666
  let next_step;
1667
1667
  try {
1668
1668
  next_step = UUFlowManager.getStep(instance, flow, next_step_id);
@@ -1670,6 +1670,24 @@ UUFlowManager.handleSkipProcessed = async function (instance_id, flow, maxDepth
1670
1670
  return;
1671
1671
  }
1672
1672
 
1673
+ // If the next step is a condition node, evaluate it server-side to find the real next step
1674
+ // Condition nodes should never be "stopped at" during slide step; we must resolve through them.
1675
+ let conditionDepth = 10;
1676
+ while (next_step.step_type === 'condition' && conditionDepth-- > 0) {
1677
+ console.log(`[workflow/engine] [handleSkipProcessed] Evaluating condition node "${next_step.name}"`);
1678
+ const resolvedStepIds = await UUFlowManager.getNextSteps(instance, flow, next_step, '');
1679
+ if (!resolvedStepIds || resolvedStepIds.length === 0) {
1680
+ console.warn('[workflow/engine] [handleSkipProcessed] Condition node evaluated to no next steps');
1681
+ return;
1682
+ }
1683
+ next_step_id = resolvedStepIds[0];
1684
+ try {
1685
+ next_step = UUFlowManager.getStep(instance, flow, next_step_id);
1686
+ } catch (e) {
1687
+ return;
1688
+ }
1689
+ }
1690
+
1673
1691
  // Mark current trace as finished with all approves as skipped
1674
1692
  const setObj = {};
1675
1693
  setObj[`traces.${traceIdx}.is_finished`] = true;
@@ -19,7 +19,7 @@ mobile_columns:
19
19
  filter_scope: space
20
20
  filters: !!js/function |
21
21
  function(filters, data){
22
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
22
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
23
23
  type: 'get', async: false
24
24
  });
25
25
  return result.filter;
@@ -18,7 +18,7 @@ mobile_columns:
18
18
  filter_scope: space
19
19
  filters: !!js/function |
20
20
  function(filters, data){
21
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
21
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
22
22
  type: 'get', async: false
23
23
  });
24
24
  return result.filter;
@@ -18,7 +18,7 @@ mobile_columns:
18
18
  filter_scope: space
19
19
  filters: !!js/function |
20
20
  function(filters, data){
21
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
21
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
22
22
  type: 'get', async: false
23
23
  });
24
24
  return result.filter;
@@ -9,7 +9,7 @@ columns:
9
9
  filter_scope: space
10
10
  filters: !!js/function |
11
11
  function(filters, data){
12
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
12
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
13
13
  type: 'get', async: false
14
14
  });
15
15
  return result.filter;
@@ -18,7 +18,7 @@ mobile_columns:
18
18
  filter_scope: space
19
19
  filters: !!js/function |
20
20
  function(filters, data){
21
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
21
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
22
22
  type: 'get', async: false
23
23
  });
24
24
  return result.filter;
@@ -18,7 +18,7 @@ mobile_columns:
18
18
  filter_scope: space
19
19
  filters: !!js/function |
20
20
  function(filters, data){
21
- var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}`, {
21
+ var result = Steedos.authRequest(`/api/workflow/v2/\${data.listName}/filter?app=\${data.appId}&additionalFilters=\${data.additionalFilters || ""}`, {
22
22
  type: 'get', async: false
23
23
  });
24
24
  return result.filter;
@@ -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=\"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",
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\n function escapeHtml(str) {\n return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n const sidebarEl = document.getElementById('steedosFlowSelectorSidebarList');\n const contentEl = document.getElementById('contentContainer');\n const searchInput = document.getElementById('searchInput');\n\n // Optimization 2: lazy rendering state\n let _observer = null;\n let _currentGroups = [];\n\n // Render cards into a placeholder element for a single group (lazy)\n var ESTIMATED_CARD_HEIGHT_PX = 88;\n var LAZY_LOAD_MARGIN = '300px 0px';\n\n function fillCards(group, placeholder) {\n if (placeholder.dataset.rendered) return;\n placeholder.dataset.rendered = 'true';\n placeholder.style.minHeight = '';\n const gridFragment = document.createDocumentFragment();\n group.items.forEach(function(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 // Optimization 3: data-flow-id for event delegation\n card.dataset.flowId = flow.id;\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 card.innerHTML = '<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 + (isFav ? ' active-fav' : '') + '\" title=\"' + (isFav ? '取消收藏' : '加入收藏') + '\" data-flow-id=\"' + flow.id + '\"><svg class=\"h-5 w-5 transition-colors duration-300 ' + iconClass + '\" viewBox=\"0 0 24 24\"><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\" /></svg></div><div class=\"mr-4 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[16px] font-bold ' + colorClass + '\">' + escapeHtml(firstChar) + '</div><div class=\"flex-1 pr-8 text-[15px] font-medium text-gray-900 line-clamp-3 leading-relaxed tracking-tight\" title=\"' + escapeHtml(flow.name) + '\">' + escapeHtml(flow.name) + '</div>';\n gridFragment.appendChild(card);\n });\n placeholder.appendChild(gridFragment);\n }\n\n // Optimization 3: single delegated click listener — handles both star-btn and card clicks\n contentEl.addEventListener('click', function(e) {\n const starBtn = e.target.closest('.star-btn');\n if (starBtn) {\n const flowId = starBtn.dataset.flowId;\n const isFav = AppState.favorites.includes(flowId);\n const newFavState = !isFav;\n if (newFavState) {\n starBtn.classList.add('active-fav', 'opacity-100');\n starBtn.querySelector('svg').setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-yellow-400 fill-current');\n starBtn.setAttribute('title', '取消收藏');\n } else {\n starBtn.classList.remove('active-fav', 'opacity-100');\n starBtn.querySelector('svg').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 starBtn.setAttribute('title', '加入收藏');\n }\n AppState.favorites = WorkflowService.toggleFavorite(flowId, newFavState);\n setTimeout(function() { renderUI(searchInput.value); }, 300);\n return;\n }\n const card = e.target.closest('[data-flow-id]');\n if (card && !card.classList.contains('star-btn')) {\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(function() {\n data._scoped.doAction([\n { \"actionType\": \"broadcast\", \"args\": { \"eventName\": \"flows.selected\" }, \"data\": { \"value\": card.dataset.flowId } }\n ]);\n }, 50);\n }\n });\n\n async function init() {\n try {\n const result = await WorkflowService.getData();\n AppState.allFlows = result.flows;\n AppState.categories = result.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 filterText = filterText || \"\";\n // Disconnect previous IntersectionObserver before rebuilding DOM\n if (_observer) { _observer.disconnect(); _observer = null; }\n sidebarEl.innerHTML = \"\";\n contentEl.innerHTML = \"\";\n\n const isSearching = filterText.length > 0;\n const groups = [];\n\n const favFlows = AppState.allFlows.filter(function(f) {\n return AppState.favorites.includes(f.id) && (isSearching ? f.name.toLowerCase().includes(filterText.toLowerCase()) : true);\n });\n if (favFlows.length > 0) {\n groups.push({ id: 'fav', name: \"我的收藏\", items: favFlows, isFav: true });\n }\n\n AppState.categories.forEach(function(cat) {\n const items = AppState.allFlows.filter(function(f) {\n return f.categoryId === cat._id && (isSearching ? f.name.toLowerCase().includes(filterText.toLowerCase()) : 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(function(f) {\n return !AppState.categories.find(function(c) { return c._id === f.categoryId; }) &&\n (isSearching ? f.name.toLowerCase().includes(filterText.toLowerCase()) : 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 _currentGroups = groups;\n const contentFragment = document.createDocumentFragment();\n const 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 const activeClass = \"bg-[#007AFF] text-white shadow-sm font-medium\";\n const inactiveClass = \"text-gray-700 hover:bg-black/5 active:bg-black/10\";\n\n groups.forEach(function(group, index) {\n const groupId = 'group-' + group.id;\n const navItem = document.createElement('div');\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 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 = (function(grp, gId) {\n return function() {\n Array.from(sidebarEl.children).forEach(function(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 const section = document.getElementById(gId);\n if (section) {\n // Optimization 2: force-render target section before scrolling to avoid blank placeholder\n const ph = section.querySelector('.card-placeholder');\n if (ph && !ph.dataset.rendered) {\n fillCards(grp, ph);\n if (_observer) _observer.unobserve(ph);\n }\n const container = document.getElementById('mainContentScroll');\n if (container) {\n const targetTop = section.getBoundingClientRect().top;\n const containerTop = container.getBoundingClientRect().top;\n container.scrollTo({ top: container.scrollTop + targetTop - containerTop - 16, behavior: 'smooth' });\n }\n }\n };\n })(group, groupId);\n sidebarEl.appendChild(navItem);\n\n // Optimization 2: render section header + placeholder (cards filled lazily)\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 const placeholder = document.createElement('div');\n placeholder.className = 'card-placeholder grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4';\n // Pre-size the placeholder to avoid layout shift while cards aren't rendered yet\n placeholder.style.minHeight = (Math.ceil(group.items.length / 4) * ESTIMATED_CARD_HEIGHT_PX) + 'px';\n section.appendChild(placeholder);\n contentFragment.appendChild(section);\n });\n\n contentEl.appendChild(contentFragment);\n\n // Optimization 2: set up IntersectionObserver to fill cards as groups scroll into view\n const scrollContainer = document.getElementById('mainContentScroll');\n _observer = new IntersectionObserver(function(entries) {\n entries.forEach(function(entry) {\n if (entry.isIntersecting && entry.target.isConnected && !entry.target.dataset.rendered) {\n const ph = entry.target;\n const section = ph.parentElement;\n if (section && section.id) {\n const gId = section.id.replace('group-', '');\n const grp = _currentGroups.find(function(g) { return String(g.id) === gId; });\n if (grp) {\n fillCards(grp, ph);\n if (_observer) _observer.unobserve(ph);\n }\n }\n }\n });\n }, { root: scrollContainer, rootMargin: LAZY_LOAD_MARGIN });\n\n contentEl.querySelectorAll('.card-placeholder').forEach(function(p) {\n _observer.observe(p);\n });\n }\n\n // Optimization 4: Chinese IME compositionstart/compositionend prevents stutter during composition\n let _isComposing = false;\n let _searchTimer = null;\n searchInput.addEventListener('compositionstart', function() { _isComposing = true; });\n searchInput.addEventListener('compositionend', function(e) {\n _isComposing = false;\n clearTimeout(_searchTimer);\n renderUI(e.target.value.trim());\n });\n searchInput.addEventListener('input', function(e) {\n if (_isComposing) return;\n clearTimeout(_searchTimer);\n _searchTimer = setTimeout(function() { renderUI(e.target.value.trim()); }, 300);\n });\n\n init();\n</script>\n\n",
4
4
  "className": "h-full"
5
- }
5
+ }
@@ -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 /* Mobile-optimized scrollbars */\n ::-webkit-scrollbar {\n width: 4px;\n height: 4px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.2);\n border-radius: 4px;\n }\n\n /* Force full screen modal for mobile */\n .antd-Modal:has(#steedosFlowSelectorMobile) {\n top: 0 !important;\n margin: 0 !important;\n padding: 0 !important;\n max-width: 100vw !important;\n width: 100vw !important;\n height: 100vh !important;\n height: 100dvh !important;\n }\n\n .antd-Modal-content:has(#steedosFlowSelectorMobile) {\n height: 100vh !important;\n height: 100dvh !important;\n padding-bottom: 0 !important;\n display: flex !important;\n flex-direction: column !important;\n border-radius: 0 !important;\n overflow: hidden !important;\n }\n\n .antd-Modal-body:has(#steedosFlowSelectorMobile) {\n margin: 0 !important;\n flex: 1 !important;\n height: auto !important;\n overflow: hidden !important;\n padding: 0 !important;\n display: flex !important;\n flex-direction: column !important;\n }\n \n .antd-Service, .liquid-amis-container {\n display: flex; /* Ensure flex propagation */\n flex-direction: column;\n height: 100%;\n overflow: hidden; /* Prevent double scrollbars */\n }\n\n /* Mobile category selector styles */\n .mobile-category-pill {\n -webkit-tap-highlight-color: transparent;\n touch-action: manipulation;\n }\n\n /* Flow card animations */\n @keyframes flowCardSlideIn {\n 0% {\n opacity: 0;\n transform: translateY(20px);\n }\n 100% {\n opacity: 1;\n transform: translateY(0);\n }\n }\n</style>\n\n<!-- Main Mobile Container -->\n<div id=\"steedosFlowSelectorMobile\" class=\"flex flex-col h-full w-full bg-[#F5F5F7] font-sans text-gray-900 overflow-hidden\">\n \n <!-- Top Header Section -->\n <div class=\"shrink-0 bg-white border-b border-gray-200 shadow-sm pt-4\">\n \n <!-- Search Bar -->\n <div class=\"px-4 pb-3\">\n <div class=\"relative\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400\">\n <svg class=\"h-5 w-5\" 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 \n type=\"text\" \n id=\"mobileSearchInput\" \n placeholder=\"搜索流程...\" \n class=\"w-full rounded-xl border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-3 text-base text-gray-900 placeholder:text-gray-400 outline-none transition-all duration-200 focus:bg-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20\"\n >\n </div>\n </div>\n \n <!-- Category Tabs - Horizontal Scroll -->\n <div class=\"overflow-x-auto scrollbar-hide pb-2\">\n <div id=\"mobileCategoryTabs\" class=\"flex gap-2 px-4 min-w-full\">\n <!-- Categories will be inserted here -->\n </div>\n </div>\n </div>\n \n <!-- Main Content Area -->\n <!-- Added min-h-0 and webkit-overflow-scrolling for better mobile scrolling -->\n <div class=\"flex-1 overflow-y-auto scroll-smooth px-4 py-4 min-h-0\" style=\"-webkit-overflow-scrolling: touch;\">\n <div id=\"mobileContentContainer\" class=\"space-y-3 pb-safe\">\n <div class=\"flex flex-col items-center justify-center pt-20 text-center\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-5 w-5\" 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\n<script>\n const MobileWorkflowService = {\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 // Use relative path directly to avoid cross-origin issues if apiBase is mixed up\n const url = \"/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, \n name: f.name, \n categoryId: cat._id, \n categoryName: cat.name || \"其他流程\" \n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) { \n console.error(\"MobileWorkflowService 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) { \n if (!favs.includes(flowId)) favs.push(flowId); \n } else { \n favs = favs.filter(id => id !== flowId); \n }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const MobileAppState = { \n allFlows: [], \n categories: [], \n favorites: [],\n currentCategory: 'all',\n /* Virtualization State */\n filteredFlows: [],\n renderedCount: 0,\n batchSize: 50\n };\n \n const tabsEl = document.getElementById('mobileCategoryTabs');\n // Important: ContentEl is the scroll host\n // listEl is the container for items\n let contentEl, listEl, searchInput;\n\n function initElements() {\n // Find elements scoped to our container to avoid collisions if multiple instances\n const wrapper = document.getElementById('steedosFlowSelectorMobile');\n if (!wrapper) return false;\n \n searchInput = document.getElementById('mobileSearchInput');\n // The scroll container is the div with class 'overflow-y-auto'\n contentEl = wrapper.querySelector('.overflow-y-auto');\n listEl = document.getElementById('mobileContentContainer');\n return true;\n }\n\n async function initMobile() {\n // Wait for DOM to be ready\n if (!initElements()) {\n setTimeout(initMobile, 100);\n return;\n }\n\n try {\n const data = await MobileWorkflowService.getData();\n MobileAppState.allFlows = data.flows;\n MobileAppState.categories = data.categories;\n MobileAppState.favorites = MobileWorkflowService.getFavorites();\n \n initObserver();\n renderMobileTabs();\n renderMobileContent();\n \n // Search init\n searchInput.addEventListener('input', (e) => {\n renderMobileContent(e.target.value.trim());\n });\n\n } catch (e) {\n console.error('Init error:', e);\n if (listEl) listEl.innerHTML = `<div class=\"text-center pt-20 text-gray-400 text-sm\">加载失败,请检查网络连接</div>`;\n }\n }\n\n let observer;\n function initObserver() {\n if (!contentEl) return;\n \n const options = {\n root: contentEl, // Observe intersection with scroll container\n rootMargin: '200px',\n threshold: 0.1\n };\n \n observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n renderNextBatch();\n }\n });\n }, options);\n }\n\n function renderMobileTabs(filterText = \"\") {\n if (!tabsEl) return;\n \n // Save scroll position\n const currentScroll = tabsEl.scrollLeft;\n tabsEl.innerHTML = \"\";\n \n // Filter flows by search text to determine counts\n let searchedFlows = MobileAppState.allFlows;\n if (filterText && filterText.length > 0) {\n searchedFlows = searchedFlows.filter(f => f.name.includes(filterText));\n }\n \n // All category\n const allCount = searchedFlows.length;\n const isAllActive = MobileAppState.currentCategory === 'all';\n // Only show count on 'all' if we are filtering, same as PC? PC usually shows (Total). Let's show (count).\n const allTab = createTab('all', `全部 (${allCount})`, isAllActive);\n tabsEl.appendChild(allTab);\n \n // Favorites\n // For favorites, we filter the ALREADY SEARCHED flows to see if they are in favorites\n const favCount = searchedFlows.filter(f => MobileAppState.favorites.includes(f.id)).length;\n if (favCount > 0) {\n const isFavActive = MobileAppState.currentCategory === 'fav';\n const favTab = createTab('fav', `★ 收藏 (${favCount})`, isFavActive);\n tabsEl.appendChild(favTab);\n }\n \n // Category tabs\n MobileAppState.categories.forEach(cat => {\n const count = searchedFlows.filter(f => f.categoryId === cat._id).length;\n if (count > 0) {\n const isActive = MobileAppState.currentCategory === cat._id;\n const tab = createTab(cat._id, `${cat.name} (${count})`, isActive);\n tabsEl.appendChild(tab);\n }\n });\n \n // Restore scroll position\n tabsEl.scrollLeft = currentScroll;\n }\n\n function createTab(catId, label, isActive) {\n const tab = document.createElement('button');\n const baseClass = \"mobile-category-pill shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all duration-200\";\n const activeClass = \"bg-blue-500 text-white shadow-md\";\n const inactiveClass = \"bg-white text-gray-700 border border-gray-200 active:bg-gray-50\";\n \n tab.className = `${baseClass} ${isActive ? activeClass : inactiveClass}`;\n tab.textContent = label;\n tab.dataset.categoryId = catId;\n \n tab.onclick = () => {\n MobileAppState.currentCategory = catId;\n // Pass current search text to preserve filter\n renderMobileContent(document.getElementById('mobileSearchInput').value.trim());\n };\n \n return tab;\n }\n\n function renderMobileContent(filterText = \"\") {\n if (!contentEl || !listEl) return;\n \n const isSearching = filterText.length > 0;\n let flows = MobileAppState.allFlows;\n \n // 1. First filter by search text\n if (isSearching) {\n flows = flows.filter(f => f.name.includes(filterText));\n }\n\n // 2. Then filter by category\n if (MobileAppState.currentCategory === 'fav') {\n flows = flows.filter(f => MobileAppState.favorites.includes(f.id));\n } else if (MobileAppState.currentCategory !== 'all') {\n flows = flows.filter(f => f.categoryId === MobileAppState.currentCategory);\n }\n \n const flowsToShow = flows;\n \n // Update tabs to reflect these search results\n renderMobileTabs(filterText);\n \n listEl.innerHTML = \"\";\n contentEl.scrollTop = 0;\n \n if (flowsToShow.length === 0) {\n listEl.innerHTML = `\n <div class=\"flex flex-col items-center justify-center pt-20 text-center\">\n <div class=\"text-gray-300 text-7xl mb-4\">∅</div>\n <div class=\"text-gray-400 text-sm\">${isSearching ? '未找到匹配的流程' : '此分类暂无流程'}</div>\\n </div>\n `;\n return;\n }\n \n MobileAppState.filteredFlows = flowsToShow;\n MobileAppState.renderedCount = 0;\n \n renderNextBatch();\n updateSentinel();\n }\n\n function renderNextBatch() {\n const { filteredFlows, renderedCount, batchSize } = MobileAppState;\n const total = filteredFlows.length;\n if (renderedCount >= total) return;\n \n const nextBatch = filteredFlows.slice(renderedCount, renderedCount + batchSize);\n const fragment = document.createDocumentFragment();\n \n nextBatch.forEach((flow, i) => {\n const index = renderedCount + i;\n const card = createMobileFlowCard(flow, index);\n \n // Remove animation for later batches to avoid scroll lag appearance\n if (renderedCount > 0) {\n card.style.animationDelay = '0s';\n }\n \n fragment.appendChild(card);\n });\n \n // Remove sentinel if exists\n const sentinel = document.getElementById('scroll-sentinel');\n if (sentinel) sentinel.remove();\n \n listEl.appendChild(fragment);\n MobileAppState.renderedCount += nextBatch.length;\n \n updateSentinel();\n }\n\n function updateSentinel() {\n if (MobileAppState.renderedCount < MobileAppState.filteredFlows.length) {\n const sentinel = document.createElement('div');\n sentinel.id = 'scroll-sentinel';\n sentinel.className = 'flex justify-center p-4';\n sentinel.innerHTML = '<span class=\"text-gray-400 text-xs\">加载更多...</span>';\n listEl.appendChild(sentinel);\n if (observer) observer.observe(sentinel);\n }\n }\n\n function createMobileFlowCard(flow, index) {\n const isFav = MobileAppState.favorites.includes(flow.id);\n const colorMap = [\n 'bg-blue-50 text-blue-600', \n 'bg-orange-50 text-orange-600', \n 'bg-emerald-50 text-emerald-600', \n 'bg-indigo-50 text-indigo-600',\n 'bg-purple-50 text-purple-600',\n 'bg-pink-50 text-pink-600'\n ];\n const colorClass = colorMap[(flow.name.length + index) % colorMap.length];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n \n const card = document.createElement('div');\n card.className = 'relative flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm transition-all duration-200 active:scale-[0.97] active:bg-gray-50';\n card.style.animation = 'flowCardSlideIn 0.4s ease-out forwards';\n card.style.animationDelay = `${Math.min(index * 0.05, 0.5)}s`;\n card.style.opacity = '0';\n \n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 fill-none stroke-current stroke-[1.5]';\n \n card.innerHTML = `\n <div class=\"flex h-14 w-14 shrink-0 items-center justify-center rounded-xl text-lg font-bold ${colorClass}\">\n ${firstChar}\n </div>\n <div class=\"flex-1 min-w-0\">\n <div class=\"text-base font-medium text-gray-900 leading-snug line-clamp-3\" title=\"${flow.name}\">\n ${flow.name}\n </div>\n </div>\n <button class=\"star-btn shrink-0 flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 active:scale-90 ${isFav ? 'bg-yellow-50' : 'active:bg-gray-100'}\" title=\"${isFav ? '取消收藏' : '收藏'}\">\n <svg class=\"h-6 w-6 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 </button>\\n `;\n \n // Click card to select flow\n card.onclick = (e) => {\n if (!e.target.closest('.star-btn')) {\n setTimeout(() => {\n data._scoped.doAction([\n { \n \"actionType\": \"broadcast\", \n \"args\": { \"eventName\": \"flows.selected\" }, \n \"data\": { \"value\": flow.id } \n }\n ]);\n }, 50);\n }\n };\n \n // Star button to toggle favorite\n const starBtn = card.querySelector('.star-btn');\n const starIcon = starBtn.querySelector('svg');\n starBtn.onclick = (e) => {\n e.stopPropagation();\n const newFavState = !isFav;\n MobileAppState.favorites = MobileWorkflowService.toggleFavorite(flow.id, newFavState);\n \n // Update UI\n if (newFavState) {\n starBtn.classList.add('bg-yellow-50');\n starBtn.classList.remove('active:bg-gray-100');\n starIcon.setAttribute('class', 'h-6 w-6 transition-colors duration-300 text-yellow-400 fill-current');\n } else {\n starBtn.classList.remove('bg-yellow-50');\n starBtn.classList.add('active:bg-gray-100');\n starIcon.setAttribute('class', 'h-6 w-6 transition-colors duration-300 text-gray-300 fill-none stroke-current stroke-[1.5]');\n }\n \n // Refresh tabs and content after short delay\n setTimeout(() => {\n renderMobileTabs();\n renderMobileContent(searchInput.value.trim());\n }, 300);\n };\n \n return card;\n }\n\n // Initialize\n initMobile();\n</script>",
3
+ "template": "<style>\n @keyframes fadeUpSpring {\n 0% { opacity: 0; transform: translateY(10px); }\n 100% { opacity: 1; transform: translateY(0); }\n }\n \n /* Mobile-optimized scrollbars */\n ::-webkit-scrollbar {\n width: 4px;\n height: 4px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.2);\n border-radius: 4px;\n }\n\n /* Force full screen modal for mobile */\n .antd-Modal:has(#steedosFlowSelectorMobile) {\n top: 0 !important;\n margin: 0 !important;\n padding: 0 !important;\n max-width: 100vw !important;\n width: 100vw !important;\n height: 100vh !important;\n height: 100dvh !important;\n }\n\n .antd-Modal-content:has(#steedosFlowSelectorMobile) {\n height: 100vh !important;\n height: 100dvh !important;\n padding-bottom: 0 !important;\n display: flex !important;\n flex-direction: column !important;\n border-radius: 0 !important;\n overflow: hidden !important;\n }\n\n .antd-Modal-body:has(#steedosFlowSelectorMobile) {\n margin: 0 !important;\n flex: 1 !important;\n height: auto !important;\n overflow: hidden !important;\n padding: 0 !important;\n display: flex !important;\n flex-direction: column !important;\n }\n \n .antd-Service, .liquid-amis-container {\n display: flex; /* Ensure flex propagation */\n flex-direction: column;\n height: 100%;\n overflow: hidden; /* Prevent double scrollbars */\n }\n\n /* Mobile category selector styles */\n .mobile-category-pill {\n -webkit-tap-highlight-color: transparent;\n touch-action: manipulation;\n }\n\n /* Flow card animations */\n @keyframes flowCardSlideIn {\n 0% {\n opacity: 0;\n transform: translateY(20px);\n }\n 100% {\n opacity: 1;\n transform: translateY(0);\n }\n }\n</style>\n\n<!-- Main Mobile Container -->\n<div id=\"steedosFlowSelectorMobile\" class=\"flex flex-col h-full w-full bg-[#F5F5F7] font-sans text-gray-900 overflow-hidden\">\n \n <!-- Top Header Section -->\n <div class=\"shrink-0 bg-white border-b border-gray-200 shadow-sm pt-4\">\n \n <!-- Search Bar -->\n <div class=\"px-4 pb-3\">\n <div class=\"relative\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400\">\n <svg class=\"h-5 w-5\" 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 \n type=\"text\" \n id=\"mobileSearchInput\" \n placeholder=\"搜索流程...\" \n class=\"w-full rounded-xl border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-3 text-base text-gray-900 placeholder:text-gray-400 outline-none transition-all duration-200 focus:bg-white focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20\"\n >\n </div>\n </div>\n \n <!-- Category Tabs - Horizontal Scroll -->\n <div class=\"overflow-x-auto scrollbar-hide pb-2\">\n <div id=\"mobileCategoryTabs\" class=\"flex gap-2 px-4 min-w-full\">\n <!-- Categories will be inserted here -->\n </div>\n </div>\n </div>\n \n <!-- Main Content Area -->\n <!-- Added min-h-0 and webkit-overflow-scrolling for better mobile scrolling -->\n <div class=\"flex-1 overflow-y-auto scroll-smooth px-4 py-4 min-h-0\" style=\"-webkit-overflow-scrolling: touch;\">\n <div id=\"mobileContentContainer\" class=\"space-y-3 pb-safe\">\n <div class=\"flex flex-col items-center justify-center pt-20 text-center\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-5 w-5\" 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\n<script>\n const MobileWorkflowService = {\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 // Use relative path directly to avoid cross-origin issues if apiBase is mixed up\n const url = \"/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, \n name: f.name, \n categoryId: cat._id, \n categoryName: cat.name || \"其他流程\" \n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) { \n console.error(\"MobileWorkflowService 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) { \n if (!favs.includes(flowId)) favs.push(flowId); \n } else { \n favs = favs.filter(id => id !== flowId); \n }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const MobileAppState = { \n allFlows: [], \n categories: [], \n favorites: [],\n currentCategory: 'all',\n /* Virtualization State */\n filteredFlows: [],\n renderedCount: 0,\n batchSize: 50\n };\n \n function escapeHtml(str) {\n return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n\n const tabsEl = document.getElementById('mobileCategoryTabs');\n // Important: ContentEl is the scroll host\n // listEl is the container for items\n let contentEl, listEl, searchInput;\n\n function initElements() {\n // Find elements scoped to our container to avoid collisions if multiple instances\n const wrapper = document.getElementById('steedosFlowSelectorMobile');\n if (!wrapper) return false;\n \n searchInput = document.getElementById('mobileSearchInput');\n // The scroll container is the div with class 'overflow-y-auto'\n contentEl = wrapper.querySelector('.overflow-y-auto');\n listEl = document.getElementById('mobileContentContainer');\n return true;\n }\n\n async function initMobile() {\n // Wait for DOM to be ready\n if (!initElements()) {\n setTimeout(initMobile, 100);\n return;\n }\n\n try {\n const data = await MobileWorkflowService.getData();\n MobileAppState.allFlows = data.flows;\n MobileAppState.categories = data.categories;\n MobileAppState.favorites = MobileWorkflowService.getFavorites();\n \n initObserver();\n renderMobileTabs();\n renderMobileContent();\n \n // Search init\n let _isComposing = false;\n let _searchTimer = null;\n searchInput.addEventListener('compositionstart', function() { _isComposing = true; });\n searchInput.addEventListener('compositionend', function(e) {\n _isComposing = false;\n clearTimeout(_searchTimer);\n renderMobileContent(e.target.value.trim());\n });\n searchInput.addEventListener('input', function(e) {\n if (_isComposing) return;\n clearTimeout(_searchTimer);\n _searchTimer = setTimeout(function() { renderMobileContent(e.target.value.trim()); }, 300);\n });\n\n } catch (e) {\n console.error('Init error:', e);\n if (listEl) listEl.innerHTML = `<div class=\"text-center pt-20 text-gray-400 text-sm\">加载失败,请检查网络连接</div>`;\n }\n }\n\n let observer;\n function initObserver() {\n if (!contentEl) return;\n \n const options = {\n root: contentEl, // Observe intersection with scroll container\n rootMargin: '200px',\n threshold: 0.1\n };\n \n observer = new IntersectionObserver((entries) => {\n entries.forEach(entry => {\n if (entry.isIntersecting) {\n renderNextBatch();\n }\n });\n }, options);\n }\n\n function renderMobileTabs(filterText = \"\") {\n if (!tabsEl) return;\n \n // Save scroll position\n const currentScroll = tabsEl.scrollLeft;\n tabsEl.innerHTML = \"\";\n \n // Filter flows by search text to determine counts\n let searchedFlows = MobileAppState.allFlows;\n if (filterText && filterText.length > 0) {\n searchedFlows = searchedFlows.filter(f => f.name.toLowerCase().includes(filterText.toLowerCase()));\n }\n \n // All category\n const allCount = searchedFlows.length;\n const isAllActive = MobileAppState.currentCategory === 'all';\n // Only show count on 'all' if we are filtering, same as PC? PC usually shows (Total). Let's show (count).\n const allTab = createTab('all', `全部 (${allCount})`, isAllActive);\n tabsEl.appendChild(allTab);\n \n // Favorites\n // For favorites, we filter the ALREADY SEARCHED flows to see if they are in favorites\n const favCount = searchedFlows.filter(f => MobileAppState.favorites.includes(f.id)).length;\n if (favCount > 0) {\n const isFavActive = MobileAppState.currentCategory === 'fav';\n const favTab = createTab('fav', `★ 收藏 (${favCount})`, isFavActive);\n tabsEl.appendChild(favTab);\n }\n \n // Category tabs\n MobileAppState.categories.forEach(cat => {\n const count = searchedFlows.filter(f => f.categoryId === cat._id).length;\n if (count > 0) {\n const isActive = MobileAppState.currentCategory === cat._id;\n const tab = createTab(cat._id, `${cat.name} (${count})`, isActive);\n tabsEl.appendChild(tab);\n }\n });\n \n // Restore scroll position\n tabsEl.scrollLeft = currentScroll;\n }\n\n function createTab(catId, label, isActive) {\n const tab = document.createElement('button');\n const baseClass = \"mobile-category-pill shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all duration-200\";\n const activeClass = \"bg-blue-500 text-white shadow-md\";\n const inactiveClass = \"bg-white text-gray-700 border border-gray-200 active:bg-gray-50\";\n \n tab.className = `${baseClass} ${isActive ? activeClass : inactiveClass}`;\n tab.textContent = label;\n tab.dataset.categoryId = catId;\n \n tab.onclick = () => {\n MobileAppState.currentCategory = catId;\n // Pass current search text to preserve filter\n renderMobileContent(document.getElementById('mobileSearchInput').value.trim());\n };\n \n return tab;\n }\n\n function renderMobileContent(filterText = \"\") {\n if (!contentEl || !listEl) return;\n \n const isSearching = filterText.length > 0;\n let flows = MobileAppState.allFlows;\n \n // 1. First filter by search text\n if (isSearching) {\n flows = flows.filter(f => f.name.toLowerCase().includes(filterText.toLowerCase()));\n }\n\n // 2. Then filter by category\n if (MobileAppState.currentCategory === 'fav') {\n flows = flows.filter(f => MobileAppState.favorites.includes(f.id));\n } else if (MobileAppState.currentCategory !== 'all') {\n flows = flows.filter(f => f.categoryId === MobileAppState.currentCategory);\n }\n \n const flowsToShow = flows;\n \n // Update tabs to reflect these search results\n renderMobileTabs(filterText);\n \n listEl.innerHTML = \"\";\n contentEl.scrollTop = 0;\n \n if (flowsToShow.length === 0) {\n listEl.innerHTML = `\n <div class=\"flex flex-col items-center justify-center pt-20 text-center\">\n <div class=\"text-gray-300 text-7xl mb-4\">∅</div>\n <div class=\"text-gray-400 text-sm\">${isSearching ? '未找到匹配的流程' : '此分类暂无流程'}</div>\\n </div>\n `;\n return;\n }\n \n MobileAppState.filteredFlows = flowsToShow;\n MobileAppState.renderedCount = 0;\n \n renderNextBatch();\n updateSentinel();\n }\n\n function renderNextBatch() {\n const { filteredFlows, renderedCount, batchSize } = MobileAppState;\n const total = filteredFlows.length;\n if (renderedCount >= total) return;\n \n const nextBatch = filteredFlows.slice(renderedCount, renderedCount + batchSize);\n const fragment = document.createDocumentFragment();\n \n nextBatch.forEach((flow, i) => {\n const index = renderedCount + i;\n const card = createMobileFlowCard(flow, index);\n \n // Remove animation for later batches to avoid scroll lag appearance\n if (renderedCount > 0) {\n card.style.animationDelay = '0s';\n }\n \n fragment.appendChild(card);\n });\n \n // Remove sentinel if exists\n const sentinel = document.getElementById('scroll-sentinel');\n if (sentinel) sentinel.remove();\n \n listEl.appendChild(fragment);\n MobileAppState.renderedCount += nextBatch.length;\n \n updateSentinel();\n }\n\n function updateSentinel() {\n if (MobileAppState.renderedCount < MobileAppState.filteredFlows.length) {\n const sentinel = document.createElement('div');\n sentinel.id = 'scroll-sentinel';\n sentinel.className = 'flex justify-center p-4';\n sentinel.innerHTML = '<span class=\"text-gray-400 text-xs\">加载更多...</span>';\n listEl.appendChild(sentinel);\n if (observer) observer.observe(sentinel);\n }\n }\n\n function createMobileFlowCard(flow, index) {\n const isFav = MobileAppState.favorites.includes(flow.id);\n const colorMap = [\n 'bg-blue-50 text-blue-600', \n 'bg-orange-50 text-orange-600', \n 'bg-emerald-50 text-emerald-600', \n 'bg-indigo-50 text-indigo-600',\n 'bg-purple-50 text-purple-600',\n 'bg-pink-50 text-pink-600'\n ];\n const colorClass = colorMap[(flow.name.length + index) % colorMap.length];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n \n const card = document.createElement('div');\n card.className = 'relative flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm transition-all duration-200 active:scale-[0.97] active:bg-gray-50';\n card.style.animation = 'flowCardSlideIn 0.4s ease-out forwards';\n card.style.animationDelay = `${Math.min(index * 0.05, 0.5)}s`;\n card.style.opacity = '0';\n \n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 fill-none stroke-current stroke-[1.5]';\n \n card.innerHTML = `\n <div class=\"flex h-14 w-14 shrink-0 items-center justify-center rounded-xl text-lg font-bold ${colorClass}\">\n ${escapeHtml(firstChar)}\n </div>\n <div class=\"flex-1 min-w-0\">\n <div class=\"text-base font-medium text-gray-900 leading-snug line-clamp-3\" title=\"${escapeHtml(flow.name)}\">\n ${escapeHtml(flow.name)}\n </div>\n </div>\n <button class=\"star-btn shrink-0 flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 active:scale-90 ${isFav ? 'bg-yellow-50' : 'active:bg-gray-100'}\" title=\"${isFav ? '取消收藏' : '收藏'}\">\n <svg class=\"h-6 w-6 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 </button>\\n `;\n \n // Click card to select flow\n card.onclick = (e) => {\n if (!e.target.closest('.star-btn')) {\n setTimeout(() => {\n data._scoped.doAction([\n { \n \"actionType\": \"broadcast\", \n \"args\": { \"eventName\": \"flows.selected\" }, \n \"data\": { \"value\": flow.id } \n }\n ]);\n }, 50);\n }\n };\n \n // Star button to toggle favorite\n const starBtn = card.querySelector('.star-btn');\n const starIcon = starBtn.querySelector('svg');\n starBtn.onclick = (e) => {\n e.stopPropagation();\n const newFavState = !isFav;\n MobileAppState.favorites = MobileWorkflowService.toggleFavorite(flow.id, newFavState);\n \n // Update UI\n if (newFavState) {\n starBtn.classList.add('bg-yellow-50');\n starBtn.classList.remove('active:bg-gray-100');\n starIcon.setAttribute('class', 'h-6 w-6 transition-colors duration-300 text-yellow-400 fill-current');\n } else {\n starBtn.classList.remove('bg-yellow-50');\n starBtn.classList.add('active:bg-gray-100');\n starIcon.setAttribute('class', 'h-6 w-6 transition-colors duration-300 text-gray-300 fill-none stroke-current stroke-[1.5]');\n }\n \n // Refresh tabs and content after short delay\n setTimeout(() => {\n renderMobileTabs();\n renderMobileContent(searchInput.value.trim());\n }, 300);\n };\n \n return card;\n }\n\n // Initialize\n initMobile();\n</script>",
4
4
  "className": "h-full"
5
- }
5
+ }
@@ -419,5 +419,15 @@
419
419
  }
420
420
  }
421
421
  },
422
- "wrapperCustomStyle": {}
422
+ "wrapperCustomStyle": {},
423
+ "onEvent": {
424
+ "init": {
425
+ "actions": [
426
+ {
427
+ "actionType": "custom",
428
+ "script": "document.querySelectorAll('link[rel=\"stylesheet\"]').forEach(link => { if (link.href.includes('salesforce-lightning-design-system.min.css')) { link.remove(); } });"
429
+ }
430
+ ]
431
+ }
432
+ }
423
433
  }
@@ -540,6 +540,7 @@ router.put('/am/flows', async function (req, res) {
540
540
  'created': now,
541
541
  'created_by': userId,
542
542
  'steps': flowCome['current']['steps'],
543
+ 'nextEvents': flowCome['current']['nextEvents'] || {},
543
544
  'form_version': flow.current.form_version,
544
545
  '_rev': flow.current._rev,
545
546
  'flow': flowId,
@@ -554,7 +555,8 @@ router.put('/am/flows', async function (req, res) {
554
555
  updateObj.$set = {
555
556
  'current.modified': now,
556
557
  'current.modified_by': userId,
557
- 'current.steps': flowCome["current"]["steps"]
558
+ 'current.steps': flowCome["current"]["steps"],
559
+ 'current.nextEvents': flowCome["current"]["nextEvents"] || {},
558
560
  }
559
561
  }
560
562
 
@@ -5,6 +5,7 @@ const objectql = require('@steedos/objectql');
5
5
  const _ = require('lodash');
6
6
  const UUFlowManager = require('../manager/uuflow_manager');
7
7
  const { requireAuthentication } = require("@steedos/auth");
8
+ const { getCollection } = require('../utils/collection');
8
9
 
9
10
  // 获取用户当前的 approve
10
11
  const getUserApprove = ({ instance, userId }) => {
@@ -38,6 +39,91 @@ const getUserApprove = ({ instance, userId }) => {
38
39
  return currentApprove;
39
40
  };
40
41
 
42
+ // ===== 新设计器 autoNumber 字段:基于字段配置直接生成编号 =====
43
+
44
+ // 序号补零
45
+ const padNumber = (num, length) => {
46
+ if (length <= 0) return String(num);
47
+ const str = String(num);
48
+ return str.length >= length ? str : '0'.repeat(length - str.length) + str;
49
+ };
50
+
51
+ // 根据日期格式生成日期片段
52
+ const formatDatePart = (dateFormat, yyyy, mm, dd) => {
53
+ const mmStr = mm < 10 ? '0' + mm : String(mm);
54
+ const ddStr = dd < 10 ? '0' + dd : String(dd);
55
+ switch (dateFormat) {
56
+ case 'YYYY': return String(yyyy);
57
+ case 'YYYYMM': return String(yyyy) + mmStr;
58
+ case 'YYYYMMDD': return String(yyyy) + mmStr + ddStr;
59
+ default: return ''; // 'none' 或 undefined
60
+ }
61
+ };
62
+
63
+ // 根据重置周期判断是否需要重置序号
64
+ const shouldResetNumber = (resetCycle, storedRule, yyyy, mm, dd) => {
65
+ switch (resetCycle) {
66
+ case 'yearly': return yyyy !== storedRule.year;
67
+ case 'monthly': return yyyy !== storedRule.year || mm !== storedRule.month;
68
+ case 'daily': return yyyy !== storedRule.year || mm !== storedRule.month || dd !== storedRule.day;
69
+ default: return false; // 'none' - 永不重置
70
+ }
71
+ };
72
+
73
+ /**
74
+ * 基于字段配置生成自动编号(新设计器 autoNumber 类型字段)
75
+ * 同样使用 instance_number_rules 集合做序号持久化,如果尚未创建规则记录则自动创建
76
+ */
77
+ const generateAutoNumberFromFieldConfig = async (spaceId, fieldConfig) => {
78
+ const db = await getCollection("instance_number_rules");
79
+
80
+ const ruleName = fieldConfig.auto_number_name;
81
+ const prefix = fieldConfig.autoNumberPrefix || '';
82
+ const suffix = fieldConfig.autoNumberSuffix || '';
83
+ const paddingLen = fieldConfig.autoNumberPadding != null ? fieldConfig.autoNumberPadding : 4;
84
+ const dateFormat = fieldConfig.autoNumberDateFormat || 'YYYYMM';
85
+ const resetCycle = fieldConfig.autoNumberResetCycle || 'monthly';
86
+
87
+ const now = new Date();
88
+ const yyyy = now.getFullYear();
89
+ const mm = now.getMonth() + 1;
90
+ const dd = now.getDate();
91
+
92
+ let numberRules = await db.findOne({ space: spaceId, name: ruleName });
93
+
94
+ if (!numberRules) {
95
+ // 首次使用该字段,自动创建规则记录
96
+ await db.insertOne({
97
+ space: spaceId,
98
+ name: ruleName,
99
+ year: yyyy,
100
+ month: mm,
101
+ day: dd,
102
+ first_number: 1,
103
+ number: 0,
104
+ rules: '' // 新类型字段不使用 rules 模板
105
+ });
106
+ numberRules = await db.findOne({ space: spaceId, name: ruleName });
107
+ }
108
+
109
+ let nextNumber = (numberRules.number || 0) + 1;
110
+
111
+ if (shouldResetNumber(resetCycle, numberRules, yyyy, mm, dd)) {
112
+ nextNumber = numberRules.first_number || 1;
113
+ }
114
+
115
+ const paddedNumber = padNumber(nextNumber, paddingLen);
116
+ const datePart = formatDatePart(dateFormat, yyyy, mm, dd);
117
+ const autoNumber = prefix + datePart + paddedNumber + suffix;
118
+
119
+ await db.updateOne(
120
+ { _id: numberRules._id },
121
+ { $set: { year: yyyy, month: mm, day: dd, number: nextNumber } }
122
+ );
123
+
124
+ return autoNumber;
125
+ };
126
+
41
127
  // 获取可编辑的自动编号字段
42
128
  const getEditableAutoNumberFields = async (step, formId, formVersion) => {
43
129
  const permissions = step.permissions || {};
@@ -45,10 +131,26 @@ const getEditableAutoNumberFields = async (step, formId, formVersion) => {
45
131
  const formV = await UUFlowManager.getFormVersion(form, formVersion);
46
132
 
47
133
  const autoNumberFields = [];
134
+ const pushedFieldKeys = new Set();
135
+
136
+ const getFieldKey = (field) => field?.code || field?.name;
137
+ const isAutoNumberTypeField = (field) => {
138
+ const fieldType = (field?.type || '').toString().toLowerCase();
139
+ return fieldType === 'autonumber' || field?.type === 'autoNumber';
140
+ };
141
+ const canGenerateAutoNumber = (field) => {
142
+ const fieldKey = getFieldKey(field);
143
+ if (!fieldKey) return false;
144
+ const permission = permissions[fieldKey];
145
+ if (permission === 'editable') return true;
146
+ return false;
147
+ };
48
148
 
49
149
  // 检查字段是否是自动编号字段(通过 formula 判断)
50
150
  const isAutoNumberField = (field) => {
51
- return field.default_value && typeof field.default_value === 'string' && field.default_value.trim().startsWith('auto_number(');
151
+ if (!field) return false;
152
+ const hasLegacyAutoNumberFormula = field.default_value && typeof field.default_value === 'string' && field.default_value.trim().startsWith('auto_number(');
153
+ return hasLegacyAutoNumberFormula || isAutoNumberTypeField(field);
52
154
  };
53
155
 
54
156
  // 从 formula 中提取自动编号规则名称
@@ -57,32 +159,68 @@ const getEditableAutoNumberFields = async (step, formId, formVersion) => {
57
159
  const match = formula.match(/auto_number\s*\(\s*['"]?([^'"()]+?)['"]?\s*\)/);
58
160
  return match ? match[1].trim() : null;
59
161
  };
162
+
163
+ const getAutoNumberRuleName = (field) => {
164
+ // 1) 兼容旧字段公式:default_value = auto_number(...)
165
+ if (field?.default_value && typeof field.default_value === 'string') {
166
+ const byFormula = extractAutoNumberName(field.default_value);
167
+ if (byFormula) return byFormula;
168
+ }
169
+
170
+ // 2) 兼容常见字段属性命名
171
+ const fromProps =
172
+ field?.auto_number_name ||
173
+ field?.autoNumberName ||
174
+ field?.auto_number_rule ||
175
+ field?.autoNumberRule ||
176
+ field?.auto_number_rule_name ||
177
+ field?.autoNumberRuleName;
178
+ if (fromProps && typeof fromProps === 'string') {
179
+ return fromProps.trim();
180
+ }
181
+
182
+ // 3) 对于新设计器 autoNumber 字段,默认回退到字段标识(code/name)
183
+ return getFieldKey(field);
184
+ };
185
+
186
+ const tryPushAutoNumberField = (field) => {
187
+ if (!isAutoNumberField(field) || !canGenerateAutoNumber(field)) {
188
+ return;
189
+ }
190
+ const fieldKey = getFieldKey(field);
191
+ const autoNumberName = getAutoNumberRuleName(field);
192
+ if (!fieldKey || !autoNumberName || pushedFieldKeys.has(fieldKey)) {
193
+ return;
194
+ }
195
+ pushedFieldKeys.add(fieldKey);
196
+
197
+ const fieldInfo = {
198
+ code: fieldKey,
199
+ auto_number_name: autoNumberName,
200
+ };
201
+
202
+ // 新设计器 autoNumber 字段,携带配置属性用于直接生成编号
203
+ if (isAutoNumberTypeField(field)) {
204
+ fieldInfo.isNewType = true;
205
+ fieldInfo.autoNumberPrefix = field.autoNumberPrefix;
206
+ fieldInfo.autoNumberSuffix = field.autoNumberSuffix;
207
+ fieldInfo.autoNumberPadding = field.autoNumberPadding;
208
+ fieldInfo.autoNumberDateFormat = field.autoNumberDateFormat;
209
+ fieldInfo.autoNumberResetCycle = field.autoNumberResetCycle;
210
+ }
211
+
212
+ autoNumberFields.push(fieldInfo);
213
+ };
214
+
60
215
  for (const field of formV.fields || []) {
61
-
62
216
  if (field.type === "section") {
63
217
  // 处理 section 中的字段
64
218
  for (const sectionField of field.fields || []) {
65
- if (isAutoNumberField(sectionField) && permissions[sectionField.code] === "editable") {
66
- const autoNumberName = extractAutoNumberName(sectionField.default_value);
67
- if (autoNumberName) {
68
- autoNumberFields.push({
69
- code: sectionField.code,
70
- auto_number_name: autoNumberName
71
- });
72
- }
73
- }
219
+ tryPushAutoNumberField(sectionField);
74
220
  }
75
221
  } else {
76
222
  // 处理普通字段
77
- if (isAutoNumberField(field) && permissions[field.code] === "editable") {
78
- const autoNumberName = extractAutoNumberName(field.default_value);
79
- if (autoNumberName) {
80
- autoNumberFields.push({
81
- code: field.code,
82
- auto_number_name: autoNumberName
83
- });
84
- }
85
- }
223
+ tryPushAutoNumberField(field);
86
224
  }
87
225
  }
88
226
 
@@ -182,7 +320,6 @@ router.post('/api/workflow/v2/auto_number', requireAuthentication, async functio
182
320
 
183
321
  // 获取可编辑的自动编号字段
184
322
  const autoNumberFields = await getEditableAutoNumberFields(step, instance.form, instance.form_version);
185
-
186
323
  if (autoNumberFields.length === 0) {
187
324
  return res.status(200).send({
188
325
  success: true,
@@ -201,8 +338,14 @@ router.post('/api/workflow/v2/auto_number', requireAuthentication, async functio
201
338
  continue;
202
339
  }
203
340
 
204
- // 生成自动编号
205
- const autoNumber = await instanceNumberBuilder(instance.space, field.auto_number_name);
341
+ let autoNumber;
342
+ if (field.isNewType) {
343
+ // 新设计器 autoNumber 字段:基于字段配置直接生成
344
+ autoNumber = await generateAutoNumberFromFieldConfig(instance.space, field);
345
+ } else {
346
+ // 旧规则:通过预配置的 instance_number_rules 记录生成
347
+ autoNumber = await instanceNumberBuilder(instance.space, field.auto_number_name);
348
+ }
206
349
 
207
350
  if (autoNumber && autoNumber._error) {
208
351
  throw autoNumber._error;