@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.
- package/designer/dist/amis-renderer/amis-renderer.css +1 -1
- package/designer/dist/amis-renderer/amis-renderer.js +1 -1
- package/designer/dist/assets/index-7cMOmCg4.css +1 -0
- package/designer/dist/assets/index-DbYFInYv.js +943 -0
- package/designer/dist/index.html +2 -2
- package/main/default/applications/approve_workflow.app.yml +3 -160
- package/main/default/applications/desktop.app.yml +21 -0
- package/main/default/client/socket.client.js +2 -5
- package/main/default/manager/handlers_manager.js +5 -2
- package/main/default/manager/instance_number_rules.js +1 -1
- package/main/default/manager/uuflow_manager.js +20 -2
- package/main/default/objects/instance_tasks/listviews/inbox.listview.yml +1 -1
- package/main/default/objects/instance_tasks/listviews/outbox.listview.yml +1 -1
- package/main/default/objects/instances/buttons/instance_delete.button.yml +7 -1
- package/main/default/objects/instances/buttons/instance_new.button.yml +2 -2
- package/main/default/objects/instances/listviews/completed.listview.yml +1 -1
- package/main/default/objects/instances/listviews/draft.listview.yml +1 -1
- package/main/default/objects/instances/listviews/monitor.listview.yml +1 -1
- package/main/default/objects/instances/listviews/pending.listview.yml +1 -1
- package/main/default/pages/flow_selector.page.amis.json +2 -2
- package/main/default/pages/flow_selector_mobile.page.amis.json +2 -2
- package/main/default/pages/page_instance_print.page.amis.json +11 -1
- package/main/default/routes/am.router.js +3 -1
- package/main/default/routes/api_auto_number.router.js +166 -23
- package/main/default/routes/api_workflow_ai_form_design.router.js +116 -16
- package/main/default/routes/api_workflow_ai_form_design_stream.router.js +115 -17
- package/main/default/routes/api_workflow_box_filter.router.js +2 -2
- package/main/default/routes/api_workflow_nav.router.js +1 -0
- package/main/default/services/flows.service.js +20 -24
- package/main/default/services/instance.service.js +6 -4
- package/main/default/test/test_badge_draft.js +12 -26
- package/main/default/test/test_badge_update.js +10 -54
- package/package.json +1 -1
- package/package.service.js +14 -0
- package/public/amis-renderer/amis-renderer.css +1 -1
- package/public/amis-renderer/amis-renderer.js +1 -1
- package/public/workflow/index.css +10 -279
- package/designer/dist/assets/index-CxYuhf9v.js +0 -757
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');\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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
203
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
232
|
-
-
|
|
233
|
-
-
|
|
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
|
-
-
|
|
236
|
-
-
|
|
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
|
-
-
|
|
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) {
|