openyida 2026.10.22 → 2026.10.23

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/README.md CHANGED
@@ -516,6 +516,7 @@ When preparing a PR that needs a personal Fork, or when you ask how to support t
516
516
  | `openyida print-template <list\|inspect\|fields\|build\|create\|copy\|save\|restore\|settings\|status\|order\|render\|files> <appType> <formUuid> [templateId] [--config <file>] [--scene <order\|quotation\|delivery\|receipt\|approval\|codes>] [--bindings <file>] [--name <title>] [--expected-hash <hash>] [--backup <file>] [--out <file>] [--status START\|STOP] [--instance <id>] [--page <n>] [--size <n>] [--allow-permission-change] [--json] \| print-template <list\|render> --detail-url <url> [--template-id <id>] [--json]` | Manage native YiDA print templates, generate document layouts, back up, restore and request PDFs |
517
517
  | `openyida form-view <list\|inspect\|save\|create\|order> <appType> <formUuid> [viewUuid] [--config <file>] [--name <title>] [--with-config] [--expected-hash <hash>] [--backup <file>] [--replace] [--json]` | Native form views: list, inspect and configure columns, widths, frozen columns and filters |
518
518
  | `openyida custom-button <list\|inspect\|options\|create\|update\|delete> <appType> <formUuid> [buttonUuid] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]` | Native custom buttons: inspect and configure form edits, automation flows and page links |
519
+ | `openyida form-message <list\|inspect\|options\|create\|update\|delete> <appType> <formUuid> [messageId] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]` | Native form notifications: inspect triggers, recipients and templates, then safely maintain bindings |
519
520
  | `openyida get-schema <appType> <formUuid\|--all> [--summary-json\|--field-map-json\|--analysis-json]` | Get one form Schema or all form Schemas |
520
521
  | `openyida check-prd-completeness <prd.md> --app-type <appType> [--build-manifest <file>] [--json]` | Check resources, frontend menu hiding and application theme delivery |
521
522
  | `openyida check-design <design.md> [--prd <prd.md>] [--base-dir <dir>] [--json]` | Validate design documents, theme tokens and PRD handoff |
package/bin/yida.js CHANGED
@@ -837,6 +837,11 @@ async function main() {
837
837
  await run(args);
838
838
  break;
839
839
  }
