@steedos-labs/plugin-workflow 3.0.39 → 3.0.41

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 (39) 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-7cMOmCg4.css +1 -0
  4. package/designer/dist/assets/index-DbYFInYv.js +943 -0
  5. package/designer/dist/index.html +2 -2
  6. package/main/default/applications/approve_workflow.app.yml +3 -160
  7. package/main/default/applications/desktop.app.yml +21 -0
  8. package/main/default/client/socket.client.js +2 -5
  9. package/main/default/manager/handlers_manager.js +5 -2
  10. package/main/default/manager/instance_number_rules.js +1 -1
  11. package/main/default/manager/uuflow_manager.js +20 -2
  12. package/main/default/objects/instance_tasks/listviews/inbox.listview.yml +1 -1
  13. package/main/default/objects/instance_tasks/listviews/outbox.listview.yml +1 -1
  14. package/main/default/objects/instances/buttons/instance_delete.button.yml +7 -1
  15. package/main/default/objects/instances/buttons/instance_new.button.yml +2 -2
  16. package/main/default/objects/instances/listviews/completed.listview.yml +1 -1
  17. package/main/default/objects/instances/listviews/draft.listview.yml +1 -1
  18. package/main/default/objects/instances/listviews/monitor.listview.yml +1 -1
  19. package/main/default/objects/instances/listviews/pending.listview.yml +1 -1
  20. package/main/default/pages/flow_selector.page.amis.json +2 -2
  21. package/main/default/pages/flow_selector_mobile.page.amis.json +2 -2
  22. package/main/default/pages/page_instance_print.page.amis.json +11 -1
  23. package/main/default/routes/am.router.js +3 -1
  24. package/main/default/routes/api_auto_number.router.js +166 -23
  25. package/main/default/routes/api_workflow_ai_form_design.router.js +116 -16
  26. package/main/default/routes/api_workflow_ai_form_design_stream.router.js +115 -17
  27. package/main/default/routes/api_workflow_box_filter.router.js +2 -2
  28. package/main/default/routes/api_workflow_nav.router.js +1 -0
  29. package/main/default/services/flows.service.js +20 -24
  30. package/main/default/services/instance.service.js +6 -4
  31. package/main/default/test/test_badge_draft.js +12 -26
  32. package/main/default/test/test_badge_update.js +10 -54
  33. package/package.json +1 -1
  34. package/package.service.js +14 -0
  35. package/public/amis-renderer/amis-renderer.css +1 -1
  36. package/public/amis-renderer/amis-renderer.js +1 -1
  37. package/public/workflow/index.css +10 -279
  38. package/designer/dist/assets/index-CxYuhf9v.js +0 -757
  39. package/designer/dist/assets/index-Dve-EwQO.css +0 -1
