@steedos-labs/plugin-workflow 3.0.84 → 3.0.86

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.
@@ -5,7 +5,7 @@
5
5
  <link rel="shortcut icon" type="image/svg+xml" href="/images/logo.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>designer</title>
8
- <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-DpFVuLlI.js"></script>
8
+ <script type="module" crossorigin src="/api/workflow/designer-v2/assets/index-CUTUedfe.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/api/workflow/designer-v2/assets/index-B56yvQDB.css">
10
10
  </head>
11
11
  <body>
@@ -1,5 +1,5 @@
1
1
  waitForThing(window, 'antd').then(function(){
2
- var v = '3.0.84';
2
+ var v = '3.0.86';
3
3
  loadJs('/amis-renderer/amis-renderer.js?v=' + v);
4
4
  loadCss('/amis-renderer/amis-renderer.css?v=' + v)
5
5
  })
@@ -8,8 +8,245 @@
8
8
  var _eval;
9
9
 
10
10
  _eval = require('eval');
11
- const { getCollection } = require('../utils/collection');
11
+ const { getCollection, _makeNewID } = require('../utils/collection');
12
12
  const _ = require('lodash');
13
+
14
+ const escapeRegExp = (str) => String(str || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+
16
+ const getFieldKey = (field) => field?.code || field?.name;
17
+
18
+ const isAutoNumberTypeField = (field) => {
19
+ const fieldType = (field?.type || '').toString().toLowerCase();
20
+ return fieldType === 'autonumber' || field?.type === 'autoNumber';
21
+ };
22
+
23
+ const extractAutoNumberName = (formula) => {
24
+ if (typeof formula !== 'string') return null;
25
+ const match = formula.match(/auto_number\s*\(\s*['"]?([^'"()]+?)['"]?\s*\)/);
26
+ return match ? match[1].trim() : null;
27
+ };
28
+
29
+ const getAutoNumberRuleName = (field, options = {}) => {
30
+ if (field?.default_value && typeof field.default_value === 'string') {
31
+ const byFormula = extractAutoNumberName(field.default_value);
32
+ if (byFormula) return byFormula;
33
+ }
34
+
35
+ const fromProps =
36
+ field?.auto_number_name ||
37
+ field?.autoNumberName ||
38
+ field?.auto_number_rule ||
39
+ field?.autoNumberRule ||
40
+ field?.auto_number_rule_name ||
41
+ field?.autoNumberRuleName;
42
+ if (fromProps && typeof fromProps === 'string') {
43
+ return fromProps.trim();
44
+ }
45
+
46
+ const formName = options.formName || options.formId || 'form';
47
+ const fieldKey = getFieldKey(field);
48
+ const fieldLabel = field?.label || fieldKey;
49
+ return `${formName} / ${fieldLabel} (${fieldKey})`;
50
+ };
51
+
52
+ const getAutoNumberRuleId = (spaceId, field, options = {}) => {
53
+ const fromProps =
54
+ field?.auto_number_id ||
55
+ field?.autoNumberId ||
56
+ field?.auto_number_rule_id ||
57
+ field?.autoNumberRuleId;
58
+ if (fromProps && typeof fromProps === 'string') {
59
+ return fromProps.trim();
60
+ }
61
+ };
62
+
63
+ const padNumber = (num, length) => {
64
+ if (length <= 0) return String(num);
65
+ const str = String(num);
66
+ return str.length >= length ? str : '0'.repeat(length - str.length) + str;
67
+ };
68
+
69
+ const formatDatePart = (dateFormat, yyyy, mm, dd) => {
70
+ const mmStr = mm < 10 ? '0' + mm : String(mm);
71
+ const ddStr = dd < 10 ? '0' + dd : String(dd);
72
+ switch (dateFormat) {
73
+ case 'YYYY': return String(yyyy);
74
+ case 'YYYYMM': return String(yyyy) + mmStr;
75
+ case 'YYYYMMDD': return String(yyyy) + mmStr + ddStr;
76
+ default: return '';
77
+ }
78
+ };
79
+
80
+ const shouldResetNumber = (resetCycle, storedRule, yyyy, mm, dd) => {
81
+ switch (resetCycle) {
82
+ case 'yearly': return yyyy !== storedRule.year;
83
+ case 'monthly': return yyyy !== storedRule.year || mm !== storedRule.month;
84
+ case 'daily': return yyyy !== storedRule.year || mm !== storedRule.month || dd !== storedRule.day;
85
+ default: return false;
86
+ }
87
+ };
88
+
89
+ const buildAutoNumberValue = (fieldConfig, number, date = new Date()) => {
90
+ const prefix = fieldConfig.autoNumberPrefix || '';
91
+ const suffix = fieldConfig.autoNumberSuffix || '';
92
+ const paddingLen = fieldConfig.autoNumberPadding != null ? fieldConfig.autoNumberPadding : 4;
93
+ const dateFormat = fieldConfig.autoNumberDateFormat || 'YYYYMM';
94
+ const yyyy = date.getFullYear();
95
+ const mm = date.getMonth() + 1;
96
+ const dd = date.getDate();
97
+ const datePart = formatDatePart(dateFormat, yyyy, mm, dd);
98
+ const paddedNumber = padNumber(number, paddingLen);
99
+
100
+ return datePart ? `${prefix}${datePart}-${paddedNumber}${suffix}` : `${prefix}${paddedNumber}${suffix}`;
101
+ };
102
+
103
+ const isAutoNumberPreviewValue = (value, fieldConfig) => {
104
+ if (typeof value !== 'string' || !value) return false;
105
+
106
+ const prefix = escapeRegExp(fieldConfig.autoNumberPrefix || '');
107
+ const suffix = escapeRegExp(fieldConfig.autoNumberSuffix || '');
108
+ const paddingLen = fieldConfig.autoNumberPadding != null ? fieldConfig.autoNumberPadding : 4;
109
+ const firstSeq = escapeRegExp(padNumber(1, paddingLen));
110
+ const dateFormat = fieldConfig.autoNumberDateFormat || 'YYYYMM';
111
+ let datePattern = '';
112
+
113
+ if (dateFormat === 'YYYY') datePattern = '\\d{4}';
114
+ if (dateFormat === 'YYYYMM') datePattern = '\\d{6}';
115
+ if (dateFormat === 'YYYYMMDD') datePattern = '\\d{8}';
116
+
117
+ const pattern = datePattern
118
+ ? `^${prefix}${datePattern}-${firstSeq}${suffix}$`
119
+ : `^${prefix}${firstSeq}${suffix}$`;
120
+
121
+ return new RegExp(pattern).test(value);
122
+ };
123
+
124
+ const isAutoNumberField = (field) => {
125
+ if (!field) return false;
126
+ const hasLegacyAutoNumberFormula = field.default_value && typeof field.default_value === 'string' && field.default_value.trim().startsWith('auto_number(');
127
+ return hasLegacyAutoNumberFormula || isAutoNumberTypeField(field);
128
+ };
129
+
130
+ const collectAutoNumberFields = (fields, permissions = {}, options = {}) => {
131
+ const autoNumberFields = [];
132
+ const pushedFieldKeys = new Set();
133
+
134
+ const canGenerateAutoNumber = (field) => {
135
+ const fieldKey = getFieldKey(field);
136
+ if (!fieldKey) return false;
137
+ return permissions[fieldKey] === 'editable';
138
+ };
139
+
140
+ const tryPushAutoNumberField = (field) => {
141
+ if (!isAutoNumberField(field) || !canGenerateAutoNumber(field)) {
142
+ return;
143
+ }
144
+ const fieldKey = getFieldKey(field);
145
+ const autoNumberName = getAutoNumberRuleName(field, options);
146
+ if (!fieldKey || !autoNumberName || pushedFieldKeys.has(fieldKey)) {
147
+ return;
148
+ }
149
+ pushedFieldKeys.add(fieldKey);
150
+
151
+ const fieldInfo = {
152
+ code: fieldKey,
153
+ auto_number_name: autoNumberName,
154
+ legacy_auto_number_name: fieldKey,
155
+ };
156
+
157
+ if (isAutoNumberTypeField(field)) {
158
+ fieldInfo.isNewType = true;
159
+ const autoNumberId = getAutoNumberRuleId(options.spaceId, field, options);
160
+ if (autoNumberId) {
161
+ fieldInfo.auto_number_id = autoNumberId;
162
+ }
163
+ fieldInfo.autoNumberPrefix = field.autoNumberPrefix;
164
+ fieldInfo.autoNumberSuffix = field.autoNumberSuffix;
165
+ fieldInfo.autoNumberPadding = field.autoNumberPadding;
166
+ fieldInfo.autoNumberDateFormat = field.autoNumberDateFormat;
167
+ fieldInfo.autoNumberResetCycle = field.autoNumberResetCycle;
168
+ }
169
+
170
+ autoNumberFields.push(fieldInfo);
171
+ };
172
+
173
+ for (const field of fields || []) {
174
+ if (field.type === 'section') {
175
+ for (const sectionField of field.fields || []) {
176
+ tryPushAutoNumberField(sectionField);
177
+ }
178
+ } else {
179
+ tryPushAutoNumberField(field);
180
+ }
181
+ }
182
+
183
+ return autoNumberFields;
184
+ };
185
+
186
+ const generateAutoNumberFromFieldConfig = async (spaceId, fieldConfig) => {
187
+ const db = await getCollection('instance_number_rules');
188
+ const ruleId = fieldConfig.auto_number_id;
189
+ const ruleName = fieldConfig.auto_number_name;
190
+ const resetCycle = fieldConfig.autoNumberResetCycle || 'monthly';
191
+ const now = new Date();
192
+ const yyyy = now.getFullYear();
193
+ const mm = now.getMonth() + 1;
194
+ const dd = now.getDate();
195
+
196
+ let numberRules = ruleId
197
+ ? await db.findOne({ _id: ruleId })
198
+ : await db.findOne({ space: spaceId, name: ruleName });
199
+
200
+ if (!numberRules) {
201
+ const legacyRuleName = fieldConfig.legacy_auto_number_name;
202
+ const legacyRule = legacyRuleName && legacyRuleName !== ruleName
203
+ ? await db.findOne({ space: spaceId, name: legacyRuleName })
204
+ : null;
205
+ await db.insertOne({
206
+ _id: ruleId || _makeNewID(),
207
+ space: spaceId,
208
+ name: ruleName,
209
+ year: legacyRule?.year || yyyy,
210
+ month: legacyRule?.month || mm,
211
+ day: legacyRule?.day || dd,
212
+ first_number: legacyRule?.first_number || 1,
213
+ number: legacyRule?.number || 0,
214
+ rules: ''
215
+ });
216
+ numberRules = ruleId
217
+ ? await db.findOne({ _id: ruleId })
218
+ : await db.findOne({ space: spaceId, name: ruleName });
219
+ }
220
+
221
+ let nextNumber = (numberRules.number || 0) + 1;
222
+ if (shouldResetNumber(resetCycle, numberRules, yyyy, mm, dd)) {
223
+ nextNumber = numberRules.first_number || 1;
224
+ }
225
+
226
+ const autoNumber = buildAutoNumberValue(fieldConfig, nextNumber, now);
227
+
228
+ await db.updateOne(
229
+ { _id: numberRules._id },
230
+ { $set: { name: ruleName, year: yyyy, month: mm, day: dd, number: nextNumber } }
231
+ );
232
+
233
+ return autoNumber;
234
+ };
235
+
236
+ const fillAutoNumberValues = async ({ spaceId, values, fields, permissions, formId, formName }) => {
237
+ const nextValues = values || {};
238
+ const autoNumberFields = collectAutoNumberFields(fields, permissions, { spaceId, formId, formName });
239
+
240
+ for (const field of autoNumberFields) {
241
+ if (!field.isNewType) continue;
242
+ const value = nextValues[field.code];
243
+ if (value && !isAutoNumberPreviewValue(value, field)) continue;
244
+ nextValues[field.code] = await generateAutoNumberFromFieldConfig(spaceId, field);
245
+ }
246
+
247
+ return nextValues;
248
+ };
249
+
13
250
  module.exports = {
14
251
  instanceNumberBuilder: async function (spaceId, name) {
15
252
  var _NUMBER, _YYYY, context, date, e, numberRules, padding, res, rules, script;
@@ -80,5 +317,12 @@ module.exports = {
80
317
  };
81
318
  }
82
319
  return res;
83
- }
320
+ },
321
+ collectAutoNumberFields,
322
+ fillAutoNumberValues,
323
+ generateAutoNumberFromFieldConfig,
324
+ getAutoNumberRuleName,
325
+ getFieldKey,
326
+ isAutoNumberPreviewValue,
327
+ isAutoNumberTypeField
84
328
  };
