@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.
- package/designer/dist/amis-renderer/amis-renderer.css +1 -1
- package/designer/dist/amis-renderer/amis-renderer.js +1 -1
- package/designer/dist/assets/index-CUTUedfe.js +1006 -0
- package/designer/dist/index.html +1 -1
- package/main/default/client/flow2_render.client.js +1 -1
- package/main/default/manager/instance_number_rules.js +246 -2
- package/main/default/manager/uuflow_manager.js +85 -10
- package/main/default/objects/instances/listviews/monitor.listview.yml +1 -1
- package/main/default/pages/flow_selector.page.amis.json +2 -2
- package/main/default/routes/api_auto_number.router.js +11 -178
- package/main/default/routes/api_workflow_chart.router.js +26 -11
- package/main/default/routes/api_workflow_export.router.js +2 -1
- package/main/default/routes/api_workflow_instance_forward.router.js +13 -1
- package/main/default/routes/buildFormDesignSystemPrompt.js +4 -3
- package/main/default/server/ejs/export_instances.ejs +12 -4
- package/main/default/test/test_getApproveValues.js +221 -0
- package/package.json +3 -2
- package/public/amis-renderer/amis-renderer.css +1 -1
- package/public/amis-renderer/amis-renderer.js +1 -1
- package/designer/dist/assets/index-DpFVuLlI.js +0 -1004
package/designer/dist/index.html
CHANGED
|
@@ -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-
|
|
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>
|
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
1417
|
-
const editableChildCodes = new Set(childFields.filter(c => c
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4700
|
-
|
|
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;
|
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');\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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');\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
|
+
}
|