@steedos-labs/plugin-workflow 3.0.84 → 3.0.85

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.
@@ -1,5 +1,5 @@
1
1
  waitForThing(window, 'antd').then(function(){
2
- var v = '3.0.84';
2
+ var v = '3.0.85';
3
3
  loadJs('/amis-renderer/amis-renderer.js?v=' + v);
4
4
  loadCss('/amis-renderer/amis-renderer.css?v=' + v)
5
5
  })
@@ -1398,23 +1398,54 @@ UUFlowManager.getApproveValues = async function (approve_values, permissions, fo
1398
1398
  const form = await UUFlowManager.getForm(form_id);
1399
1399
  const form_v = await UUFlowManager.getFormVersion(form, form_version);
1400
1400
 
1401
+ // 判断字段是否为公式字段。
1402
+ // 历史表单设计器保存时存在丢失 `formula` 属性的情况,此时公式表达式会被转存到
1403
+ // `default_value` 中,形如 "${...}"。这里同时兜底识别该形态,避免老数据下公式
1404
+ // 子字段被当成普通无权限字段误过滤,保存后刷新计算结果丢失。
1405
+ const isFormulaField = (f) => {
1406
+ if (!f) return false;
1407
+ if (f.formula) return true;
1408
+ const dv = f.default_value;
1409
+ return typeof dv === 'string' && /^\$\{[\s\S]+\}$/.test(dv);
1410
+ };
1411
+
1412
+ // 开启 STEEDOS_DEBUG 时,打印 getApproveValues 关键参数,便于定位线上
1413
+ // 「公式子字段保存后刷新丢值」类问题,运维无需临时改源码。
1414
+ const debugApproveValues = !!process.env.STEEDOS_DEBUG;
1415
+ if (debugApproveValues) {
1416
+ console.log('[getApproveValues] form_id=%s form_version=%s permissions=%j', form_id, form_version, permissions);
1417
+ }
1418
+
1401
1419
  // Filter fields based on permissions
1402
1420
  for (const field of form_v.fields || []) {
1403
1421
  if (field.type === "section") {
1404
1422
  // Handle section fields
1405
1423
  for (const sectionField of field.fields || []) {
1406
- if (!sectionField.formula && (permissions[sectionField.code] === null || permissions[sectionField.code] !== "editable")) {
1424
+ if (!isFormulaField(sectionField) && (permissions[sectionField.code] === null || permissions[sectionField.code] !== "editable")) {
1407
1425
  delete approve_values[sectionField.code];
1408
1426
  }
1409
1427
  }
1410
- } else if (field.type === "table" && !field.formula && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1428
+ } else if (field.type === "table" && !isFormulaField(field) && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1411
1429
  // 子表字段处理:子表本身无编辑权限时,检查是否有子字段具有编辑权限
1412
1430
  const childFields = field.fields || [];
1413
1431
  const hasEditableChild = childFields.some(c => permissions[c.code] === "editable");
1432
+ if (debugApproveValues) {
1433
+ // 只摘取关键属性,避免 _amisField 等运行时副本属性刷屏
1434
+ const childSchemaSummary = childFields.map(c => ({
1435
+ code: c.code,
1436
+ type: c.type,
1437
+ formula: c.formula,
1438
+ default_value: c.default_value,
1439
+ isFormula: isFormulaField(c),
1440
+ permission: permissions[c.code]
1441
+ }));
1442
+ console.log('[getApproveValues] table field "%s" hasEditableChild=%s childFieldsSummary=%j approve_values_before=%j',
1443
+ field.code, hasEditableChild, childSchemaSummary, approve_values[field.code]);
1444
+ }
1414
1445
  if (hasEditableChild && Array.isArray(approve_values[field.code])) {
1415
1446
  // 保留可编辑子字段及公式子字段。公式字段为计算值(非用户输入),其结果依赖其他字段,
1416
- // 必须随依赖字段一并持久化,否则保存后刷新会回退。与第1432行顶层字段 !field.formula 保护逻辑对称一致。
1417
- const editableChildCodes = new Set(childFields.filter(c => c.formula || permissions[c.code] === "editable").map(c => c.code));
1447
+ // 必须随依赖字段一并持久化,否则保存后刷新会回退。与下方顶层字段 !isFormulaField(field) 保护逻辑对称一致。
1448
+ const editableChildCodes = new Set(childFields.filter(c => isFormulaField(c) || permissions[c.code] === "editable").map(c => c.code));
1418
1449
  editableChildCodes.add('_id');
1419
1450
  approve_values[field.code] = approve_values[field.code].map(row => {
1420
1451
  const filtered = {};
@@ -1425,11 +1456,18 @@ UUFlowManager.getApproveValues = async function (approve_values, permissions, fo
1425
1456
  }
1426
1457
  return filtered;
1427
1458
  });
1459
+ if (debugApproveValues) {
1460
+ console.log('[getApproveValues] table field "%s" kept child codes=%j approve_values_after=%j',
1461
+ field.code, Array.from(editableChildCodes), approve_values[field.code]);
1462
+ }
1428
1463
  } else {
1429
1464
  // 无可编辑子字段 → 整表删除
1430
1465
  delete approve_values[field.code];
1466
+ if (debugApproveValues) {
1467
+ console.log('[getApproveValues] table field "%s" deleted (no editable child)', field.code);
1468
+ }
1431
1469
  }
1432
- } else if (!field.formula && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1470
+ } else if (!isFormulaField(field) && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1433
1471
  // 普通字段:无编辑权限且非公式字段则删除
1434
1472
  delete approve_values[field.code];
1435
1473
  }
@@ -4692,12 +4730,16 @@ UUFlowManager.relocate = async function (instance_from_client, current_user_info
4692
4730
  const space_id = instance.space;
4693
4731
  const instance_id = last_trace.instance;
4694
4732
  const inbox_users = instance.inbox_users || [];
4695
- const relocate_inbox_users = instance_from_client.relocate_inbox_users || [];
4733
+ let relocate_inbox_users = instance_from_client.relocate_inbox_users || [];
4696
4734
  const relocate_comment = instance_from_client.relocate_comment;
4697
4735
  const relocate_next_step = instance_from_client.relocate_next_step;
4698
4736
 
4699
- const not_in_inbox_users = inbox_users.filter(u => !relocate_inbox_users.includes(u));
4700
- const new_inbox_users = relocate_inbox_users.filter(u => !inbox_users.includes(u));
4737
+ if (_.isString(relocate_inbox_users)) {
4738
+ relocate_inbox_users = [relocate_inbox_users];
4739
+ } else if (!Array.isArray(relocate_inbox_users)) {
4740
+ relocate_inbox_users = [];
4741
+ }
4742
+
4701
4743
  const approve_users = [];
4702
4744
 
4703
4745
  const flow = await UUFlowManager.getFlow(instance.flow);
@@ -4705,6 +4747,17 @@ UUFlowManager.relocate = async function (instance_from_client, current_user_info
4705
4747
  const next_step_type = next_step.step_type;
4706
4748
  const next_step_name = next_step.name;
4707
4749
 
4750
+ if (next_step_type === 'start' && relocate_inbox_users.length === 0) {
4751
+ relocate_inbox_users = await HandlersManager.getHandlers(instance_id, relocate_next_step, current_user);
4752
+ }
4753
+
4754
+ if (next_step_type !== 'end' && (!relocate_inbox_users || relocate_inbox_users.length === 0)) {
4755
+ throw new Error('未指定下一步处理人');
4756
+ }
4757
+
4758
+ const not_in_inbox_users = inbox_users.filter(u => !relocate_inbox_users.includes(u));
4759
+ const new_inbox_users = relocate_inbox_users.filter(u => !inbox_users.includes(u));
4760
+
4708
4761
  const current_setp = await UUFlowManager.getStep(instance, flow, last_trace.step);
4709
4762
  const current_setp_type = current_setp.step_type;
4710
4763
 
@@ -48,6 +48,6 @@ extra_columns:
48
48
  - extras
49
49
  - values
50
50
  disableSwitch: true
51
- filter_required: true
51
+ # filter_required: true
52
52
  # searchable_default:
53
53
  # submit_date: "${[STARTOF(DATEMODIFY(NOW(), -180, 'day'), 'day'),ENDOF(NOW(), 'day')]}"
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "type": "liquid",
3
- "template": "<style>\n @keyframes fadeUpSpring {\n 0% { opacity: 0; transform: translateY(10px); }\n 100% { opacity: 1; transform: translateY(0); }\n }\n \n /* Make scrollbars standardized and visible */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.25); /* Darker for visibility on gray bg */\n border-radius: 4px;\n border: 2px solid transparent; /* Creates padding effect */\n background-clip: content-box;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: rgba(0, 0, 0, 0.4);\n }\n\n /* \n Fix outer modal scrollbar - SAFER VERSION \n Only apply these aggressive overrides (no padding, hidden overflow)\n to the specific modal that contains our component (identified by #steedosFlowSelectorSidebarList).\n This prevents breaking other stacked modals like 'Confirm Dialogs'.\n */\n .antd-Modal-body:has(#steedosFlowSelectorSidebarList) {\n overflow: hidden !important;\n padding: 0 !important; /* Optional: maximize space */\n display: flex;\n flex-direction: column;\n }\n\n /* Ensure the AMIS container fills height if needed */\n .antd-Service, .liquid-amis-container {\n height: 100%;\n }\n</style>\n\n<!-- Main Container: Fixed Height 70vh. -->\n<div class=\"flex h-[70vh] max-h-[800px] w-full overflow-hidden font-sans text-gray-900 bg-white\" style=\"min-height: 0;\">\n\n <!-- Left Sidebar -->\n <!-- flex-col, h-full, overflow-hidden -->\n <div class=\"flex flex-col w-[260px] h-full border-r border-gray-200 bg-[#F2F2F7] shrink-0 overflow-hidden\">\n <!-- Header -->\n <div class=\"shrink-0 pt-4 pb-2 px-3\">\n <div class=\"relative group\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-gray-500\">\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"></circle>\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>\n </svg>\n </div>\n <input type=\"text\" id=\"searchInput\" placeholder=\"搜索流程名称...\" class=\"w-full rounded-[10px] border-none bg-[#767680]/10 py-1.5 pl-9 pr-3 text-[14px] text-gray-900 placeholder:text-gray-500 outline-none transition-all duration-200 focus:bg-white focus:shadow-sm focus:ring-2 focus:ring-blue-500/20\">\n </div>\n </div>\n \n <!-- List Container -->\n <!-- min-h-0 is CRITICAL for flex child scrolling -->\n <div class=\"flex-1 min-h-0 overflow-y-auto px-2 pb-4 space-y-0.5 scroll-smooth\" id=\"steedosFlowSelectorSidebarList\">\n </div>\n </div>\n\n <!-- Right Content -->\n <!-- flex-1 fills remaining width -->\n <div class=\"flex-1 h-full relative bg-white overflow-hidden\">\n <!-- Absolute inset-0 locks the scroll container size -->\n <div id=\"mainContentScroll\" class=\"absolute inset-0 overflow-y-auto scroll-smooth p-6\">\n <div id=\"contentContainer\" class=\"w-full h-auto min-h-full\">\n <div class=\"flex h-full w-full flex-col items-center justify-center pt-20\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path>\n </svg>\n <span>正在加载资源...</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n const WorkflowService = {\n apiBase: \"\",\n getHeaders: function() { return { 'Content-Type': 'application/json' }; },\n getData: async function() {\n try {\n const appId = (typeof data !== 'undefined' && data.context && data.context.app_id) ? data.context.app_id : \"\";\n const url = this.apiBase + \"/service/api/flows/getList?action=new&appId=\" + encodeURIComponent(appId);\n const res = await fetch(url, { headers: this.getHeaders() });\n const treeData = await res.json();\n const categories = [];\n const parsedFlows = [];\n if (Array.isArray(treeData)) {\n treeData.forEach(cat => {\n categories.push({ _id: cat._id, name: cat.name });\n if (Array.isArray(cat.flows)) {\n cat.flows.forEach(f => {\n parsedFlows.push({\n id: f._id, name: f.name, categoryId: cat._id, categoryName: cat.name || \"其他流程\"\n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) {\n console.error(\"WorkflowService Error:\", e);\n return { categories: [], flows: [] };\n }\n },\n getFavorites: function() {\n const saved = localStorage.getItem('steedos_fav_ids');\n return saved ? JSON.parse(saved) : [];\n },\n toggleFavorite: function(flowId, isFav) {\n let favs = this.getFavorites();\n if (isFav) { if (!favs.includes(flowId)) favs.push(flowId); }\n else { favs = favs.filter(id => id !== flowId); }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const AppState = { allFlows: [], categories: [], favorites: [] };\n\n function escapeHtml(str) {\n return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n const sidebarEl = document.getElementById('steedosFlowSelectorSidebarList');\n const contentEl = document.getElementById('contentContainer');\n const searchInput = document.getElementById('searchInput');\n\n // Optimization 2: lazy rendering state\n let _observer = null;\n let _currentGroups = [];\n\n // Render cards into a placeholder element for a single group (lazy)\n var ESTIMATED_CARD_HEIGHT_PX = 88;\n var LAZY_LOAD_MARGIN = '300px 0px';\n\n function fillCards(group, placeholder) {\n if (placeholder.dataset.rendered) return;\n placeholder.dataset.rendered = 'true';\n placeholder.style.minHeight = '';\n const gridFragment = document.createDocumentFragment();\n group.items.forEach(function(flow, i) {\n const isFav = AppState.favorites.includes(flow.id);\n const colorMap = ['bg-blue-50 text-blue-600', 'bg-orange-50 text-orange-600', 'bg-emerald-50 text-emerald-600', 'bg-indigo-50 text-indigo-600'];\n const colorClass = colorMap[(flow.name.length + i) % 4];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n const card = document.createElement('div');\n card.className = 'group relative flex h-auto min-h-[72px] cursor-pointer items-center rounded-2xl border border-gray-100 bg-white p-3 text-left shadow-[0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.02] transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-1 hover:border-gray-200 hover:shadow-[0_12px_24px_rgba(0,0,0,0.08)] active:scale-[0.98] active:bg-gray-50' + (i < 12 ? ' animate-[fadeUpSpring_0.4s_cubic-bezier(0.16,1,0.3,1)_forwards]' : '');\n if (i < 12) { card.style.animationDelay = (i * 0.03) + 's'; card.style.opacity = '0'; }\n // Optimization 3: data-flow-id for event delegation\n card.dataset.flowId = flow.id;\n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]';\n const btnBgClass = isFav ? 'opacity-100 hover:scale-110' : 'opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:scale-110';\n card.innerHTML = '<div class=\"star-btn group/btn absolute right-2 top-1/2 -translate-y-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200 ' + btnBgClass + (isFav ? ' active-fav' : '') + '\" title=\"' + (isFav ? '取消收藏' : '加入收藏') + '\" data-flow-id=\"' + flow.id + '\"><svg class=\"h-5 w-5 transition-colors duration-300 ' + iconClass + '\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z\" /></svg></div><div class=\"mr-4 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[16px] font-bold ' + colorClass + '\">' + escapeHtml(firstChar) + '</div><div class=\"flex-1 pr-8 text-[15px] font-medium text-gray-900 line-clamp-3 leading-relaxed tracking-tight\" title=\"' + escapeHtml(flow.name) + '\">' + escapeHtml(flow.name) + '</div>';\n gridFragment.appendChild(card);\n });\n placeholder.appendChild(gridFragment);\n }\n\n // Optimization 3: single delegated click listener — handles both star-btn and card clicks\n contentEl.addEventListener('click', function(e) {\n const starBtn = e.target.closest('.star-btn');\n if (starBtn) {\n const flowId = starBtn.dataset.flowId;\n const isFav = AppState.favorites.includes(flowId);\n const newFavState = !isFav;\n if (newFavState) {\n starBtn.classList.add('active-fav', 'opacity-100');\n starBtn.querySelector('svg').setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-yellow-400 fill-current');\n starBtn.setAttribute('title', '取消收藏');\n } else {\n starBtn.classList.remove('active-fav', 'opacity-100');\n starBtn.querySelector('svg').setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]');\n starBtn.setAttribute('title', '加入收藏');\n }\n AppState.favorites = WorkflowService.toggleFavorite(flowId, newFavState);\n setTimeout(function() { renderUI(searchInput.value); }, 300);\n return;\n }\n const card = e.target.closest('[data-flow-id]');\n if (card && !card.classList.contains('star-btn')) {\n if (card.dataset.loading === 'true') return;\n card.dataset.loading = 'true';\n card.style.pointerEvents = 'none';\n card.style.opacity = '0.6';\n card.innerHTML = '<div class=\"flex items-center justify-center w-full gap-2 text-gray-400 text-sm\"><svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle><path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg><span>正在创建...</span></div>';\n setTimeout(function() {\n data._scoped.doAction([\n { \"actionType\": \"broadcast\", \"args\": { \"eventName\": \"flows.selected\" }, \"data\": { \"value\": card.dataset.flowId } }\n ]);\n }, 50);\n }\n });\n\n async function init() {\n try {\n const result = await WorkflowService.getData();\n AppState.allFlows = result.flows;\n AppState.categories = result.categories;\n AppState.favorites = WorkflowService.getFavorites();\n renderUI();\n } catch (e) {\n contentEl.innerHTML = '<div class=\"text-gray-400 text-sm\">加载失败,请检查网络</div>';\n }\n }\n\n function matchKeywords(name, filterText) {\n if (!filterText) return true;\n var keywords = filterText.toLowerCase().split(/\\s+/).filter(function(k) { return k.length > 0; });\n if (keywords.length === 0) return true;\n var lowerName = name.toLowerCase();\n return keywords.every(function(k) { return lowerName.includes(k); });\n }\n\n function renderUI(filterText) {\n filterText = filterText || \"\";\n // Disconnect previous IntersectionObserver before rebuilding DOM\n if (_observer) { _observer.disconnect(); _observer = null; }\n sidebarEl.innerHTML = \"\";\n contentEl.innerHTML = \"\";\n\n const isSearching = filterText.length > 0;\n const groups = [];\n\n const favFlows = AppState.allFlows.filter(function(f) {\n return AppState.favorites.includes(f.id) && matchKeywords(f.name, filterText);\n });\n if (favFlows.length > 0) {\n groups.push({ id: 'fav', name: \"我的收藏\", items: favFlows, isFav: true });\n }\n\n AppState.categories.forEach(function(cat) {\n const items = AppState.allFlows.filter(function(f) {\n return f.categoryId === cat._id && matchKeywords(f.name, filterText);\n });\n if (items.length > 0) {\n groups.push({ id: cat._id, name: cat.name, items: items, isFav: false });\n }\n });\n\n const otherItems = AppState.allFlows.filter(function(f) {\n return !AppState.categories.find(function(c) { return c._id === f.categoryId; }) &&\n matchKeywords(f.name, filterText);\n });\n if (otherItems.length > 0) {\n groups.push({ id: 'other', name: \"其他流程\", items: otherItems, isFav: false });\n }\n\n if (groups.length === 0) {\n contentEl.innerHTML = '<div class=\"animate-[fadeUpSpring_0.5s_ease-out] text-center pt-20\"><div class=\"text-gray-200 text-7xl mb-4\">∅</div><div class=\"text-gray-400 text-sm\">未找到匹配流程</div></div>';\n return;\n }\n\n _currentGroups = groups;\n const contentFragment = document.createDocumentFragment();\n const navBase = \"group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-[14px] transition-all duration-200 ease-out select-none\";\n const activeClass = \"bg-[#007AFF] text-white shadow-sm font-medium\";\n const inactiveClass = \"text-gray-700 hover:bg-black/5 active:bg-black/10\";\n\n groups.forEach(function(group, index) {\n const groupId = 'group-' + group.id;\n const navItem = document.createElement('div');\n navItem.className = navBase + ' ' + (index === 0 ? activeClass : inactiveClass);\n const badgeClass = index === 0 ? \"text-white/80\" : \"text-gray-400 group-hover:text-gray-500\";\n navItem.innerHTML = '<span class=\"truncate\">' + (group.isFav ? '★ ' : '') + group.name + '</span><span class=\"' + badgeClass + ' text-[12px] font-medium transition-colors\">' + group.items.length + '</span>';\n\n navItem.onclick = (function(grp, gId) {\n return function() {\n Array.from(sidebarEl.children).forEach(function(el) {\n el.className = navBase + ' ' + inactiveClass;\n el.querySelector('span:last-child').className = \"text-gray-400 group-hover:text-gray-500 text-[12px] font-medium transition-colors\";\n });\n navItem.className = navBase + ' ' + activeClass;\n navItem.querySelector('span:last-child').className = \"text-white/80 text-[12px] font-medium transition-colors\";\n const section = document.getElementById(gId);\n if (section) {\n // Optimization 2: force-render target section before scrolling to avoid blank placeholder\n const ph = section.querySelector('.card-placeholder');\n if (ph && !ph.dataset.rendered) {\n fillCards(grp, ph);\n if (_observer) _observer.unobserve(ph);\n }\n const container = document.getElementById('mainContentScroll');\n if (container) {\n const targetTop = section.getBoundingClientRect().top;\n const containerTop = container.getBoundingClientRect().top;\n container.scrollTo({ top: container.scrollTop + targetTop - containerTop - 16, behavior: 'smooth' });\n }\n }\n };\n })(group, groupId);\n sidebarEl.appendChild(navItem);\n\n // Optimization 2: render section header + placeholder (cards filled lazily)\n const section = document.createElement('div');\n section.id = groupId;\n section.className = \"mb-10\";\n const headerColor = group.isFav ? 'text-amber-500' : 'text-gray-900';\n section.innerHTML = '<div class=\"sticky top-0 z-20 mb-4 bg-white pb-2 text-xl font-bold tracking-tight text-left border-b border-gray-100 ' + headerColor + '\">' + group.name + '</div>';\n const placeholder = document.createElement('div');\n placeholder.className = 'card-placeholder grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4';\n // Pre-size the placeholder to avoid layout shift while cards aren't rendered yet\n placeholder.style.minHeight = (Math.ceil(group.items.length / 4) * ESTIMATED_CARD_HEIGHT_PX) + 'px';\n section.appendChild(placeholder);\n contentFragment.appendChild(section);\n });\n\n contentEl.appendChild(contentFragment);\n\n // Optimization 2: set up IntersectionObserver to fill cards as groups scroll into view\n const scrollContainer = document.getElementById('mainContentScroll');\n _observer = new IntersectionObserver(function(entries) {\n entries.forEach(function(entry) {\n if (entry.isIntersecting && entry.target.isConnected && !entry.target.dataset.rendered) {\n const ph = entry.target;\n const section = ph.parentElement;\n if (section && section.id) {\n const gId = section.id.replace('group-', '');\n const grp = _currentGroups.find(function(g) { return String(g.id) === gId; });\n if (grp) {\n fillCards(grp, ph);\n if (_observer) _observer.unobserve(ph);\n }\n }\n }\n });\n }, { root: scrollContainer, rootMargin: LAZY_LOAD_MARGIN });\n\n contentEl.querySelectorAll('.card-placeholder').forEach(function(p) {\n _observer.observe(p);\n });\n }\n\n // Optimization 4: Chinese IME compositionstart/compositionend prevents stutter during composition\n let _isComposing = false;\n let _searchTimer = null;\n searchInput.addEventListener('compositionstart', function() { _isComposing = true; });\n searchInput.addEventListener('compositionend', function(e) {\n _isComposing = false;\n clearTimeout(_searchTimer);\n renderUI(e.target.value.trim());\n });\n searchInput.addEventListener('input', function(e) {\n if (_isComposing) return;\n clearTimeout(_searchTimer);\n _searchTimer = setTimeout(function() { renderUI(e.target.value.trim()); }, 300);\n });\n\n init();\n</script>\n\n",
3
+ "template": "<style>\n @keyframes fadeUpSpring {\n 0% { opacity: 0; transform: translateY(10px); }\n 100% { opacity: 1; transform: translateY(0); }\n }\n \n /* Make scrollbars standardized and visible */\n ::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n }\n ::-webkit-scrollbar-track {\n background: transparent;\n }\n ::-webkit-scrollbar-thumb {\n background-color: rgba(0, 0, 0, 0.25); /* Darker for visibility on gray bg */\n border-radius: 4px;\n border: 2px solid transparent; /* Creates padding effect */\n background-clip: content-box;\n }\n ::-webkit-scrollbar-thumb:hover {\n background-color: rgba(0, 0, 0, 0.4);\n }\n\n /* \n Fix outer modal scrollbar - SAFER VERSION \n Only apply these aggressive overrides (no padding, hidden overflow)\n to the specific modal that contains our component (identified by #steedosFlowSelectorSidebarList).\n This prevents breaking other stacked modals like 'Confirm Dialogs'.\n */\n .antd-Modal-body:has(#steedosFlowSelectorSidebarList) {\n overflow: hidden !important;\n padding: 0 !important; /* Optional: maximize space */\n display: flex;\n flex-direction: column;\n }\n\n /* Ensure the AMIS container fills height if needed */\n .antd-Service, .liquid-amis-container {\n height: 100%;\n }\n</style>\n\n<!-- Main Container: Fixed Height 70vh. -->\n<div class=\"flex h-[70vh] max-h-[800px] w-full overflow-hidden font-sans text-gray-900 bg-white\" style=\"min-height: 0;\">\n\n <!-- Left Sidebar -->\n <!-- flex-col, h-full, overflow-hidden -->\n <div class=\"flex flex-col w-[260px] h-full border-r border-gray-200 bg-[#F2F2F7] shrink-0 overflow-hidden\">\n <!-- Header -->\n <div class=\"shrink-0 pt-4 pb-2 px-3\">\n <div class=\"relative group\">\n <div class=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-gray-500\">\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <circle cx=\"11\" cy=\"11\" r=\"8\"></circle>\n <line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>\n </svg>\n </div>\n <input type=\"text\" id=\"searchInput\" placeholder=\"搜索流程名称...\" class=\"w-full rounded-[10px] border-none bg-[#767680]/10 py-1.5 pl-9 pr-3 text-[14px] text-gray-900 placeholder:text-gray-500 outline-none transition-all duration-200 focus:bg-white focus:shadow-sm focus:ring-2 focus:ring-blue-500/20\">\n </div>\n </div>\n \n <!-- List Container -->\n <!-- min-h-0 is CRITICAL for flex child scrolling -->\n <div class=\"flex-1 min-h-0 overflow-y-auto px-2 pb-4 space-y-0.5 scroll-smooth\" id=\"steedosFlowSelectorSidebarList\">\n </div>\n </div>\n\n <!-- Right Content -->\n <!-- flex-1 fills remaining width -->\n <div class=\"flex-1 h-full relative bg-white overflow-hidden\">\n <!-- Absolute inset-0 locks the scroll container size -->\n <div id=\"mainContentScroll\" class=\"absolute inset-0 overflow-y-auto p-6\">\n <div id=\"contentContainer\" class=\"w-full h-auto min-h-full\">\n <div class=\"flex h-full w-full flex-col items-center justify-center pt-20\">\n <div class=\"inline-flex items-center gap-2 text-gray-400 text-sm animate-pulse\">\n <svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle>\n <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path>\n </svg>\n <span>正在加载资源...</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<script>\n const WorkflowService = {\n apiBase: \"\",\n getHeaders: function() { return { 'Content-Type': 'application/json' }; },\n getData: async function() {\n try {\n const appId = (typeof data !== 'undefined' && data.context && data.context.app_id) ? data.context.app_id : \"\";\n const url = this.apiBase + \"/service/api/flows/getList?action=new&appId=\" + encodeURIComponent(appId);\n const res = await fetch(url, { headers: this.getHeaders() });\n const treeData = await res.json();\n const categories = [];\n const parsedFlows = [];\n if (Array.isArray(treeData)) {\n treeData.forEach(cat => {\n categories.push({ _id: cat._id, name: cat.name });\n if (Array.isArray(cat.flows)) {\n cat.flows.forEach(f => {\n parsedFlows.push({\n id: f._id, name: f.name, categoryId: cat._id, categoryName: cat.name || \"其他流程\"\n });\n });\n }\n });\n }\n return { categories: categories, flows: parsedFlows };\n } catch (e) {\n console.error(\"WorkflowService Error:\", e);\n return { categories: [], flows: [] };\n }\n },\n getFavorites: function() {\n const saved = localStorage.getItem('steedos_fav_ids');\n return saved ? JSON.parse(saved) : [];\n },\n toggleFavorite: function(flowId, isFav) {\n let favs = this.getFavorites();\n if (isFav) { if (!favs.includes(flowId)) favs.push(flowId); }\n else { favs = favs.filter(id => id !== flowId); }\n localStorage.setItem('steedos_fav_ids', JSON.stringify(favs));\n return favs;\n }\n };\n\n const AppState = { allFlows: [], categories: [], favorites: [] };\n\n function escapeHtml(str) {\n return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n const sidebarEl = document.getElementById('steedosFlowSelectorSidebarList');\n const contentEl = document.getElementById('contentContainer');\n const searchInput = document.getElementById('searchInput');\n\n // Optimization 2: lazy rendering state\n let _observer = null;\n let _currentGroups = [];\n\n // Render cards into a placeholder element for a single group (lazy)\n var ESTIMATED_CARD_HEIGHT_PX = 88;\n var LAZY_LOAD_MARGIN = '300px 0px';\n\n function fillCards(group, placeholder) {\n if (placeholder.dataset.rendered) return;\n placeholder.dataset.rendered = 'true';\n placeholder.style.minHeight = '';\n const gridFragment = document.createDocumentFragment();\n group.items.forEach(function(flow, i) {\n const isFav = AppState.favorites.includes(flow.id);\n const colorMap = ['bg-blue-50 text-blue-600', 'bg-orange-50 text-orange-600', 'bg-emerald-50 text-emerald-600', 'bg-indigo-50 text-indigo-600'];\n const colorClass = colorMap[(flow.name.length + i) % 4];\n const firstChar = flow.name.replace(/【.*?】/g, '').charAt(0) || flow.name.charAt(0);\n const card = document.createElement('div');\n card.className = 'group relative flex h-auto min-h-[72px] cursor-pointer items-center rounded-2xl border border-gray-100 bg-white p-3 text-left shadow-[0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-black/[0.02] transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-1 hover:border-gray-200 hover:shadow-[0_12px_24px_rgba(0,0,0,0.08)] active:scale-[0.98] active:bg-gray-50' + (i < 12 ? ' animate-[fadeUpSpring_0.4s_cubic-bezier(0.16,1,0.3,1)_forwards]' : '');\n if (i < 12) { card.style.animationDelay = (i * 0.03) + 's'; card.style.opacity = '0'; }\n // Optimization 3: data-flow-id for event delegation\n card.dataset.flowId = flow.id;\n const iconClass = isFav ? 'text-yellow-400 fill-current' : 'text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]';\n const btnBgClass = isFav ? 'opacity-100 hover:scale-110' : 'opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:scale-110';\n card.innerHTML = '<div class=\"star-btn group/btn absolute right-2 top-1/2 -translate-y-1/2 z-20 flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200 ' + btnBgClass + (isFav ? ' active-fav' : '') + '\" title=\"' + (isFav ? '取消收藏' : '加入收藏') + '\" data-flow-id=\"' + flow.id + '\"><svg class=\"h-5 w-5 transition-colors duration-300 ' + iconClass + '\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z\" /></svg></div><div class=\"mr-4 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[16px] font-bold ' + colorClass + '\">' + escapeHtml(firstChar) + '</div><div class=\"flex-1 pr-8 text-[15px] font-medium text-gray-900 line-clamp-3 leading-relaxed tracking-tight\" title=\"' + escapeHtml(flow.name) + '\">' + escapeHtml(flow.name) + '</div>';\n gridFragment.appendChild(card);\n });\n placeholder.appendChild(gridFragment);\n }\n\n // Optimization 3: single delegated click listener — handles both star-btn and card clicks\n contentEl.addEventListener('click', function(e) {\n const starBtn = e.target.closest('.star-btn');\n if (starBtn) {\n const flowId = starBtn.dataset.flowId;\n const isFav = AppState.favorites.includes(flowId);\n const newFavState = !isFav;\n if (newFavState) {\n starBtn.classList.add('active-fav', 'opacity-100');\n starBtn.querySelector('svg').setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-yellow-400 fill-current');\n starBtn.setAttribute('title', '取消收藏');\n } else {\n starBtn.classList.remove('active-fav', 'opacity-100');\n starBtn.querySelector('svg').setAttribute('class', 'h-5 w-5 transition-colors duration-300 text-gray-300 group-hover/btn:text-gray-400 fill-none stroke-current stroke-[1.5]');\n starBtn.setAttribute('title', '加入收藏');\n }\n AppState.favorites = WorkflowService.toggleFavorite(flowId, newFavState);\n setTimeout(function() { renderUI(searchInput.value); }, 300);\n return;\n }\n const card = e.target.closest('[data-flow-id]');\n if (card && !card.classList.contains('star-btn')) {\n if (card.dataset.loading === 'true') return;\n card.dataset.loading = 'true';\n card.style.pointerEvents = 'none';\n card.style.opacity = '0.6';\n card.innerHTML = '<div class=\"flex items-center justify-center w-full gap-2 text-gray-400 text-sm\"><svg class=\"animate-spin h-4 w-4\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle><path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg><span>正在创建...</span></div>';\n setTimeout(function() {\n data._scoped.doAction([\n { \"actionType\": \"broadcast\", \"args\": { \"eventName\": \"flows.selected\" }, \"data\": { \"value\": card.dataset.flowId } }\n ]);\n }, 50);\n }\n });\n\n async function init() {\n try {\n const result = await WorkflowService.getData();\n AppState.allFlows = result.flows;\n AppState.categories = result.categories;\n AppState.favorites = WorkflowService.getFavorites();\n renderUI();\n } catch (e) {\n contentEl.innerHTML = '<div class=\"text-gray-400 text-sm\">加载失败,请检查网络</div>';\n }\n }\n\n function matchKeywords(name, filterText) {\n if (!filterText) return true;\n var keywords = filterText.toLowerCase().split(/\\s+/).filter(function(k) { return k.length > 0; });\n if (keywords.length === 0) return true;\n var lowerName = name.toLowerCase();\n return keywords.every(function(k) { return lowerName.includes(k); });\n }\n\n function renderUI(filterText) {\n filterText = filterText || \"\";\n // Disconnect previous IntersectionObserver before rebuilding DOM\n if (_observer) { _observer.disconnect(); _observer = null; }\n sidebarEl.innerHTML = \"\";\n contentEl.innerHTML = \"\";\n\n const isSearching = filterText.length > 0;\n const groups = [];\n\n const favFlows = AppState.allFlows.filter(function(f) {\n return AppState.favorites.includes(f.id) && matchKeywords(f.name, filterText);\n });\n if (favFlows.length > 0) {\n groups.push({ id: 'fav', name: \"我的收藏\", items: favFlows, isFav: true });\n }\n\n AppState.categories.forEach(function(cat) {\n const items = AppState.allFlows.filter(function(f) {\n return f.categoryId === cat._id && matchKeywords(f.name, filterText);\n });\n if (items.length > 0) {\n groups.push({ id: cat._id, name: cat.name, items: items, isFav: false });\n }\n });\n\n const otherItems = AppState.allFlows.filter(function(f) {\n return !AppState.categories.find(function(c) { return c._id === f.categoryId; }) &&\n matchKeywords(f.name, filterText);\n });\n if (otherItems.length > 0) {\n groups.push({ id: 'other', name: \"其他流程\", items: otherItems, isFav: false });\n }\n\n if (groups.length === 0) {\n contentEl.innerHTML = '<div class=\"animate-[fadeUpSpring_0.5s_ease-out] text-center pt-20\"><div class=\"text-gray-200 text-7xl mb-4\">∅</div><div class=\"text-gray-400 text-sm\">未找到匹配流程</div></div>';\n return;\n }\n\n _currentGroups = groups;\n const contentFragment = document.createDocumentFragment();\n const navBase = \"group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-[14px] select-none\";\n const activeClass = \"bg-[#007AFF] text-white shadow-sm font-medium\";\n const inactiveClass = \"text-gray-700 hover:bg-black/5 active:bg-black/10\";\n\n groups.forEach(function(group, index) {\n const groupId = 'group-' + group.id;\n const navItem = document.createElement('div');\n navItem.className = navBase + ' ' + (index === 0 ? activeClass : inactiveClass);\n const badgeClass = index === 0 ? \"text-white/80\" : \"text-gray-400 group-hover:text-gray-500\";\n navItem.innerHTML = '<span class=\"truncate\">' + (group.isFav ? '★ ' : '') + group.name + '</span><span class=\"' + badgeClass + ' text-[12px] font-medium transition-colors\">' + group.items.length + '</span>';\n\n navItem.onclick = (function(grp, gId) {\n return function() {\n Array.from(sidebarEl.children).forEach(function(el) {\n el.className = navBase + ' ' + inactiveClass;\n el.querySelector('span:last-child').className = \"text-gray-400 group-hover:text-gray-500 text-[12px] font-medium transition-colors\";\n });\n navItem.className = navBase + ' ' + activeClass;\n navItem.querySelector('span:last-child').className = \"text-white/80 text-[12px] font-medium transition-colors\";\n const section = document.getElementById(gId);\n if (!section) return;\n const container = document.getElementById('mainContentScroll');\n if (!container) return;\n // Plan A: defer heavy DOM work (fillCards for all groups + raf converge loop)\n // to the next animation frame so the browser first paints the new active\n // className on the clicked left-side category. Without this, hundreds of\n // card inserts happen synchronously inside the click handler and the active\n // state appears stuck on the previous item for several seconds.\n var _runHeavy = function() {\n // Plan B2: batch-render cards across frames with a per-frame time budget so the\n // main thread stays responsive. A render version token aborts any in-flight loop\n // when the user clicks a different category or re-opens the modal. The scroll\n // converge logic from v8 runs in the same loop, re-measuring each frame so the\n // target stays pinned even as before/after groups render and grow scrollHeight.\n window._flowSelectorRenderVersion = (window._flowSelectorRenderVersion || 0) + 1;\n var myVer = window._flowSelectorRenderVersion;\n\n // Fast path: if every group already has its cards rendered, skip the batched\n // render queue and the raf converge loop entirely. This handles every click\n // after the first one, eliminating the perceived lag on repeat clicks.\n var allRendered = true;\n for (var fk = 0; fk < _currentGroups.length; fk++) {\n var fgEl = document.getElementById(\"group-\" + _currentGroups[fk].id);\n if (!fgEl) continue;\n var fphEl = fgEl.querySelector(\".card-placeholder\");\n if (fphEl && !fphEl.dataset.rendered) { allRendered = false; break; }\n }\n if (allRendered) {\n var fastDelta = section.getBoundingClientRect().top - container.getBoundingClientRect().top;\n var fastMax = container.scrollHeight - container.clientHeight;\n var fastTarget = container.scrollTop + (fastDelta - 16);\n if (fastTarget < 0) fastTarget = 0;\n if (fastTarget > fastMax) fastTarget = fastMax;\n container.scrollTop = fastTarget;\n return;\n }\n\n \n if (_observer) {\n try { _observer.disconnect(); } catch (e) {}\n }\n \n // Build render queue: target first (so it has real content before scroll lands on it),\n // then before-groups in document order (these push target down, converge re-aligns),\n // then after-groups in document order (they do not affect target position).\n var targetIdx = -1;\n for (var k = 0; k < _currentGroups.length; k++) {\n if (_currentGroups[k].id === grp.id) { targetIdx = k; break; }\n }\n var queue = [];\n if (targetIdx >= 0) {\n queue.push(_currentGroups[targetIdx]);\n for (var k = 0; k < targetIdx; k++) queue.push(_currentGroups[k]);\n for (var k = targetIdx + 1; k < _currentGroups.length; k++) queue.push(_currentGroups[k]);\n }\n \n // Temporarily force scroll-behavior:auto so our direct scrollTop assignments are not\n // animated by the CSS .scroll-smooth class. Restored when the loop ends.\n var savedScrollBehavior = container.style.scrollBehavior;\n container.style.scrollBehavior = \"auto\";\n var maxFrames = 300; // ~5s ceiling\n var renderBudgetMs = 6; // per-frame DOM mutation budget\n var lastSetScrollTop = -1; // detect user manual scroll mid-loop\n \n var finish = function() {\n container.style.scrollBehavior = savedScrollBehavior;\n };\n \n var step = function() {\n // Abort if a newer click has taken over.\n if (myVer !== window._flowSelectorRenderVersion) { finish(); return; }\n if (maxFrames-- <= 0) { finish(); return; }\n \n // If user manually scrolled (wheel/touch/keyboard), stop fighting them. We keep\n // rendering remaining groups (they are still expected to appear) but stop adjusting\n // scrollTop.\n var userScrolled = (lastSetScrollTop >= 0 && Math.abs(container.scrollTop - lastSetScrollTop) > 4);\n \n // Render groups up to the per-frame budget.\n var nowFn = function() { return (typeof performance !== \"undefined\" && performance.now) ? performance.now() : 0; };\n var hasPerf = (typeof performance !== \"undefined\" && !!performance.now);\n var deadline = hasPerf ? nowFn() + renderBudgetMs : 0;\n while (queue.length > 0 && (!hasPerf || nowFn() < deadline)) {\n var g = queue.shift();\n var gEl = document.getElementById(\"group-\" + g.id);\n if (!gEl) continue;\n var phEl = gEl.querySelector(\".card-placeholder\");\n if (phEl && !phEl.dataset.rendered) {\n fillCards(g, phEl);\n }\n if (!hasPerf) break; // no perf API: 1 per frame fallback\n }\n \n // Adjust scroll toward target unless user took over.\n var doneInRange = false;\n var doneAtMax = false;\n if (!userScrolled) {\n var delta = section.getBoundingClientRect().top - container.getBoundingClientRect().top;\n var maxScroll = container.scrollHeight - container.clientHeight;\n var target = container.scrollTop + (delta - 16);\n if (target < 0) target = 0;\n doneInRange = Math.abs(delta - 16) <= 1;\n doneAtMax = target > maxScroll && container.scrollTop >= maxScroll - 1;\n if (!doneInRange && !doneAtMax) {\n if (target > maxScroll) container.scrollTop = maxScroll;\n else container.scrollTop = target;\n lastSetScrollTop = container.scrollTop;\n }\n }\n \n // Loop end condition: queue drained AND scroll converged (or user took control).\n if (queue.length === 0 && (userScrolled || doneInRange || doneAtMax)) {\n finish();\n return;\n }\n \n if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(step);\n else setTimeout(step, 16);\n };\n \n if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(step);\n else setTimeout(step, 16);\n };\n if (typeof requestAnimationFrame === \"function\") {\n requestAnimationFrame(_runHeavy);\n } else {\n setTimeout(_runHeavy, 0);\n }\n };\n })(group, groupId);\n sidebarEl.appendChild(navItem);\n\n // Optimization 2: render section header + placeholder (cards filled lazily)\n const section = document.createElement('div');\n section.id = groupId;\n section.className = \"mb-10\";\n const headerColor = group.isFav ? 'text-amber-500' : 'text-gray-900';\n section.innerHTML = '<div class=\"sticky top-0 z-20 mb-4 bg-white pb-2 text-xl font-bold tracking-tight text-left border-b border-gray-100 ' + headerColor + '\">' + group.name + '</div>';\n const placeholder = document.createElement('div');\n placeholder.className = 'card-placeholder grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4';\n // Pre-size the placeholder to avoid layout shift while cards aren't rendered yet\n placeholder.style.minHeight = (Math.ceil(group.items.length / 4) * ESTIMATED_CARD_HEIGHT_PX) + 'px';\n section.appendChild(placeholder);\n contentFragment.appendChild(section);\n });\n\n contentEl.appendChild(contentFragment);\n\n // Optimization 2: set up IntersectionObserver to fill cards as groups scroll into view\n const scrollContainer = document.getElementById('mainContentScroll');\n _observer = new IntersectionObserver(function(entries) {\n entries.forEach(function(entry) {\n if (entry.isIntersecting && entry.target.isConnected && !entry.target.dataset.rendered) {\n const ph = entry.target;\n const section = ph.parentElement;\n if (section && section.id) {\n const gId = section.id.replace('group-', '');\n const grp = _currentGroups.find(function(g) { return String(g.id) === gId; });\n if (grp) {\n fillCards(grp, ph);\n if (_observer) _observer.unobserve(ph);\n }\n }\n }\n });\n }, { root: scrollContainer, rootMargin: LAZY_LOAD_MARGIN });\n\n contentEl.querySelectorAll('.card-placeholder').forEach(function(p) {\n _observer.observe(p);\n });\n }\n\n // Optimization 4: Chinese IME compositionstart/compositionend prevents stutter during composition\n let _isComposing = false;\n let _searchTimer = null;\n searchInput.addEventListener('compositionstart', function() { _isComposing = true; });\n searchInput.addEventListener('compositionend', function(e) {\n _isComposing = false;\n clearTimeout(_searchTimer);\n renderUI(e.target.value.trim());\n });\n searchInput.addEventListener('input', function(e) {\n if (_isComposing) return;\n clearTimeout(_searchTimer);\n _searchTimer = setTimeout(function() { renderUI(e.target.value.trim()); }, 300);\n });\n\n init();\n</script>\n\n",
4
4
  "className": "h-full"
5
- }
5
+ }
@@ -113,12 +113,10 @@ const FlowversionAPI = {
113
113
 
114
114
  getStepLabel: function (stepName, stepHandlerName) {
115
115
  // 返回sstepName与stepHandlerName结合的步骤显示名称
116
+ // 注意:必须单行输出,避免 replaceErrorSymbol 把模板字符串的换行替换成 <br/> 撑高节点
116
117
  let nodeStr = "";
117
118
  if (stepName) {
118
- nodeStr = `<div class='graph-node'>
119
- <div class='step-name'>${stepName}</div>
120
- <div class='step-handler-name'>${stepHandlerName}</div>
121
- </div>`;
119
+ nodeStr = `<div class='graph-node'><div class='step-name'>${stepName}</div><div class='step-handler-name'>${stepHandlerName}</div></div>`;
122
120
  // 把特殊字符清空或替换,以避免mermaidAPI出现异常
123
121
  nodeStr = FlowversionAPI.replaceErrorSymbol(nodeStr);
124
122
  }
@@ -294,12 +292,10 @@ const FlowversionAPI = {
294
292
 
295
293
  getTraceName: function (traceName, approveHandlerName) {
296
294
  // 返回trace节点名称
295
+ // 注意:必须单行输出,避免 replaceErrorSymbol 把模板字符串的换行替换成 <br/> 撑高节点
297
296
  let nodeStr = "";
298
297
  if (traceName) {
299
- nodeStr = `<div class='graph-node'>
300
- <div class='trace-name'>${traceName}</div>
301
- <div class='trace-handler-name'>${approveHandlerName}</div>
302
- </div>`;
298
+ nodeStr = `<div class='graph-node'><div class='trace-name'>${traceName}</div><div class='trace-handler-name'>${approveHandlerName}</div></div>`;
303
299
  nodeStr = FlowversionAPI.replaceErrorSymbol(nodeStr);
304
300
  }
305
301
  return nodeStr;
@@ -871,6 +867,12 @@ const FlowversionAPI = {
871
867
  .graph-node {
872
868
  padding: 4px 8px;
873
869
  }
870
+ .node foreignObject > div {
871
+ height: 100% !important;
872
+ display: flex !important;
873
+ align-items: center;
874
+ justify-content: center;
875
+ }
874
876
  .graph-node .step-name {
875
877
  font-weight: 500;
876
878
  margin-bottom: 2px;
@@ -113,7 +113,8 @@ router.get('/api/workflow/export/instances', requireAuthentication, async functi
113
113
  const str = fs.readFileSync(path.resolve(__dirname, '../server/ejs/export_instances.ejs'), 'utf8');
114
114
  const template = ejs.compile(str);
115
115
  let lang = 'en';
116
- if (userSession.locale === 'zh-cn') {
116
+ const locale = String(userSession.locale || userSession.language || '').toLowerCase().replace('_', '-');
117
+ if (locale.startsWith('zh')) {
117
118
  lang = 'zh-CN';
118
119
  }
119
120
  const utcOffset = timezoneoffset / -60;
@@ -7,6 +7,14 @@
7
7
 
8
8
  <Worksheet ss:Name="<%= form_name %>" >
9
9
  <Table>
10
+ <%
11
+ var getFieldDisplayName = function(field) {
12
+ if (!field) {
13
+ return '';
14
+ }
15
+ return field.label || field.title || field.name || field.code || '';
16
+ };
17
+ %>
10
18
  <!-- 编号 -->
11
19
  <Column ss:Width="35"/>
12
20
  <!-- 名称 -->
@@ -52,13 +60,13 @@
52
60
  <Cell><Data ss:Type="String"><%= t('export.current_step_start_date',{},lang) %></Data></Cell>
53
61
  <% _.each(fields, function(field) { %>
54
62
  <% if (field.type != "table" && field.type != "section") { %>
55
- <Cell><Data ss:Type="String"><%= field.name ? field.name : field.code %></Data></Cell>
63
+ <Cell><Data ss:Type="String"><%= getFieldDisplayName(field) %></Data></Cell>
56
64
  <% } %>
57
65
 
58
66
  <% if (field.type == "section" && field.fields) {
59
67
  _.each(field.fields, function(sec) {
60
68
  %>
61
- <Cell><Data ss:Type="String"><%= sec.name ? sec.name : sec.code %></Data></Cell>
69
+ <Cell><Data ss:Type="String"><%= getFieldDisplayName(sec) %></Data></Cell>
62
70
  <% }) %>
63
71
  <% } %>
64
72
  <% }) %>
@@ -259,7 +267,7 @@
259
267
  </Worksheet>
260
268
 
261
269
  <% _.each(table_fields, function(table_field) { %>
262
- <Worksheet ss:Name="<%= table_field.name ? table_field.name : table_field.code %>" >
270
+ <Worksheet ss:Name="<%= getFieldDisplayName(table_field) %>" >
263
271
  <Table>
264
272
  <!-- 编号 -->
265
273
  <Column ss:Width="35"/>
@@ -296,7 +304,7 @@
296
304
  <Cell><Data ss:Type="String"><%= t('export.current_step_name',{},lang) %></Data></Cell>
297
305
  <Cell><Data ss:Type="String"><%= t('export.current_step_start_date',{},lang) %></Data></Cell>
298
306
  <% _.each(table_field.fields, function(field) { %>
299
- <Cell><Data ss:Type="String"><%= field.name ? field.name : field.code %></Data></Cell>
307
+ <Cell><Data ss:Type="String"><%= getFieldDisplayName(field) %></Data></Cell>
300
308
  <% }) %>
301
309
  </Row>
302
310
  <% _.each(ins_to_xls, function(ins) { %>
@@ -0,0 +1,221 @@
1
+ /**
2
+ * 回归测试:UUFlowManager.getApproveValues 对公式子字段的兜底识别。
3
+ *
4
+ * 背景:
5
+ * - issue: 子表保存后刷新,公式子字段计算结果丢失(线上现象)
6
+ * - PR #781 在过滤规则中加 `c.formula ||` 放权
7
+ * - 但部分历史表单设计器保存时丢失了字段的 `formula` 属性,
8
+ * 公式表达式被转存到 `default_value`,形如 "${...}"
9
+ * - 因此除了 `c.formula` 之外,还需把 `default_value` 形如 "${...}" 的字段视为公式字段
10
+ *
11
+ * 用法:
12
+ * node main/default/test/test_getApproveValues.js
13
+ * 或 yarn workspace @steedos-labs/plugin-workflow test:approve-values
14
+ */
15
+
16
+ const assert = require('assert');
17
+
18
+ // 注意:require uuflow_manager.js 会顺带加载 @steedos/objectql 等运行时依赖;
19
+ // 本机已验证可独立加载,无需启动完整 server。
20
+ const UUFlowManager = require('../manager/uuflow_manager.js');
21
+
22
+ // Monkey-patch DB 读取函数,注入测试用 form schema
23
+ const fakeForms = {};
24
+ UUFlowManager.getForm = async function (form_id) {
25
+ if (!fakeForms[form_id]) throw new Error(`fake form not found: ${form_id}`);
26
+ return fakeForms[form_id];
27
+ };
28
+ UUFlowManager.getFormVersion = function (form, form_version) {
29
+ if (form.current && form.current._id === form_version) return form.current;
30
+ const h = (form.historys || []).find(f => f._id === form_version);
31
+ if (!h) throw new Error(`fake form version not found: ${form_version}`);
32
+ return h;
33
+ };
34
+
35
+ let passed = 0;
36
+ let failed = 0;
37
+
38
+ async function run(name, fn) {
39
+ try {
40
+ await fn();
41
+ console.log(` ✓ ${name}`);
42
+ passed++;
43
+ } catch (e) {
44
+ console.log(` ✗ ${name}`);
45
+ console.log(` ${e.message}`);
46
+ if (e.actual !== undefined) {
47
+ console.log(` actual: ${JSON.stringify(e.actual)}`);
48
+ console.log(` expected: ${JSON.stringify(e.expected)}`);
49
+ }
50
+ failed++;
51
+ }
52
+ }
53
+
54
+ (async () => {
55
+ console.log('UUFlowManager.getApproveValues 公式子字段兜底识别');
56
+
57
+ // 通用:父表 readonly,前两个子字段 editable,第三个(公式)无显式权限
58
+ const permissions = {
59
+ '表格': 'readable',
60
+ '加油量': 'editable',
61
+ '单价': 'editable',
62
+ '金额': 'readable',
63
+ };
64
+
65
+ const submittedRow = {
66
+ _id: 'row1',
67
+ '加油量': '10',
68
+ '单价': '8',
69
+ '金额': '80', // 客户端计算出的公式值
70
+ };
71
+
72
+ await run('公式子字段带 formula 属性(本地新版 schema)→ 保留金额', async () => {
73
+ fakeForms['F1'] = {
74
+ current: {
75
+ _id: 'V1',
76
+ fields: [{
77
+ type: 'table',
78
+ code: '表格',
79
+ fields: [
80
+ { type: 'number', code: '加油量' },
81
+ { type: 'number', code: '单价' },
82
+ { type: 'number', code: '金额', formula: '{加油量}*{单价}' },
83
+ ],
84
+ }],
85
+ },
86
+ };
87
+ const approve_values = { '表格': [Object.assign({}, submittedRow)] };
88
+ const out = await UUFlowManager.getApproveValues(approve_values, permissions, 'F1', 'V1');
89
+ assert.deepStrictEqual(out['表格'][0], submittedRow);
90
+ });
91
+
92
+ await run('公式子字段无 formula、default_value 为 ${...}(线上老 schema)→ 保留金额', async () => {
93
+ fakeForms['F2'] = {
94
+ current: {
95
+ _id: 'V2',
96
+ fields: [{
97
+ type: 'table',
98
+ code: '表格',
99
+ fields: [
100
+ { type: 'number', code: '加油量', default_value: '0.00' },
101
+ { type: 'number', code: '单价', default_value: '0.00' },
102
+ { type: 'number', code: '金额', default_value: '${加油量*单价}' },
103
+ ],
104
+ }],
105
+ },
106
+ };
107
+ const approve_values = { '表格': [Object.assign({}, submittedRow)] };
108
+ const out = await UUFlowManager.getApproveValues(approve_values, permissions, 'F2', 'V2');
109
+ assert.deepStrictEqual(out['表格'][0], submittedRow,
110
+ '老 schema 公式子字段应被识别保留,否则保存后刷新公式值会丢失');
111
+ });
112
+
113
+ await run('普通子字段 default_value 是纯字符串 → 不当作公式,无 editable 权限则删除', async () => {
114
+ fakeForms['F3'] = {
115
+ current: {
116
+ _id: 'V3',
117
+ fields: [{
118
+ type: 'table',
119
+ code: '表格',
120
+ fields: [
121
+ { type: 'text', code: '加油量' },
122
+ { type: 'text', code: '单价' },
123
+ { type: 'text', code: '备注', default_value: '默认备注' },
124
+ ],
125
+ }],
126
+ },
127
+ };
128
+ const perms = { '表格': 'readable', '加油量': 'editable', '单价': 'editable', '备注': 'readable' };
129
+ const row = { _id: 'r', '加油量': '1', '单价': '2', '备注': '客户端写入' };
130
+ const approve_values = { '表格': [Object.assign({}, row)] };
131
+ const out = await UUFlowManager.getApproveValues(approve_values, perms, 'F3', 'V3');
132
+ assert.strictEqual(out['表格'][0]['备注'], undefined, '普通字段不应被当作公式保留');
133
+ assert.strictEqual(out['表格'][0]['加油量'], '1');
134
+ });
135
+
136
+ await run('default_value 形如 "${"... 但未完整闭合 → 不当作公式', async () => {
137
+ fakeForms['F4'] = {
138
+ current: {
139
+ _id: 'V4',
140
+ fields: [{
141
+ type: 'table',
142
+ code: '表格',
143
+ fields: [
144
+ { type: 'text', code: '加油量' },
145
+ { type: 'text', code: '怪字段', default_value: '${unclosed' },
146
+ ],
147
+ }],
148
+ },
149
+ };
150
+ const perms = { '表格': 'readable', '加油量': 'editable', '怪字段': 'readable' };
151
+ const row = { _id: 'r', '加油量': '1', '怪字段': 'x' };
152
+ const approve_values = { '表格': [Object.assign({}, row)] };
153
+ const out = await UUFlowManager.getApproveValues(approve_values, perms, 'F4', 'V4');
154
+ assert.strictEqual(out['表格'][0]['怪字段'], undefined);
155
+ });
156
+
157
+ await run('顶层公式字段无 formula、default_value=${...} → 保留', async () => {
158
+ fakeForms['F5'] = {
159
+ current: {
160
+ _id: 'V5',
161
+ fields: [
162
+ { type: 'number', code: '数量' },
163
+ { type: 'number', code: '小计', default_value: '${数量*10}' },
164
+ ],
165
+ },
166
+ };
167
+ const perms = { '数量': 'editable', '小计': 'readable' };
168
+ const approve_values = { '数量': '5', '小计': '50' };
169
+ const out = await UUFlowManager.getApproveValues(approve_values, perms, 'F5', 'V5');
170
+ assert.strictEqual(out['小计'], '50', '顶层公式字段应同样被兜底识别');
171
+ });
172
+
173
+ await run('section 内公式子字段无 formula、default_value=${...} → 保留', async () => {
174
+ fakeForms['F6'] = {
175
+ current: {
176
+ _id: 'V6',
177
+ fields: [{
178
+ type: 'section',
179
+ code: 'section1',
180
+ fields: [
181
+ { type: 'number', code: '数量' },
182
+ { type: 'number', code: '小计', default_value: '${数量*10}' },
183
+ ],
184
+ }],
185
+ },
186
+ };
187
+ const perms = { '数量': 'editable', '小计': 'readable' };
188
+ const approve_values = { '数量': '5', '小计': '50' };
189
+ const out = await UUFlowManager.getApproveValues(approve_values, perms, 'F6', 'V6');
190
+ assert.strictEqual(out['小计'], '50', 'section 内公式字段应同样被兜底识别');
191
+ });
192
+
193
+ await run('permissions 为 null → 直接返回 {}', async () => {
194
+ const out = await UUFlowManager.getApproveValues({ x: 1 }, null, 'F1', 'V1');
195
+ assert.deepStrictEqual(out, {});
196
+ });
197
+
198
+ await run('historys 版本中的老 schema 同样生效', async () => {
199
+ fakeForms['F7'] = {
200
+ current: { _id: 'V7_NEW', fields: [] },
201
+ historys: [{
202
+ _id: 'V7_OLD',
203
+ fields: [{
204
+ type: 'table',
205
+ code: '表格',
206
+ fields: [
207
+ { type: 'number', code: '加油量', default_value: '0.00' },
208
+ { type: 'number', code: '单价', default_value: '0.00' },
209
+ { type: 'number', code: '金额', default_value: '${加油量*单价}' },
210
+ ],
211
+ }],
212
+ }],
213
+ };
214
+ const approve_values = { '表格': [Object.assign({}, submittedRow)] };
215
+ const out = await UUFlowManager.getApproveValues(approve_values, permissions, 'F7', 'V7_OLD');
216
+ assert.deepStrictEqual(out['表格'][0], submittedRow);
217
+ });
218
+
219
+ console.log(`\n${passed} passed, ${failed} failed`);
220
+ process.exit(failed === 0 ? 0 : 1);
221
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steedos-labs/plugin-workflow",
3
- "version": "3.0.84",
3
+ "version": "3.0.85",
4
4
  "main": "package.service.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -23,7 +23,8 @@
23
23
  "release": "npm run build:designer && npm run stamp-version && npm publish --registry https://registry.npmjs.org && npx cnpm sync @steedos-labs/plugin-workflow",
24
24
  "export-templates": "node run.js export",
25
25
  "convert-templates": "node convert-templates.js",
26
- "test:formula-compat": "node main/default/test/test_formula_compat.js"
26
+ "test:formula-compat": "node main/default/test/test_formula_compat.js",
27
+ "test:approve-values": "node main/default/test/test_getApproveValues.js"
27
28
  },
28
29
  "dependencies": {
29
30
  "graphql-parse-resolve-info": "^4.12.3",