@@ -13,6 +13,7 @@ const WorkflowManager = require('./workflow_manager');
13
13
  const { getCollection, _makeNewID } = require('../utils/collection');
14
14
  const WorkingTime = require('../utils/workingTime');
15
15
  const pushManager = require('./push_manager');
16
+ const autoNumberManager = require('./instance_number_rules');
16
17
  const { t } = require('@steedos/i18n')
17
18
  const moment = require('moment')
18
19
  const { getObject } = require('@steedos/objectql');
@@ -1398,23 +1399,54 @@ UUFlowManager.getApproveValues = async function (approve_values, permissions, fo
1398
1399
  const form = await UUFlowManager.getForm(form_id);
1399
1400
  const form_v = await UUFlowManager.getFormVersion(form, form_version);
1400
1401
 
1402
+ // 判断字段是否为公式字段。
1403
+ // 历史表单设计器保存时存在丢失 `formula` 属性的情况,此时公式表达式会被转存到
1404
+ // `default_value` 中,形如 "${...}"。这里同时兜底识别该形态,避免老数据下公式
1405
+ // 子字段被当成普通无权限字段误过滤,保存后刷新计算结果丢失。
1406
+ const isFormulaField = (f) => {
1407
+ if (!f) return false;
1408
+ if (f.formula) return true;
1409
+ const dv = f.default_value;
1410
+ return typeof dv === 'string' && /^\$\{[\s\S]+\}$/.test(dv);
1411
+ };
1412
+
1413
+ // 开启 STEEDOS_DEBUG 时,打印 getApproveValues 关键参数,便于定位线上
1414
+ // 「公式子字段保存后刷新丢值」类问题,运维无需临时改源码。
1415
+ const debugApproveValues = !!process.env.STEEDOS_DEBUG;
1416
+ if (debugApproveValues) {
1417
+ console.log('[getApproveValues] form_id=%s form_version=%s permissions=%j', form_id, form_version, permissions);
1418
+ }
1419
+
1401
1420
  // Filter fields based on permissions
1402
1421
  for (const field of form_v.fields || []) {
1403
1422
  if (field.type === "section") {
1404
1423
  // Handle section fields
1405
1424
  for (const sectionField of field.fields || []) {
1406
- if (!sectionField.formula && (permissions[sectionField.code] === null || permissions[sectionField.code] !== "editable")) {
1425
+ if (!isFormulaField(sectionField) && (permissions[sectionField.code] === null || permissions[sectionField.code] !== "editable")) {
1407
1426
  delete approve_values[sectionField.code];
1408
1427
  }
1409
1428
  }
1410
- } else if (field.type === "table" && !field.formula && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1429
+ } else if (field.type === "table" && !isFormulaField(field) && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1411
1430
  // 子表字段处理:子表本身无编辑权限时,检查是否有子字段具有编辑权限
1412
1431
  const childFields = field.fields || [];
1413
1432
  const hasEditableChild = childFields.some(c => permissions[c.code] === "editable");
1433
+ if (debugApproveValues) {
1434
+ // 只摘取关键属性,避免 _amisField 等运行时副本属性刷屏
1435
+ const childSchemaSummary = childFields.map(c => ({
1436
+ code: c.code,
1437
+ type: c.type,
1438
+ formula: c.formula,
1439
+ default_value: c.default_value,
1440
+ isFormula: isFormulaField(c),
1441
+ permission: permissions[c.code]
1442
+ }));
1443
+ console.log('[getApproveValues] table field "%s" hasEditableChild=%s childFieldsSummary=%j approve_values_before=%j',
1444
+ field.code, hasEditableChild, childSchemaSummary, approve_values[field.code]);
1445
+ }
1414
1446
  if (hasEditableChild && Array.isArray(approve_values[field.code])) {
1415
1447
  // 保留可编辑子字段及公式子字段。公式字段为计算值(非用户输入),其结果依赖其他字段,
1416
- // 必须随依赖字段一并持久化,否则保存后刷新会回退。与第1432行顶层字段 !field.formula 保护逻辑对称一致。
1417
- const editableChildCodes = new Set(childFields.filter(c => c.formula || permissions[c.code] === "editable").map(c => c.code));
1448
+ // 必须随依赖字段一并持久化,否则保存后刷新会回退。与下方顶层字段 !isFormulaField(field) 保护逻辑对称一致。
1449
+ const editableChildCodes = new Set(childFields.filter(c => isFormulaField(c) || permissions[c.code] === "editable").map(c => c.code));
1418
1450
  editableChildCodes.add('_id');
1419
1451
  approve_values[field.code] = approve_values[field.code].map(row => {
1420
1452
  const filtered = {};
@@ -1425,11 +1457,18 @@ UUFlowManager.getApproveValues = async function (approve_values, permissions, fo
1425
1457
  }
1426
1458
  return filtered;
1427
1459
  });
1460
+ if (debugApproveValues) {
1461
+ console.log('[getApproveValues] table field "%s" kept child codes=%j approve_values_after=%j',
1462
+ field.code, Array.from(editableChildCodes), approve_values[field.code]);
1463
+ }
1428
1464
  } else {
1429
1465
  // 无可编辑子字段 → 整表删除
1430
1466
  delete approve_values[field.code];
1467
+ if (debugApproveValues) {
1468
+ console.log('[getApproveValues] table field "%s" deleted (no editable child)', field.code);
1469
+ }
1431
1470
  }
1432
- } else if (!field.formula && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1471
+ } else if (!isFormulaField(field) && (permissions[field.code] === null || permissions[field.code] !== "editable")) {
1433
1472
  // 普通字段:无编辑权限且非公式字段则删除
1434
1473
  delete approve_values[field.code];
1435
1474
  }
@@ -1530,9 +1569,20 @@ UUFlowManager.workflow_engine = async function (approve_from_client, current_use
1530
1569
  setObj[`${key_str}read_date`] = new Date();
1531
1570
  }
1532
1571
 
1572
+ const form = await UUFlowManager.getForm(instance.form);
1573
+ const form_v = await UUFlowManager.getFormVersion(form, instance.form_version);
1574
+ const valuesWithAutoNumber = await autoNumberManager.fillAutoNumberValues({
1575
+ spaceId: space_id,
1576
+ values,
1577
+ fields: form_v.fields || [],
1578
+ permissions: step.permissions,
1579
+ formId: instance.form,
1580
+ formName: form.name
1581
+ });
1582
+
1533
1583
  // Filter approve values based on permissions
1534
1584
  setObj[`${key_str}values`] = await UUFlowManager.getApproveValues(
1535
- values,
1585
+ valuesWithAutoNumber,
1536
1586
  step.permissions,
1537
1587
  instance.form,
1538
1588
  instance.form_version
@@ -3476,6 +3526,7 @@ UUFlowManager.submit_instance = async function (instance_from_client, user_info)
3476
3526
  // 先执行暂存的操作
3477
3527
  // ================begin================
3478
3528
  const form = await UUFlowManager.getForm(instance.form, { fields: { historys: 0 } });
3529
+ const form_v = await UUFlowManager.getFormVersion(form, instance.form_version);
3479
3530
  // 获取Flow当前版本开始节点
3480
3531
  const start_step = flow.current.steps.find(step => step.step_type === 'start');
3481
3532
 
@@ -3522,6 +3573,15 @@ UUFlowManager.submit_instance = async function (instance_from_client, user_info)
3522
3573
  instance_traces[0].name = start_step.name;
3523
3574
  }
3524
3575
 
3576
+ values = await autoNumberManager.fillAutoNumberValues({
3577
+ spaceId: space_id,
3578
+ values,
3579
+ fields: form_v.fields || [],
3580
+ permissions: step.permissions,
3581
+ formId: instance.form,
3582
+ formName: form.name
3583
+ });
3584
+
3525
3585
  // 调整approves的values删除values中在当前步骤中没有编辑权限的字段值
3526
3586
  instance_traces[0].approves[0].values = await UUFlowManager.getApproveValues(
3527
3587
  values,
@@ -4692,12 +4752,16 @@ UUFlowManager.relocate = async function (instance_from_client, current_user_info
4692
4752
  const space_id = instance.space;
4693
4753
  const instance_id = last_trace.instance;
4694
4754
  const inbox_users = instance.inbox_users || [];
4695
- const relocate_inbox_users = instance_from_client.relocate_inbox_users || [];
4755
+ let relocate_inbox_users = instance_from_client.relocate_inbox_users || [];
4696
4756
  const relocate_comment = instance_from_client.relocate_comment;
4697
4757
  const relocate_next_step = instance_from_client.relocate_next_step;
4698
4758
 
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));
4759
+ if (_.isString(relocate_inbox_users)) {
4760
+ relocate_inbox_users = [relocate_inbox_users];
4761
+ } else if (!Array.isArray(relocate_inbox_users)) {
4762
+ relocate_inbox_users = [];
4763
+ }
4764
+
4701
4765
  const approve_users = [];
4702
4766
 
4703
4767
  const flow = await UUFlowManager.getFlow(instance.flow);
@@ -4705,6 +4769,17 @@ UUFlowManager.relocate = async function (instance_from_client, current_user_info
4705
4769
  const next_step_type = next_step.step_type;
4706
4770
  const next_step_name = next_step.name;
4707
4771
 
4772
+ if (next_step_type === 'start' && relocate_inbox_users.length === 0) {
4773
+ relocate_inbox_users = await HandlersManager.getHandlers(instance_id, relocate_next_step, current_user);
4774
+ }
4775
+
4776
+ if (next_step_type !== 'end' && (!relocate_inbox_users || relocate_inbox_users.length === 0)) {
4777
+ throw new Error('未指定下一步处理人');
4778
+ }
4779
+
4780
+ const not_in_inbox_users = inbox_users.filter(u => !relocate_inbox_users.includes(u));
4781
+ const new_inbox_users = relocate_inbox_users.filter(u => !inbox_users.includes(u));
4782
+
4708
4783
  const current_setp = await UUFlowManager.getStep(instance, flow, last_trace.step);
4709
4784
  const current_setp_type = current_setp.step_type;
4710
4785
 
@@ -5147,4 +5222,4 @@ UUFlowManager.caculateExtras = async function (values = {}, formDoc, formVersion
5147
5222
  return _.isEmpty(extras) ? undefined : extras;
5148
5223
  };
5149
5224
 
5150
- module.exports = UUFlowManager;
5225
+ module.exports = UUFlowManager;
@@ -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
+ }