840
+ case 'form-message': {
841
+ const { run } = require('../lib/app/form-message');
842
+ await run(args);
843
+ break;
844
+ }
840
845
  case 'print-template': {
841
846
  const { run } = require('../lib/print-template/print-template');
842
847
  await run(args);
@@ -0,0 +1,352 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { isDeepStrictEqual } = require('util');
7
+ const { CliError } = require('../core/cli-error');
8
+ const { t } = require('../core/i18n');
9
+ const { resolveProjectRoot } = require('../core/utils');
10
+ const { createYidaClient } = require('../core/yida-client');
11
+
12
+ const ACTIONS = ['list', 'inspect', 'options', 'create', 'update', 'delete'];
13
+ const RECEIVER_TYPES = new Set(['employee', 'role', 'formField', 'variable']);
14
+ const FORM_TRIGGERS = new Set(['FORM_DATA_CREATE', 'FORM_DATA_EDIT', 'FORM_DATA_DELETE', 'FORM_COMMENT']);
15
+ const SYSTEM_VARIABLES = new Set(['originator']);
16
+ const MESSAGE_FIELD_COMPONENTS = new Set([
17
+ 'TextareaField', 'EmployeeField', 'SelectField', 'MultiSelectField', 'DateField', 'TextField',
18
+ 'RadioField', 'NumberField', 'CheckboxField', 'RateField', 'CascadeDateField', 'DepartmentSelectField',
19
+ ]);
20
+ const RECIPIENT_FIELD_COMPONENTS = new Set(['EmployeeField']);
21
+ const ID = /^[A-Za-z0-9_-]+$/;
22
+ const TEMPLATES = Object.freeze({
23
+ workNotice: { uuid: 'system_default_work_notice', type: 'workNotice', content: true, target: 'person' },
24
+ detailImage: { uuid: '8fbf8a81-c775-4570-b367-86d4d719a85f', type: 'card', fields: true, target: 'person' },
25
+ imageCard: { uuid: '39caf717-df43-46e1-98f9-564fdd1dc789', type: 'card', content: true, target: 'person' },
26
+ detailText: { uuid: '97bd36d0-64d8-4c50-96e8-f02312655700', type: 'card', fields: true, target: 'person' },
27
+ commentCard: { uuid: 'c298cd88-0de6-44c3-9023-c93e0d7db9ac', type: 'card', content: true, target: 'person' },
28
+ pinnedImage: { uuid: 'cdcdf076-3625-4da7-9a61-571591594209', type: 'ceilingCard', content: true, target: 'group' },
29
+ pinnedText: { uuid: '68240de5-8d9e-416a-b5b2-c71dca0ab703', type: 'ceilingCard', content: true, target: 'group' },
30
+ });
31
+ const TEMPLATE_BY_UUID = new Map(Object.entries(TEMPLATES).map(([key, item]) => [item.uuid, { key, ...item }]));
32
+
33
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ const clone = value => JSON.parse(JSON.stringify(value));
35
+
36
+ /** Build a stable fingerprint for concurrency checks and destructive actions. */
37
+ function hash(value) {
38
+ const stable = item => Array.isArray(item) ? item.map(stable) : object(item)
39
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, stable(item[key])])) : item;
40
+ return crypto.createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
41
+ }
42
+
43
+ function fail(code, key, detail = '', details) {
44
+ throw new CliError(t(`form_message.${key}`, detail), { code: `FORM_MESSAGE_${code}`, details });
45
+ }
46
+
47
+ function safe(value) {
48
+ if (!value || typeof value !== 'object') {return;}
49
+ for (const key of Object.keys(value)) {
50
+ if (['__proto__', 'prototype', 'constructor'].includes(key)) {fail('INVALID_CONFIG', 'invalid_config', key);}
51
+ safe(value[key]);
52
+ }
53
+ }
54
+
55
+ function parseJson(value, label) {
56
+ try { return typeof value === 'string' ? JSON.parse(value) : value; }
57
+ catch (_) { return fail('INVALID_CONFIG', 'invalid_config', label); }
58
+ }
59
+
60
+ function unwrap(response, endpoint) {
61
+ if (!response || response.success !== true || response.__needLogin || response.__csrfExpired || response.content === false) {
62
+ fail('API_ERROR', 'api_error', response?.errorMsg || response?.message || endpoint, {
63
+ endpoint, platformErrorCode: response?.errorCode || null, needLogin: !!response?.__needLogin,
64
+ });
65
+ }
66
+ return response.content;
67
+ }
68
+
69
+ /** Normalize a native binding while retaining fields required for lossless backup and update. */
70
+ function normalize(raw) {
71
+ if (!object(raw) || raw.id === undefined || raw.id === null || !String(raw.id).trim()) {
72
+ fail('INVALID_RESPONSE', 'invalid_response', 'id');
73
+ }
74
+ const cardConfig = raw.cardConfig ? parseJson(raw.cardConfig, 'cardConfig') : {};
75
+ if (!object(cardConfig)) {fail('INVALID_RESPONSE', 'invalid_response', 'cardConfig');}
76
+ const recipients = [];
77
+ const add = (source, type) => String(source || '').split(',').map(value => value.trim()).filter(Boolean)
78
+ .forEach(value => recipients.push({ type, value }));
79
+ add(raw.persons, 'employee'); add(raw.roleId, 'role'); add(raw.formField, 'formField'); add(raw.variable, 'variable');
80
+ const nativeGroups = Array.isArray(cardConfig.groups) ? clone(cardConfig.groups) : [];
81
+ const template = TEMPLATE_BY_UUID.get(cardConfig.templateUuid) || {};
82
+ const buttons = Array.isArray(cardConfig.buttons) ? clone(cardConfig.buttons) : [];
83
+ return {
84
+ id: String(raw.id),
85
+ formUuid: raw.formUuid || null,
86
+ trigger: raw.phase || null,
87
+ messageType: raw.messageType || 'NORMAL',
88
+ recipients,
89
+ groups: nativeGroups,
90
+ template: {
91
+ key: template.key || null,
92
+ uuid: cardConfig.templateUuid || raw.messageUuid || null,
93
+ type: cardConfig.templateType || template.type || null,
94
+ title: cardConfig.cardTitle || '',
95
+ content: cardConfig.mainContent || '',
96
+ fields: Array.isArray(cardConfig.datas) ? clone(cardConfig.datas) : [],
97
+ image: Array.isArray(cardConfig.messageImage) ? clone(cardConfig.messageImage) : [],
98
+ contentType: cardConfig.messageContentType || (raw.messageUuid ? 'TEMPLATE' : 'CUSTOM'),
99
+ platformTemplateUuid: raw.messageUuid && !String(raw.messageUuid).includes('#') ? raw.messageUuid : null,
100
+ button: buttons[0] || null,
101
+ },
102
+ advanced: {
103
+ openMore: !!cardConfig.openMore,
104
+ activityIds: String(raw.activityId || '').split(',').filter(Boolean),
105
+ condition: raw.conditionJson ? parseJson(raw.conditionJson, 'conditionJson') : null,
106
+ processId: raw.processId || null,
107
+ outcome: String(raw.outResult || '').split(',').filter(Boolean),
108
+ currentGroup: raw.curDingGroup === 'y',
109
+ sendWorkNotice: raw.isSendMessage === 'y',
110
+ },
111
+ native: clone(raw),
112
+ };
113
+ }
114
+
115
+ function parseArgs(args) {
116
+ const positional = [];
117
+ const flags = {};
118
+ for (let i = 0; i < args.length; i++) {
119
+ const arg = args[i];
120
+ if (Object.hasOwn(flags, arg)) {fail('USAGE', 'usage');}
121
+ if (['--json', '--yes', '--help', '-h'].includes(arg)) { flags[arg] = true; continue; }
122
+ if (['--config', '--backup', '--expected-hash'].includes(arg)) {
123
+ if (!args[i + 1] || args[i + 1].startsWith('-')) {fail('USAGE', 'usage');}
124
+ flags[arg] = args[++i]; continue;
125
+ }
126
+ if (arg.startsWith('-')) {fail('USAGE', 'usage');}
127
+ positional.push(arg);
128
+ }
129
+ if (flags['--help'] || flags['-h']) {return { help: true };}
130
+ const [action, appType, formUuid, messageId] = positional;
131
+ const targeted = ['inspect', 'update', 'delete'].includes(action);
132
+ if (!ACTIONS.includes(action) || positional.length !== (targeted ? 4 : 3)
133
+ || !ID.test(appType || '') || !ID.test(formUuid || '') || (targeted && !ID.test(messageId || ''))
134
+ || (['create', 'update'].includes(action) && !flags['--config'])
135
+ || (action === 'delete' && (!flags['--yes'] || !flags['--expected-hash']))) {fail('USAGE', 'usage');}
136
+ const allowed = { list: [], inspect: [], options: [], create: ['--config', '--backup'],
137
+ update: ['--config', '--backup', '--expected-hash'], delete: ['--yes', '--backup', '--expected-hash'] };
138
+ if (Object.keys(flags).some(key => key !== '--json' && !allowed[action].includes(key))) {fail('USAGE', 'usage');}
139
+ return { action, appType, formUuid, messageId, flags };
140
+ }
141
+
142
+ function fieldId(field) { return field.fieldId || field.id || field.componentId; }
143
+ function fieldType(field) { return field.componentName || field.fieldType || field.type; }
144
+
145
+ function normalizeTemplate(value) {
146
+ if (!object(value)) {fail('INVALID_CONFIG', 'invalid_config', 'template');}
147
+ const registry = value.key ? TEMPLATES[value.key] : TEMPLATE_BY_UUID.get(value.uuid);
148
+ if (!registry || (value.uuid && value.uuid !== registry.uuid) || (value.type && value.type !== registry.type)) {
149
+ fail('INVALID_CONFIG', 'invalid_config', 'template.key / template.uuid / template.type');
150
+ }
151
+ const result = { ...value, key: value.key || TEMPLATE_BY_UUID.get(registry.uuid).key,
152
+ uuid: registry.uuid, type: registry.type };
153
+ result.title = String(result.title || '').trim();
154
+ result.content = String(result.content || '').trim();
155
+ result.fields = result.fields || [];
156
+ result.image = result.image || [];
157
+ result.contentType = 'CUSTOM';
158
+ result.platformTemplateUuid = null;
159
+ result.button = result.button || null;
160
+ if (!result.title || result.title.replace(/\$!\{\w+\}/g, '').length > 50) {fail('INVALID_CONFIG', 'invalid_config', 'template.title');}
161
+ if (registry.content && !result.content) {fail('INVALID_CONFIG', 'invalid_config', 'template.content');}
162
+ if (registry.fields && (!Array.isArray(result.fields) || !result.fields.length)) {fail('INVALID_CONFIG', 'invalid_config', 'template.fields');}
163
+ if (!Array.isArray(result.fields) || !Array.isArray(result.image)) {fail('INVALID_CONFIG', 'invalid_config', 'template.fields / template.image');}
164
+ if (result.button !== undefined && result.button !== null) {
165
+ if (!object(result.button) || typeof result.button.name !== 'string'
166
+ || !['formInstDetail', 'comment', 'custom'].includes(result.button.type)) {fail('INVALID_CONFIG', 'invalid_config', 'template.button');}
167
+ if (result.button.type === 'custom') {
168
+ let url; try { url = new URL(result.button.value); } catch (_) { fail('INVALID_CONFIG', 'invalid_config', 'template.button.value'); }
169
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) {fail('INVALID_CONFIG', 'invalid_config', 'template.button.value');}
170
+ }
171
+ }
172
+ return { result, registry };
173
+ }
174
+
175
+ /** Validate references against the target form metadata before a mutation. */
176
+ function validateConfig(value, metadata) {
177
+ if (!object(value) || !FORM_TRIGGERS.has(value.trigger)) {
178
+ fail('UNSUPPORTED_TRIGGER', 'unsupported_trigger', value?.trigger || '');
179
+ }
180
+ const formType = metadata.form.formType;
181
+ if (formType === 'process') {fail('UNSUPPORTED_FORM', 'unsupported_form', formType);}
182
+ const { result: template, registry } = normalizeTemplate(value.template);
183
+ const recipients = value.recipients || [];
184
+ const groups = value.groups || [];
185
+ if (!Array.isArray(recipients) || !Array.isArray(groups)) {fail('INVALID_CONFIG', 'invalid_config', 'recipients / groups');}
186
+ if (registry.target === 'person' && !recipients.length) {fail('INVALID_CONFIG', 'invalid_config', 'recipients');}
187
+ if (registry.target === 'group' && !groups.length) {fail('INVALID_CONFIG', 'invalid_config', 'groups');}
188
+ if (recipients.length > 100) {fail('INVALID_CONFIG', 'invalid_config', 'recipients (max 100)');}
189
+ const seen = new Set();
190
+ for (const receiver of recipients) {
191
+ if (!object(receiver) || !RECEIVER_TYPES.has(receiver.type) || !ID.test(String(receiver.value || ''))
192
+ || seen.has(`${receiver.type}:${receiver.value}`)) {fail('INVALID_CONFIG', 'invalid_config', 'recipients');}
193
+ seen.add(`${receiver.type}:${receiver.value}`);
194
+ if (receiver.type === 'variable' && !SYSTEM_VARIABLES.has(receiver.value)) {fail('INVALID_CONFIG', 'invalid_config', `recipient variable: ${receiver.value}`);}
195
+ if (receiver.type === 'formField' && !metadata.recipientFields.some(field => fieldId(field) === receiver.value)) {
196
+ fail('INVALID_CONFIG', 'invalid_config', `recipient field: ${receiver.value}`);
197
+ }
198
+ }
199
+ for (const group of groups) {
200
+ if (!object(group) || !ID.test(String(group.cid || group.id || ''))) {fail('INVALID_CONFIG', 'invalid_config', 'groups');}
201
+ }
202
+ for (const item of template.fields) {
203
+ if (!object(item) || !['variable', 'formField'].includes(item.type) || typeof item.title !== 'string' || !item.title.trim()
204
+ || !ID.test(String(item.value || ''))) {fail('INVALID_CONFIG', 'invalid_config', 'template.fields');}
205
+ if (item.type === 'variable' && !metadata.systemFields.some(field => field.value === item.value)) {
206
+ fail('INVALID_CONFIG', 'invalid_config', `template variable: ${item.value}`);
207
+ }
208
+ if (item.type === 'formField' && !metadata.messageFields.some(field => fieldId(field) === item.value)) {
209
+ fail('INVALID_CONFIG', 'invalid_config', `template field: ${item.value}`);
210
+ }
211
+ }
212
+ return { trigger: value.trigger, recipients: clone(recipients), groups: clone(groups), template };
213
+ }
214
+
215
+ function patchConfig(base, patch) {
216
+ if (!object(patch) || !Object.keys(patch).length || Object.keys(patch).some(key => !['trigger', 'recipients', 'groups', 'template'].includes(key))) {
217
+ fail('INVALID_CONFIG', 'invalid_config', 'trigger, recipients, groups, template');
218
+ }
219
+ safe(patch);
220
+ const next = { ...base, ...patch };
221
+ if (patch.template) {next.template = { ...base.template, ...patch.template };}
222
+ return next;
223
+ }
224
+
225
+ function editable(message) {
226
+ return { trigger: message.trigger, recipients: clone(message.recipients), groups: clone(message.groups), template: clone(message.template) };
227
+ }
228
+
229
+ function serialize(config, formUuid, messageId, native = {}) {
230
+ const byType = type => config.recipients.filter(item => item.type === type).map(item => item.value).join(',');
231
+ const registered = config.template.key ? TEMPLATES[config.template.key] : TEMPLATE_BY_UUID.get(config.template.uuid);
232
+ const nativeCardConfig = native.cardConfig ? parseJson(native.cardConfig, 'cardConfig') : {};
233
+ const cardConfig = {
234
+ ...nativeCardConfig,
235
+ openMore: false,
236
+ groups: config.groups,
237
+ messageImage: config.template.image,
238
+ cardTitle: config.template.title,
239
+ mainContent: config.template.content,
240
+ datas: config.template.fields,
241
+ templateUuid: config.template.uuid || registered.uuid,
242
+ templateType: config.template.type || registered.type,
243
+ messageContentType: 'CUSTOM',
244
+ buttons: config.template.button ? [config.template.button] : [],
245
+ };
246
+ return {
247
+ ...clone(native),
248
+ service: native.service || '', formUuid, phase: config.trigger, outResult: native.outResult || '',
249
+ formField: byType('formField'), studentField: native.studentField || '', roleId: byType('role'),
250
+ persons: byType('employee'), varField: native.varField || '', messageUuid: '', id: messageId || '',
251
+ variable: byType('variable'), email: native.email || '', formFieldEmail: native.formFieldEmail || '',
252
+ activityId: native.activityId || '', messageType: native.messageType || 'NORMAL',
253
+ curDingGroup: native.curDingGroup || 'n', isSendMessage: native.isSendMessage || 'n',
254
+ processId: native.processId || '', conditionJson: native.conditionJson || '', cardConfig: JSON.stringify(cardConfig),
255
+ };
256
+ }
257
+
258
+ async function execute(parsed, client = createYidaClient()) {
259
+ const { action, appType, formUuid, messageId, flags } = parsed;
260
+ const prefix = `/${appType}/query/`;
261
+ const read = async (endpoint, params = {}) => unwrap(await client.get(prefix + endpoint, { formUuid, ...params }), endpoint);
262
+ const list = async () => {
263
+ const rows = await read('formMsgTpl/listFormMessageBind.json');
264
+ if (!Array.isArray(rows)) {fail('INVALID_RESPONSE', 'invalid_response', 'messages');}
265
+ const messages = rows.map(normalize);
266
+ if (messages.some(item => item.formUuid && item.formUuid !== formUuid)
267
+ || new Set(messages.map(item => item.id)).size !== messages.length) {fail('INVALID_RESPONSE', 'invalid_response', 'messages');}
268
+ return messages;
269
+ };
270
+ const metadata = async () => {
271
+ const info = await read('formdesign/getFormAndAppInfo.json', { appType });
272
+ const form = info?.formDatas?.values?.find(item => item.formUuid === formUuid);
273
+ if (!form || !Array.isArray(form.fields)) {fail('INVALID_RESPONSE', 'invalid_response', 'form metadata');}
274
+ const fields = form.fields;
275
+ return {
276
+ form: { formUuid, formType: form.formType, title: form.title, processCode: form.processCode || null },
277
+ triggers: [...FORM_TRIGGERS],
278
+ readOnlyTriggers: ['RECUR_JOB_FIELD', 'INST_START', 'INST_COMPLETE', 'INST_NODE', 'TASK_GENERATE', 'TASK_COMPLETED'],
279
+ templates: Object.entries(TEMPLATES).map(([key, item]) => ({ key, ...item })),
280
+ systemFields: [
281
+ { value: 'formInstId', componentName: 'TextField' }, { value: 'originatorName', componentName: 'TextField' },
282
+ { value: 'originator', componentName: 'TextField' }, { value: 'createTime', componentName: 'DateField' },
283
+ { value: 'modifiedTime', componentName: 'DateField' }, { value: 'serialNo', componentName: 'TextField' },
284
+ { value: 'formTitle', componentName: 'TextField' }, { value: 'title', componentName: 'TextField' },
285
+ ],
286
+ messageFields: fields.filter(field => MESSAGE_FIELD_COMPONENTS.has(fieldType(field))),
287
+ recipientFields: fields.filter(field => RECIPIENT_FIELD_COMPONENTS.has(fieldType(field))),
288
+ recipientVariables: [...SYSTEM_VARIABLES],
289
+ supportsToggle: false,
290
+ };
291
+ };
292
+ // Link to the form's native notification settings in the authenticated environment.
293
+ const configUrl = new URL(`/${appType}/admin/${formUuid}/settings/messageSetting`, client.getAuthRef().baseUrl).href;
294
+ const output = { success: true, action, appType, formUuid, configUrl };
295
+ if (action === 'options') {return { ...output, options: await metadata() };}
296
+ const beforeList = await list();
297
+ const before = messageId ? beforeList.find(item => item.id === messageId) : null;
298
+ if (messageId && !before) {fail('NOT_FOUND', 'not_found', messageId);}
299
+ if (action === 'list') {return { ...output, messages: beforeList, totalCount: beforeList.length, supportsToggle: false };}
300
+ if (action === 'inspect') {return { ...output, messageId, message: before, configHash: hash(editable(before)), supportsToggle: false };}
301
+ if (flags['--expected-hash'] && flags['--expected-hash'] !== hash(editable(before))) {fail('CONFLICT', 'conflict');}
302
+
303
+ let expected;
304
+ if (action !== 'delete') {
305
+ let patch;
306
+ try { patch = parseJson(fs.readFileSync(path.resolve(flags['--config']), 'utf8'), 'config'); }
307
+ catch (error) { if (error instanceof CliError) {throw error;} fail('INVALID_CONFIG', 'invalid_config', 'config file'); }
308
+ const defaults = { trigger: 'FORM_DATA_CREATE', recipients: [{ type: 'variable', value: 'originator' }], groups: [],
309
+ template: { key: 'workNotice', title: '', content: '', fields: [], image: [], button: null } };
310
+ expected = validateConfig(patchConfig(before ? editable(before) : defaults, patch), await metadata());
311
+ if (before && isDeepStrictEqual(editable(before), expected)) {
312
+ return { ...output, messageId, changed: false, message: before, configHash: hash(expected), readbackVerified: true };
313
+ }
314
+ }
315
+ const snapshot = beforeList.map(item => [item.id, hash(item.native)]);
316
+ if (!isDeepStrictEqual(snapshot, (await list()).map(item => [item.id, hash(item.native)]))) {fail('CONFLICT', 'conflict');}
317
+ const backupPath = flags['--backup'] ? path.resolve(flags['--backup']) : path.join(resolveProjectRoot().projectRoot,
318
+ '.cache', 'openyida', 'form-message', `${Date.now()}-${crypto.randomUUID()}.json`);
319
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
320
+ fs.writeFileSync(backupPath, JSON.stringify({ appType, formUuid, action, messages: beforeList }, null, 2), { flag: 'wx', mode: 0o600 });
321
+ const details = { target: { appType, formUuid, ...(messageId ? { messageId } : {}) }, backupPath,
322
+ retrySafe: false, sideEffectState: 'unknown', readbackAllowed: true };
323
+ const endpoint = action === 'delete' ? 'formMsgTpl/removeMessageBind.json' : 'formMsgTpl/saveOrUpdateMessageBind.json';
324
+ const params = action === 'delete' ? { id: messageId } : serialize(expected, formUuid, messageId, before?.native);
325
+ try { unwrap(await client.postFormOnce(prefix + endpoint, params), endpoint); }
326
+ catch (error) { throw new CliError(error.message, { code: error.code || 'FORM_MESSAGE_WRITE_FAILED', details: { ...error.details, ...details } }); }
327
+ try {
328
+ const after = await list();
329
+ if (action === 'delete') {
330
+ if (after.some(item => item.id === messageId)) {fail('READBACK_MISMATCH', 'readback_mismatch');}
331
+ return { ...output, messageId, changed: true, backupPath, readbackVerified: true };
332
+ }
333
+ const added = after.filter(item => !beforeList.some(old => old.id === item.id));
334
+ const saved = action === 'create' ? (added.length === 1 ? added[0] : null) : after.find(item => item.id === messageId);
335
+ if (!saved || !isDeepStrictEqual(editable(saved), expected)) {fail('READBACK_MISMATCH', 'readback_mismatch');}
336
+ return { ...output, messageId: saved.id, changed: true, backupPath, readbackVerified: true,
337
+ message: saved, configHash: hash(expected) };
338
+ } catch (error) {
339
+ throw new CliError(error.message, { code: error.code || 'FORM_MESSAGE_READBACK_FAILED',
340
+ details: { ...details, mutationAccepted: true, sideEffectState: 'accepted_unverified' } });
341
+ }
342
+ }
343
+
344
+ async function run(args, options = {}) {
345
+ const parsed = parseArgs(args);
346
+ if (parsed.help) { process.stdout.write(t('form_message.usage') + '\n'); return; }
347
+ const result = await execute(parsed, options.client);
348
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
349
+ return result;
350
+ }
351
+
352
+ module.exports = { ACTIONS, TEMPLATES, run, execute, parseArgs, normalize, validateConfig, patchConfig, serialize, hash, editable };
@@ -549,6 +549,12 @@ const COMMAND_SIDE_EFFECTS = new Map([
549
549
  read_actions: ['list', 'inspect', 'options'],
550
550
  mutating_actions: ['create', 'update', 'delete'],
551
551
  })],