@@ -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;
@@ -75,6 +75,7 @@ router.post('/am/ai/form-design', async function auth(req, res, next) {
75
75
  - "org" — 组织(选择单个部门)
76
76
  - "orgMulti" — 组织(多选,选择多个部门)
77
77
  - "reference" — 对象选择(选择指定对象的记录,支持多选,可展示记录的多个字段)
78
+ - "richtext" — 富文本(支持格式化文本、颜色、链接等)
78
79
  - required: 布尔值,是否必填
79
80
  - colspan: 数字,字段占几列,取值范围 1~tableColumns。默认 1(占一列),设为 tableColumns 则独占一行。section 和 table 始终等于 tableColumns
80
81
  - description: 字符串,字段说明/提示文字,支持 HTML
@@ -168,6 +169,19 @@ router.post('/am/ai/form-design', async function auth(req, res, next) {
168
169
  - pickerMultiple: 布尔值,是否允许多选,默认 false
169
170
  - lookupFilters: 字符串,OData 过滤表达式,如 "status eq 'active'"
170
171
  - lookupDisplayFields: 数组,关联数据后额外展示的字段,格式 [{ "field": "字段API名", "label": "显示标签" }]
172
+ - lookupFillRules: 数组,填充规则,选择关联记录后将源字段值自动填充到表单其他字段。格式 [{ "sourceField": "关联对象的字段API名", "targetField": "当前表单中的目标字段name" }]
173
+ - 示例:选择合同后自动填充合同金额到表单的金额字段:lookupFillRules: [{ "sourceField": "amount", "targetField": "contract_amount" }]
174
+
175
+ - member 类型(成员单选)填充规则:
176
+ - pickerFillRules: 数组,选择人员后将人员字段值自动填充到表单其他字段。源对象为 space_users。格式 [{ "sourceField": "源字段API名", "targetField": "当前表单中的目标字段name" }]
177
+ - sourceField 支持点号表示法,可访问关联对象的子属性。成员的 organization 字段引用了 organizations 对象,因此可以使用 "organization.xxx" 访问部门属性
178
+ - 可用的 organization 子属性示例:organization.name(部门名称)、organization.fullname(部门全称)等
179
+ - 示例:选择成员后自动填充其所属部门名称到文本字段:
180
+ pickerFillRules: [{ "sourceField": "organization.name", "targetField": "dept_name" }, { "sourceField": "name", "targetField": "user_name" }]
181
+
182
+ - org 类型(部门单选)填充规则:
183
+ - pickerFillRules: 数组,选择部门后将部门字段值自动填充到表单其他字段。源对象为 organizations。格式同 member 的 pickerFillRules
184
+ - 示例:选择部门后自动填充部门全称到文本字段:pickerFillRules: [{ "sourceField": "fullname", "targetField": "org_fullname" }]
171
185
 
172
186
  - reference 类型(对象选择):
173
187
  - reference_to: 字符串,关联对象的 API 名称,如 "contracts"、"accounts"
@@ -193,16 +207,22 @@ router.post('/am/ai/form-design', async function auth(req, res, next) {
193
207
  ## 事件脚本
194
208
 
195
209
  events 对象包含三个可选的 JavaScript 脚本:
196
- - onInit: 表单初始化时执行。可用参数:form (表单实例), fields (字段数据)
210
+ - onInit: 表单初始化时执行。可用参数:form (表单实例), fields (字段数据), currentStep (当前审批步骤对象)
197
211
  - form.setFieldValue(fieldName, value) — 设置字段值
198
212
  - form.getFieldValue(fieldName) — 获取字段值
199
213
  - form.setFieldOptions(fieldName, options) — 设置下拉选项
200
214
  - form.setFieldHidden(fieldName, hidden) — 显隐字段
201
215
  - form.setFieldRequired(fieldName, required) — 设为必填
202
- - onValueChange: 字段值变化时执行。可用参数:field (变化的字段), value (新值), oldValue (旧值), form (表单实例), values (所有字段值)
203
- - onSubmit: 表单提交前执行。可用参数:form (表单实例), values (提交数据)。返回 false 可阻止提交
216
+ - form.currentStep 当前审批步骤对象,包含 name(步骤名)、step_type(步骤类型: start/submit/sign/counterSign/condition)、_id 等属性
217
+ - onValueChange: 字段值变化时执行。可用参数:field (变化的字段), value (新值), oldValue (旧值), form (表单实例), values (所有字段值), currentStep (当前审批步骤对象)
218
+ - onSubmit: 表单提交前执行。可用参数:form (表单实例), values (提交数据), currentStep (当前审批步骤对象)。返回 false 可阻止提交
219
+
220
+ 所有脚本支持 async/await。
221
+ ⚠️ 脚本内容直接写代码体,参数(form、field、value 等)由运行时自动注入,**禁止用 function(...){} 包裹**。
222
+ 正确:"if (field.name === 'x') { ... }"
223
+ 错误:"function(field, value, oldValue, form, values) { ... }"
204
224
 
205
- 所有脚本支持 async/await,示例:
225
+ 示例:
206
226
  \`\`\`javascript
207
227
  // onValueChange 示例
208
228
  if (field.name === 'quantity' || field.name === 'unit_price') {
@@ -227,14 +247,16 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
227
247
  新版表单已**不再支持** instance_template(审批单模板)、form_script(表单脚本)、flow_events(流程事件),这些是旧版的遗留功能。当用户提供这些数据时,你需要:
228
248
 
229
249
  1. **分析其中的业务逻辑**,理解它们实现了什么功能
230
- 2. **将业务逻辑迁移到新版机制中**:
231
- - 字段显隐控制 转为字段的 visibilityRules
232
- - 动态必填控制转为字段的 requiredRules
233
- - 字段值自动计算转为 formula 类型字段,或写入 events.onValueChange
250
+ 2. **⭐ 声明式字段配置优先,脚本兜底**。迁移旧版脚本时,按以下优先级选择实现方式——能用字段属性声明式解决的,绝不写事件脚本:
251
+ - 数值范围校验(如金额上限/下限)→ number 字段的 **min / max** 属性(而非 onSubmit 脚本校验)
252
+ - 字段显隐控制字段的 **visibilityRules**(而非 onValueChange 中 form.setFieldHidden())
253
+ - 动态必填控制字段的 **requiredRules**(而非 onValueChange 中 form.setFieldRequired())
254
+ - 字段值自动计算 → **formula** 类型字段(而非 onValueChange 中 form.setFieldValue()),仅当公式语法无法表达时才退化到 onValueChange
255
+ - 跨对象属性引用(如旧公式 {applicant.organization.name})→ 成员字段的 **pickerFillRules**,sourceField 使用点号表示法如 "organization.name",targetField 指向目标文本字段
256
+ - 字段选项设置 → 字段的 **options** 属性(静态选项),仅动态/异步加载选项时才用 events.onInit / events.onValueChange 中 form.setFieldOptions()
234
257
  - 初始化赋值/选项加载 → 写入 events.onInit
235
- - 值联动/级联逻辑 写入 events.onValueChange
236
- - 提交前校验 写入 events.onSubmit
237
- - 字段选项设置 → 转为字段的 options 属性,或通过 events.onInit / events.onValueChange 中 form.setFieldOptions() 设置
258
+ - 值联动/级联逻辑(无法用 formula 表达时)→ 写入 events.onValueChange
259
+ - 提交前复杂校验(无法用 min/max/required 等表达时)→ 写入 events.onSubmit
238
260
  3. **instance_template 仍可用于推断字段 label、colspan(列宽)和 rowspan(行高)**
239
261
  4. **不要在输出中返回** instance_template、form_script、flow_events 字段
240
262
 
@@ -313,8 +335,54 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
313
335
  - 新版事件脚本使用 form.getFieldValue() / form.setFieldValue() / form.setFieldOptions() / form.setFieldHidden() / form.setFieldRequired() 等 API
314
336
 
315
337
  ### 流程事件 (flow_events)
316
- - 旧版流程级别的事件脚本
317
- - 分析其中的业务逻辑,合并迁移到新版的 events
338
+ - 旧版流程级别的事件脚本,通常绑定在 jQuery 事件上(如 \`instance-before-submit\`、\`instance-before-save\` 等)
339
+ - 分析其中的业务逻辑,**优先用字段声明式配置实现,无法声明式表达时才写入 events 脚本**
340
+ - 常见迁移模式:
341
+ - \`instance-before-submit\` 中的数值范围校验(如 \`money >= 5000000\` 则阻止提交)→ **直接设置 number 字段的 max 属性**,如 \`"max": 4999999.99\`,无需写 onSubmit 脚本
342
+ - \`instance-before-submit\` 中的必填校验 → 设置字段 \`required: true\` 或 \`requiredRules\`
343
+ - \`instance-before-submit\` 中的复杂跨字段校验(如"结束日期必须大于开始日期"且无法用单字段属性表达)→ 写入 events.onSubmit,用 \`form.getFieldValue()\` 获取值,返回 \`false\` 阻止提交
344
+ - \`instance-before-save\` 中的自动赋值 → 写入 events.onInit 或 events.onValueChange
345
+ - \`WorkflowManager.getInstance().state\` 等实例状态判断 → 新版 onSubmit 仅在提交时触发,草稿保存不触发,通常可省略状态判断
346
+ - \`e.preventDefault()\` + \`toastr.error(msg)\` → 新版用 \`form.showError(msg); return false;\` 替代
347
+ - 注意:旧版脚本中的字段引用(如 \`formValues['中文字段名']\`)需替换为新版英文字段 name
348
+ - 注意:旧版脚本是 JavaScript 语法,其中被注释掉的代码(// 单行注释 或 /* */ 块注释)属于废弃逻辑,不需要识别和迁移,直接忽略即可
349
+ - 迁移示例:
350
+ 旧版 flow_events:
351
+ \`\`\`javascript
352
+ var money = parseFloat(formValues['申请增加额度']);
353
+ if (money >= 5000000) { e.preventDefault(); toastr.error('金额不能超过500万'); }
354
+ \`\`\`
355
+ 新版迁移方式:**不写脚本**,直接在字段上配置:
356
+ \`\`\`json
357
+ { "name": "apply_increase_amount", "type": "number", "max": 4999999.99, "precision": 2 }
358
+ \`\`\`
359
+
360
+ ### 旧版 HTML 字段类型迁移
361
+ - 旧版表单中存在 type 为 \"html\" 的字段,这是旧版的 HTML 编辑器字段,允许用户输入和编辑 HTML 格式的富文本内容
362
+ - 新版表单中 **不再支持 \"html\" 类型**,已被 **\"richtext\"(富文本)** 类型完全替代
363
+ - **迁移规则**:当遇到 type 为 \"html\" 的字段时,必须将其 type 改为 \"richtext\",其余属性(name、label、required、colspan、defaultValue 等)保持不变
364
+ - richtext 类型支持格式化文本、颜色、链接等富文本编辑功能,功能上完全覆盖旧版 html 类型
365
+ - 迁移示例:
366
+ 旧版字段:{ "name": "content", "label": "内容", "type": "html" }
367
+ 新版字段:{ "name": "content", "label": "内容", "type": "richtext" }
368
+ - migrationLog 记录格式:"字段 <name>: type 从 html 转换为 richtext"
369
+
370
+ ### 旧公式字段跨对象引用迁移
371
+ - 旧版表单中,公式字段可能通过 \`{applicant.organization.name}\`、\`{applicant.name}\` 等语法引用成员的关联属性。这种跨对象引用在新版中不再通过公式实现,而是通过**成员字段的 pickerFillRules(填充规则)**来替代。
372
+ - 迁移方式:
373
+ 1. 将引用跨对象属性的公式字段改为**普通文本字段**(type: "text")作为目标字段
374
+ 2. 在成员字段(type: "member")上配置 **pickerFillRules**,使用点号表示法的 sourceField(如 "organization.name"),targetField 指向该文本字段
375
+ 3. 如果 targetField 字段应为只读展示(即旧版是纯展示的公式字段),设置 readonly: true
376
+ - 迁移示例:
377
+ 旧版公式字段:\`{applicant.organization.name}\` 用于显示申请人所属部门名称
378
+ 新版迁移方式:
379
+ 1. 创建文本字段 { "name": "dept_name", "label": "所属部门", "type": "text", "readonly": true }
380
+ 2. 在成员字段上配置 pickerFillRules: [{ "sourceField": "organization.name", "targetField": "dept_name" }]
381
+ - 常见映射:
382
+ - \`{applicant.organization.name}\` → sourceField: "organization.name"
383
+ - \`{applicant.organization.fullname}\` → sourceField: "organization.fullname"
384
+ - \`{applicant.name}\` → sourceField: "name"
385
+ - \`{applicant.mobile}\` → sourceField: "mobile"
318
386
 
319
387
  ## 输出格式
320
388
  返回一个 JSON 对象,格式如下(不要有任何其他文字、解释、markdown 标记):
@@ -325,8 +393,32 @@ if (field.name === 'quantity' || field.name === 'unit_price') {
325
393
  "onInit": "脚本代码或空字符串",
326
394
  "onValueChange": "脚本代码或空字符串",
327
395
  "onSubmit": "脚本代码或空字符串"
328
- }
329
- }`;
396
+ },
397
+ "migrationLog": ["迁移记录,仅升级场景需要"]
398
+ }
399
+
400
+ ### migrationLog(迁移记录)
401
+ 当用户提供了 flow_events、form_script、instance_template 中包含业务逻辑(如校验、赋值、显隐控制等脚本代码)时,必须在 migrationLog 数组中为每条被迁移的逻辑记录一条说明,格式:
402
+ "<来源>: <原逻辑简述> → <迁移到的具体位置>"
403
+
404
+ 迁移目标可以是以下任意一种:
405
+ - 字段属性:如 "字段 apply_increase_amount 的 max:4999999.99"
406
+ - 字段规则:如 "字段 remark 的 visibilityRules"、"字段 attachment_desc 的 requiredRules"
407
+ - formula 字段:如 "新增 formula 字段 total_amount"
408
+ - 事件脚本:如 "events.onSubmit"、"events.onValueChange"
409
+ - 已省略(含原因):如 "已省略(旧版 state=='draft' 判断,新版 onSubmit 仅提交时触发,无需判断)"
410
+
411
+ 示例:
412
+ "migrationLog": [
413
+ "flow_events: 申请增加额度>=500万阻止提交 → 字段 apply_increase_amount 的 max:4999999.99",
414
+ "flow_events: ins.state!='draft' 状态判断 → 已省略(新版 onSubmit 仅提交时触发)",
415
+ "form_script: 根据类型字段显隐备注 → 字段 remark 的 visibilityRules",
416
+ "form_script: 选择部门后级联加载人员选项 → events.onValueChange",
417
+ "instance_template: {applicant.organization.name} 跨对象引用 → 成员字段 applicant 的 pickerFillRules (organization.name → dept_name)"
418
+ ]
419
+
420
+ ⚠️ 如果 flow_events 或 form_script 不为空但 migrationLog 为空数组,视为错误。每条旧版逻辑都必须有对应的迁移记录,不允许静默丢弃。
421
+ 如果没有提供 flow_events、form_script,或它们为空,则 migrationLog 可省略或为空数组。`;
330
422
 
331
423
  // Build current fields description
332
424
  const fieldsJson = JSON.stringify(fields || [], null, 2);
@@ -439,7 +531,7 @@ ${userRequest}
439
531
  'text', 'textarea', 'number', 'date', 'datetime', 'time',
440
532
  'select', 'multiSelect', 'checkbox', 'radio', 'file', 'image',
441
533
  'lookup', 'section', 'table', 'grid', 'formula',
442
- 'member', 'memberMulti', 'org', 'orgMulti', 'reference'
534
+ 'member', 'memberMulti', 'org', 'orgMulti', 'reference', 'richtext'
443
535
  ];
444
536
  const jsIdentifierRe = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
445
537
  function sanitizeName(name) {
@@ -458,6 +550,8 @@ ${userRequest}
458
550
  if (field.type === 'groupPicker') field.type = field.pickerMultiple ? 'orgMulti' : 'org';
459
551
  // Backward compat: map legacy select + pickerMultiple to multiSelect
460
552
  if (field.type === 'select' && field.pickerMultiple) field.type = 'multiSelect';
553
+ // Backward compat: map legacy html type to richtext
554
+ if (field.type === 'html') field.type = 'richtext';
461
555
  if (field.type && !validTypes.includes(field.type)) {
462
556
  field.type = 'text'; // fallback to text for unknown types
463
557
  }
@@ -481,6 +575,8 @@ ${userRequest}
481
575
  if (child.type === 'userPicker') child.type = child.pickerMultiple ? 'memberMulti' : 'member';
482
576
  if (child.type === 'groupPicker') child.type = child.pickerMultiple ? 'orgMulti' : 'org';
483
577
  if (child.type === 'select' && child.pickerMultiple) child.type = 'multiSelect';
578
+ // Backward compat: map legacy html type to richtext
579
+ if (child.type === 'html') child.type = 'richtext';
484
580
  if (child.type && !validTypes.includes(child.type)) {
485
581
  child.type = 'text';
486
582
  }
@@ -512,6 +608,10 @@ ${userRequest}
512
608
  onSubmit: result.events.onSubmit || '',
513
609
  },
514
610
  };
611
+ // Pass through migrationLog if present (for upgrade audit trail)
612
+ if (Array.isArray(result.migrationLog) && result.migrationLog.length > 0) {
613
+ responseData.migrationLog = result.migrationLog;
614
+ }
515
615
  return res.json(responseData);
516
616
 
517
617
  } catch (error) {