552
+ ['form-message', sideEffect('mixed', {
553
+ mutates_yida: true,
554
+ mutates_local: true,
555
+ read_actions: ['list', 'inspect', 'options'],
556
+ mutating_actions: ['create', 'update', 'delete'],
557
+ })],
552
558
  ['cool-card', sideEffect('mixed', { mutates_yida: true, mutates_local: true, read_actions: ['list', 'templates', 'get'], mutating_actions: ['create'] })],
553
559
  ['data-card', sideEffect('mixed', { mutates_yida: true, mutates_local: true, read_actions: ['list', 'get', 'probe', 'release-check', 'refs'], mutating_actions: ['create', 'save', 'publish', 'release-get', 'release-save', 'release-test'], note: 'release-get reads broadcast settings but the native backend may initialize the application cool app; classify it conservatively as a possible mutation.' })],
554
560
  ['print-template', sideEffect('mixed', printTemplateEffects)],
@@ -863,6 +869,13 @@ const COMMAND_PERMISSIONS = new Map([
863
869
  ask_actions: ['delete'],
864
870
  })),
865
871
 
872
+ ...permissionEntries([
873
+ 'form-message',
874
+ ], actionDependentPermission({
875
+ preauthorized_actions: ['create', 'update'],
876
+ ask_actions: ['delete'],
877
+ })),
878
+
866
879
  ...permissionEntries([
867
880
  'aggregate-table',
868
881
  ], actionDependentPermission({
@@ -1638,6 +1651,19 @@ const COMMAND_GROUPS = [
1638
1651
  examples: ['openyida custom-button options APP_XXX FORM_XXX --json', 'openyida custom-button create APP_XXX FORM_XXX --config button.json --json'],
1639
1652
  notes: ['Manages native form custom buttons: edit current fields, fill an associated form, invoke a published manual custom-button flow (type 6), or open a page. options reads eligible fields, conditions, forms, flows and display locations. Updates merge action properties and translations; arrays and permissionConfig replace. Changing action type, fillType or urlType requires a complete new actionConfig. Saves back up first and verify by readback; writes are never replayed automatically. delete permanently removes the button definition and requires --yes and --expected-hash from inspect. This differs from removing a display reference. Platform permissions, edition limits, 10-button limit, and completed-process restrictions still apply. No runtime button execution. See docs/guides/custom-buttons.md.'],
1640
1653
  }),
1654
+ command('form-message', ['form-message'], 'form-message <list|inspect|options|create|update|delete> <appType> <formUuid> [messageId] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]', 'help.cmd_form_message', {
1655
+ managedRun: { scope: 'actions', actions: {
1656
+ list: { scope: 'app', appPosition: 1 },
1657
+ inspect: { scope: 'app', appPosition: 1 },
1658
+ options: { scope: 'app', appPosition: 1 },
1659
+ create: { scope: 'app', appPosition: 1, mutation: { operation: 'update_form', formUuidPosition: 2 } },
1660
+ update: { scope: 'app', appPosition: 1, mutation: { operation: 'update_form', formUuidPosition: 2 } },
1661
+ delete: { scope: 'app', appPosition: 1, mutation: { operation: 'update_form', formUuidPosition: 2 } },
1662
+ } },
1663
+ output: 'json',
1664
+ examples: ['openyida form-message options APP_XXX FORM_XXX --json', 'openyida form-message create APP_XXX FORM_XXX --config message.json --json'],
1665
+ notes: ['Manages native form-level message notification bindings, not application settings, custom-page messaging or integration automation. list and inspect preserve native configurations. create and update currently support verified immediate events for ordinary forms and seven native templates; scheduled and process-node writes are rejected until their rule and process context can be verified. The platform exposes no independent enable/disable state, so deletion is the only supported removal action and requires --yes plus the current inspect hash. Writes back up first, detect concurrent changes and require readback; requests are never replayed automatically. Saving a binding does not send a test message or prove delivery. See docs/guides/form-messages.md.'],
1666
+ }),
1641
1667
  command('get-schema', ['get-schema'], 'get-schema <appType> <formUuid|--all> [--summary-json|--field-map-json|--analysis-json]', 'help.cmd_get_schema', { managedRun: { scope: 'app', appPosition: 0 },
1642
1668
  examples: ['openyida get-schema APP_XXX FORM_XXX --field-map-json'],
1643
1669
  }),
@@ -19,6 +19,17 @@ module.exports = {
19
19
  'limit': 'A form supports at most 10 custom buttons.',
20
20
  'readback_mismatch': 'Saved custom button does not match readback. Keep the backup and inspect remote state.'
21
21
  },
22
+ form_message: {
23
+ usage: 'Usage: openyida form-message <list|inspect|options|create|update|delete> <appType> <formUuid> [messageId] [--config <file>] [--expected-hash <hash>] [--backup <file>] [--yes] [--json]',
24
+ invalid_config: 'Invalid form message configuration or argument: {0}',
25
+ invalid_response: 'Incomplete platform form-message response: {0}',
26
+ api_error: 'Form message API failed: {0}. Check login, form administration permission, edition and feature availability.',
27
+ not_found: 'The message notification does not exist in the target form: {0}',
28
+ conflict: 'The message notification changed after it was read. Inspect again before saving.',
29
+ unsupported_trigger: 'This CLI has not verified writes for this trigger: {0}. Existing configurations remain readable.',
30
+ unsupported_form: 'This CLI has not verified notification writes for this form type: {0}. Existing configurations remain readable.',
31
+ readback_mismatch: 'Saved message notification does not match readback. Keep the backup and inspect remote state.',
32
+ },
22
33
  dws_external: {'note': 'dws is an independent external tool. OpenYida only shows installation instructions; it does not download or install it.', 'install_guide': 'Show official DingTalk CLI installation instructions'},
23
34
  cool_card: {'unsupported_environment': 'The Alibaba Group edition does not support building cool cards.', 'description': 'Create and read native YiDA cool cards (interactive and pinned)', 'usage': 'Usage: openyida cool-card <list|templates|create|get> <appType> [cardPageCode] [--type im|onebox] [--name <title>] [--page <n>] [--size <n>] [--search <text>] [--out <file>] [--json]', 'failed': 'Native cool card operation stopped: {0}'},
24
35
  data_card: {'description':'Create, configure, publish and distribute native YiDA data cards','usage':'Usage: openyida data-card <list|create|get|save|publish|probe|release-get|release-save|release-check|release-test|refs> <appType> [cardCode] [--name <title>] [--description <text>] [--schema <file>|--config <file>] [--expected-hash <hash>] [--backup <file>] [--out <file>] [--published|--version <version>] [--page <n>] [--size <n>] [--search <text>] [--allow-send] [--json]','failed':'Native data card operation stopped: {0}','conflict':'The draft changed. Read it again before saving or publishing.','mismatch':'Readback does not match the expected schema or published state. Keep the backup and inspect the card before retrying.'},
@@ -143,6 +154,7 @@ module.exports = {
143
154
  // ── bin/yida.js printHelp() ────────────────────────
144
155
  help: {
145
156
  cmd_custom_button: 'Native custom buttons: inspect and configure form edits, automation flows and page links',
157
+ cmd_form_message: 'Native form notifications: inspect triggers, recipients and templates, then safely maintain bindings',
146
158
  cmd_check_design: 'Validate design documents, theme tokens and PRD handoff',
147
159
  subtitle: 'AI-powered low-code development tool for Yida',
148
160
  usage: 'Usage:',
@@ -19,6 +19,17 @@ module.exports = {
19
19
  'limit': '单个表单最多支持 10 个自定义按钮。',
20
20
  'readback_mismatch': '保存后的配置与回读不一致,请保留备份并检查远端状态。'
21
21
  },
22
+ form_message: {
23
+ usage: '用法:openyida form-message <list|inspect|options|create|update|delete> <appType> <formUuid> [messageId] [--config <文件>] [--expected-hash <哈希>] [--backup <文件>] [--yes] [--json]',
24
+ invalid_config: '消息通知配置或参数无效:{0}',
25
+ invalid_response: '平台返回的消息通知配置不完整:{0}',
26
+ api_error: '表单消息通知接口失败:{0}。请检查登录、表单管理权限、组织版本和功能开关。',
27
+ not_found: '目标表单中不存在消息通知:{0}',
28
+ conflict: '消息通知在读取后发生变化,请重新查询后再保存。',
29
+ unsupported_trigger: '当前 CLI 尚未验证此触发规则的写入协议:{0}。仍可查询已有配置。',
30
+ unsupported_form: '当前 CLI 尚未验证此表单类型的消息通知写入协议:{0}。仍可查询已有配置。',
31
+ readback_mismatch: '保存后的消息通知与回读不一致,请保留备份并检查远端状态。',
32
+ },
22
33
  dws_external: {'note': 'dws 是独立的外部工具;OpenYida 仅提供安装指引,不下载或安装它。', 'install_guide': '显示钉钉 CLI 官方安装指引'},
23
34
  cool_card: {'unsupported_environment': '集团版不支持酷卡片搭建。', 'description': '创建和读取宜搭原生酷卡片(交互卡片与置顶卡片)', 'usage': '用法: openyida cool-card <list|templates|create|get> <appType> [cardPageCode] [--type im|onebox] [--name <title>] [--page <n>] [--size <n>] [--search <text>] [--out <file>] [--json]', 'failed': '原生酷卡片操作已停止:{0}'},
24
35
  data_card: {'description':'创建、配置、发布原生数据卡片并管理投放播报','usage':'用法: openyida data-card <list|create|get|save|publish|probe|release-get|release-save|release-check|release-test|refs> <appType> [cardCode] [--name <title>] [--description <text>] [--schema <file>|--config <file>] [--expected-hash <hash>] [--backup <file>] [--out <file>] [--published|--version <version>] [--page <n>] [--size <n>] [--search <text>] [--allow-send] [--json]','failed':'原生数据卡片操作已停止:{0}','conflict':'卡片草稿已发生变化,请重新读取后再保存或发布。','mismatch':'回读配置或发布状态与预期不一致,请保留备份并检查卡片后再重试。'},
@@ -143,6 +154,7 @@ module.exports = {
143
154
  // ── bin/yida.js printHelp() ────────────────────────
144
155
  help: {
145
156
  cmd_custom_button: '原生自定义按钮:查询、配置填写表单、自动化流及页面跳转',
157
+ cmd_form_message: '原生表单消息通知:查询触发、接收对象与模板并安全维护配置',
146
158
  cmd_check_design: '检查设计文档、主题变量与 PRD 交接',
147
159
  subtitle: '宜搭低代码 AI 开发工具',
148
160
  usage: '用法:',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openyida",
3
- "version": "2026.10.22",
3
+ "version": "2026.10.23",
4
4
  "description": "OpenYida CLI - 宜搭低代码 AI 开发工具(安装即用,零配置)",
5
5
  "bin": {
6
6
  "openyida": "bin/yida.js",
@@ -144,7 +144,7 @@ Fast / Plan 选定的主题和主色属于整个应用:导航、原生页、
144
144
  | `yida-skills/context` | 登录、退出、切换组织、组织版本/容量、Schema、fieldId、执行前检查 | `yida-login`、`yida-logout`、`yida-basic-info`、`yida-get-schema`、`yida-corp-efficiency` |
145
145
  | `yida-skills/app` | 从零搭应用、完整系统、应用启停、应用导航、多语言、AI 老应用主题升级 | `yida-app`、`yida-create-app`、`yida-app-lifecycle`、`yida-nav-group`、`yida-i18n`、`yida-upgrade-app-theme`(仅明确升级要求并 ask_human 确认) |
146
146
  | `yida-skills/design` | 完整应用需求分析、PRD、视觉设计、单页 UI 改造、应用主题色、全局换肤、图片素材 | `yida-requirement-analysis`、`yida-prd`、`yida-design`、`yida-data-manage-ui`、`yida-image-assets` |
147
- | `yida-skills/form` | 表单字段、公式、校验、业务关联规则、原生自定义按钮、打印模板、批量录入、数据记录 | `yida-create-form-page`、`yida-custom-button`、`yida-print-template`、`yida-form-view`、`yida-formula`、`yida-formula-evaluate`、`yida-business-rule`、`yida-canvas-table-form`、`yida-table-form`、`yida-data-management` |
147
+ | `yida-skills/form` | 表单字段、公式、校验、业务关联规则、原生自定义按钮、消息通知、打印模板、批量录入、数据记录 | `yida-create-form-page`、`yida-custom-button`、`yida-form-message`、`yida-print-template`、`yida-form-view`、`yida-formula`、`yida-formula-evaluate`、`yida-business-rule`、`yida-canvas-table-form`、`yida-table-form`、`yida-data-management` |
148
148
  | `yida-skills/process` | 审批、流程表单、流程规则、节点/分支/字段权限、流程代理 | `yida-create-process`、`yida-process-rule`、`yida-flow-node-config`、`yida-agent-center` |
149
149
  | `yida-skills/page` | 工作台、办事门户、任务看板、自定义展示页、页面源码开发、平台 JSX 组件页面维护、页面发布、页面内导航、PPT 页面 | `yida-create-page`、`yida-canvas-custom-page`、`yida-custom-page`、`yida-canvas-data-binding`、`yida-task-center`(仅显式选择自定义待办)、`yida-canvas-upgrade`、`yida-publish-page`、`yida-openyida-publish-guard`、`yida-density`、`yida-nav-shell`、`yida-ppt-slider` |
150
150
  | `yida-skills/analytics` | 用户显式指定宜搭原生数据卡片(含其投放播报);聚合表、虚拟视图、报表、统计、图表、Recharts、ECharts、经营分析看板、驾驶舱、大屏 | `yida-data-card`、`yida-aggregate-table`、`yida-report`、`yida-rechart`、`yida-chart`、`yida-dashboard` |
@@ -200,6 +200,7 @@ Fast / Plan 选定的主题和主色属于整个应用:导航、原生页、
200
200
  | 业务操作队列、业务工作台或办事门户页面 | `yida-canvas-custom-page`;接业务数据时加载 `yida-canvas-data-binding`,不因页面名称或辅助统计转为经营看板;不顺带新增自定义审批待办中心 |
201
201
  | 批量录入、表格填写、多行编辑 | 默认 `yida-canvas-table-form`;已检测到平台 JSX 组件页面、native 页面或存量源码使用 `this.utils.yida.saveFormData` 时用 `yida-table-form` |
202
202
  | 页面设置中的自定义按钮、客户跟进/分配、审批后发起订单、按钮触发自动化或打开页面 | `yida-custom-button`;配置原生按钮定义和显示位置,自定义页面内 React 按钮不走此入口 |
203
+ | 页面设置中的消息通知、表单提交/编辑/删除/评论后通知、原生工作通知与详情/置顶卡片 | `yida-form-message`;选择发送时机、接收人和七种消息样式,保存后核对配置;按用户要求执行真实发送测试,沿用已明确的接收对象与测试授权 |
203
204
  | 打印这条数据/数据详情链接、原生打印模板、打印设置、订单/报价/出入库/审批单、单据版式与字段明细、字段二维码/条形码、备份恢复和PDF生成 | `yida-print-template`;复用已有表单和模板,以真实打印字典生成动态内容;Word组件、叮当打印和浏览器页面打印不走此入口 |
204
205
  | 原生数据管理页视图列表、显示列配置、字段显隐与顺序、列宽、冻结列、默认筛选、多视图 | `yida-form-view`;使用原生配置读写,保存前备份并回读,不通过 CSS 隐藏字段或重建自定义列表 |
205
206
  | 当前数据管理页换风格、只留横线、突出新增/筛选/行操作、列级强调、状态列标签、数据管理 UI 异常、自定义简单数据管理 | `yida-data-manage-ui`(优先于通用美化);保留承载类型和业务,从目标推导维度、定位 token/CSS 并按页改写;备份发布并实测,不要求选择配方 |
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: yida-form-message
3
+ description: 为已有普通表单配置消息通知,选择新增、编辑、删除或评论后的发送时机、接收人和消息样式。支持工作通知、详情卡片、图文卡片、评论卡片和群置顶卡片等七种模板;按用户要求执行真实发送测试。
4
+ ---
5
+
6
+ # 表单消息通知
7
+
8
+ 为已有普通表单配置“什么时候、向谁、发送什么通知”。使用 `openyida form-message` 查询、新增、修改和删除「页面设置 > 消息通知」中的配置。保存后,表单事件发生时发送通知。
9
+
10
+ ## 适用场景
11
+
12
+ | 用户需求 | 使用入口 |
13
+ | --- | --- |
14
+ | 配置表单新增、编辑、删除或评论后的消息通知 | 本技能 |
15
+ | 查询日期定时、流程开始/结束、流程节点或审批操作的已有通知 | 本技能查询;这些类型的修改使用宜搭页面 |
16
+ | 配置集成自动化中的通知节点 | `yida-integration` |
17
+ | 应用级通知、自定义页面消息、机器人消息 | 对应功能入口,独立于表单消息设置 |
18
+
19
+ ## 操作步骤
20
+
21
+ 1. 查询现有通知和可用选项,核实目标表单、字段、接收人和模板。
22
+ 2. 按用户要求准备配置文件,再创建或修改通知。首次配置可从[发送给发起人的示例](references/guide.md)开始。
23
+ 3. 核对保存结果中的 `readbackVerified: true`,交付可点击的消息通知配置链接(CLI 返回的 `configUrl`),附上通知 ID、标题、触发时机和接收对象。完整应用搭建和单点新增、修改通知均按此要求交付。用户要求真实测试时,按已指定的对象和范围执行并确认送达;已有明确授权可直接沿用。
24
+
25
+ `configUrl` 指向该表单的消息通知列表,地址为 `<当前认证环境域名>/<appType>/admin/<formUuid>/settings/messageSetting`。使用返回的真实域名与标识;用户打开后可按通知标题找到本次配置。
26
+
27
+ ```bash
28
+ openyida form-message list <appType> <formUuid> --json
29
+ openyida form-message options <appType> <formUuid> --json
30
+ openyida form-message inspect <appType> <formUuid> <messageId> --json
31
+ openyida form-message create <appType> <formUuid> --config message.json --json
32
+ openyida form-message update <appType> <formUuid> <messageId> --config patch.json --expected-hash <configHash> --json
33
+ ```
34
+
35
+ ## 配置规则
36
+
37
+ 普通表单支持新增、编辑、删除和评论后通知。接收对象支持指定人员、角色、表单人员字段和发起人;群置顶模板使用平台群 ID。模板中文名称、参数和选用方式见[配置指南](references/guide.md)。
38
+
39
+ 更新时 `template` 属性合并,`recipients`、`groups` 和 `template.fields` 数组整体替换。CLI 写入前自动备份并检查配置是否变化。遇到超时或回读不一致,保留 `backupPath`,先执行 `list` / `inspect` 确认远端状态,再决定下一步;不自动重放写请求。
40
+
41
+ ## 删除与测试
42
+
43
+ 用户明确要求移除通知时,先用 `inspect` 获取最新 `configHash`,再执行删除。平台当前提供配置删除功能,没有独立启停接口;`isSendMessage` 是平台原生字段,不是通知开关。
44
+
45
+ ```bash
46
+ openyida form-message delete <appType> <formUuid> <messageId> --expected-hash <configHash> --yes --json
47
+ ```
48
+
49
+ 删除后重建会产生新 ID;备份保留原配置内容,恢复时需重新核对引用。真实测试使用独立测试目标,业务配置按用户指定的表单执行。测试前确认用户已授权触发表单事件及对应接收对象;交付时分别记录配置保存、事件触发和接收端送达结果。测试结束后,按用户要求保留或清理配置和数据。
50
+
51
+ ## 参考文件
52
+
53
+ - [配置指南](references/guide.md):四种触发、七种模板、接收对象、变量格式和配置示例。
@@ -0,0 +1,111 @@
1
+ # 表单消息通知配置
2
+
3
+ 选择触发时机、消息样式或接收对象,以及编写配置文件时,查阅本指南。
4
+
5
+ ## 场景与触发
6
+
7
+ | 场景 | trigger | 说明 |
8
+ | --- | --- | --- |
9
+ | 新记录提醒 | `FORM_DATA_CREATE` | 表单创建成功后发送 |
10
+ | 记录被修改 | `FORM_DATA_EDIT` | 任意编辑成功后发送;当前配置范围为整条记录的编辑事件 |
11
+ | 删除审计提醒 | `FORM_DATA_DELETE` | 删除成功后发送,接收人需在删除后仍可解析 |
12
+ | 评论协作提醒 | `FORM_COMMENT` | 评论成功后发送 |
13
+
14
+ 日期字段定时、流程开始/结束、到达节点和审批操作的已有通知支持查询;配置这些发送规则时,使用宜搭页面设置对应的时间、流程版本、节点和审批结果。
15
+
16
+ ## 七种消息样式
17
+
18
+ | 页面名称 | `template.key` | 主要用途 | 内容参数 |
19
+ | --- | --- | --- | --- |
20
+ | 纯文本普通消息(工作通知) | `workNotice` | 向接收人发送文字提醒 | `content` |
21
+ | 图文详情卡片 | `detailImage` | 用图文卡片展示表单字段,配置详情入口 | `fields` |
22
+ | 图文卡片 | `imageCard` | 用图文展示通知内容 | `content` |
23
+ | 纯文本详情卡片 | `detailText` | 用文字卡片展示表单字段,配置详情入口 | `fields` |
24
+ | 图文评论卡片 | `commentCard` | 展示通知内容,配置评论入口 | `content` |
25
+ | 图文群置顶卡片 | `pinnedImage` | 在指定群中置顶图文通知 | `content` |
26
+ | 纯文本详情置顶卡片 | `pinnedText` | 在指定群中置顶文字通知,配置详情入口 | `content` |
27
+
28
+ 前五种模板通过 `recipients` 选择接收人;后两种通过 `groups` 选择群。七种模板均有配置支持,实际送达、详情跳转、评论和群置顶效果分别在接收端验证。
29
+
30
+ ## 配置示例
31
+
32
+ ### 发送给发起人
33
+
34
+ 将下面的配置保存为 `message.json`,用于普通表单新增后通知发起人:
35
+
36
+ ```json
37
+ {
38
+ "trigger": "FORM_DATA_CREATE",
39
+ "recipients": [
40
+ {"type": "variable", "value": "originator"}
41
+ ],
42
+ "groups": [],
43
+ "template": {
44
+ "key": "workNotice",
45
+ "title": "订单已提交",
46
+ "content": "订单 $!{serialNo} 已提交,请及时处理",
47
+ "fields": [],
48
+ "image": [],
49
+ "button": null
50
+ }
51
+ }
52
+ ```
53
+
54
+ 变量格式为 `$!{变量或字段ID}`。系统字段从 `options.systemFields` 选取;表单字段从 `options.messageFields` 选取实际 ID。详情卡片中的 `fields` 也使用这两类选项。
55
+
56
+ ### 展示表单详情
57
+
58
+ 下面的金额字段 ID 替换为 `options.messageFields` 中的实际 ID:
59
+
60
+ ```json
61
+ {
62
+ "trigger": "FORM_DATA_CREATE",
63
+ "recipients": [{"type": "variable", "value": "originator"}],
64
+ "groups": [],
65
+ "template": {
66
+ "key": "detailText",
67
+ "title": "订单详情",
68
+ "fields": [
69
+ {"type": "variable", "value": "serialNo", "title": "流水号"},
70
+ {"type": "formField", "value": "实际金额字段ID", "title": "金额"}
71
+ ],
72
+ "image": [],
73
+ "button": {"name": "查看详情", "type": "formInstDetail", "value": ""}
74
+ }
75
+ }
76
+ ```
77
+
78
+ `detailImage` / `detailText` 使用 `fields`;`workNotice` / `imageCard` / `commentCard` / `pinnedImage` / `pinnedText` 使用 `content`。`button.type` 可为 `formInstDetail`、`comment` 或 `custom`;自定义链接仅接受无账号密码的 HTTP(S) URL。
79
+
80
+ ### 在群中置顶
81
+
82
+ 群置顶模板把 `recipients` 设为空,并提供平台返回的群标识:
83
+
84
+ ```json
85
+ {
86
+ "trigger": "FORM_DATA_EDIT",
87
+ "recipients": [],
88
+ "groups": [{"cid": "实际群ID", "name": "业务群"}],
89
+ "template": {
90
+ "key": "pinnedText",
91
+ "title": "订单状态更新",
92
+ "content": "订单状态已更新",
93
+ "fields": [],
94
+ "image": []
95
+ }
96
+ }
97
+ ```
98
+
99
+ ## 接收对象
100
+
101
+ - `employee`:指定人员的平台 ID,选择目标组织中仍有效且可见的人员。
102
+ - `role`:宜搭角色 ID;实际成员按触发时权限解析。
103
+ - `formField`:目标表单中的人员字段,必须出现在 `options.recipientFields`。
104
+ - `variable`:当前仅支持原生 `originator`(发起人)。
105
+ - `groups`:原生群置顶卡片目标;群权限、酷应用支持和成员关系仍由平台校验。
106
+
107
+ 向指定人员发送时,将接收项设为 `{"type":"employee","value":"实际人员ID"}`;向表单人员字段中的成员发送时,使用 `{"type":"formField","value":"实际人员字段ID"}`。一次消息最多选择 100 人,具体可见范围和可用能力以目标组织为准。
108
+
109
+ ## 备份位置
110
+
111
+ 创建、修改和删除配置时,CLI 自动把原配置备份到 `.cache/openyida/form-message/`。传入 `--backup` 可指定一个新的备份文件路径;操作返回的 `backupPath` 可用于查找该文件。
@@ -49,6 +49,19 @@
49
49
  ],
50
50
  "requires_login": true
51
51
  },
52
+ {
53
+ "name": "yida-form-message",
54
+ "path": "skills/yida-form-message/SKILL.md",
55
+ "display_name": "表单消息通知",
56
+ "description": "配置普通表单新增、编辑、删除或评论后的消息通知,选择接收人和七种消息样式;保存后核对配置,按用户要求执行真实发送测试。",
57
+ "category": "yida-skills/form",
58
+ "tags": ["消息通知", "表单通知", "工作通知", "消息卡片", "群置顶卡片"],
59
+ "aliases": ["表单消息", "页面消息通知", "form-message"],
60
+ "positive_signals": ["配置表单提交后消息通知", "修改页面设置里的消息通知", "表单编辑后通知发起人", "查询消息通知接收对象", "配置表单原生消息卡片"],
61
+ "negative_signals": ["自定义页面发送消息", "机器人群消息", "应用级通知", "集成自动化发消息", "原生酷卡片资源"],
62
+ "command_ids": ["form-message"],
63
+ "requires_login": true
64
+ },
52
65
  {
53
66
  "name": "yida-cool-card",
54
67
  "path": "skills/yida-cool-card/SKILL.md",